830 lines
22 KiB
TypeScript
830 lines
22 KiB
TypeScript
import { KEYS } from "../data/mock/keys";
|
||
import { getSession } from "../data/mock/store";
|
||
import { newId } from "./id";
|
||
import { readJson, writeJson } from "./storage";
|
||
import { nowUnixNano } from "./time";
|
||
|
||
/** 計費維度(對齊未來付費方案) */
|
||
export type UsageMeter = "ai_copy" | "ai_research" | "web_search" | "ai_image";
|
||
|
||
export type PlanId = "free" | "starter" | "pro";
|
||
|
||
export type UsageEvent = {
|
||
id: string;
|
||
/** 使用者 uid(舊資料可能缺,會歸到 unknown) */
|
||
uid: string;
|
||
meter: UsageMeter;
|
||
/** 消耗點數 */
|
||
credits: number;
|
||
/** 使用者可讀說明 */
|
||
label: string;
|
||
/** 功能來源,除錯/對帳 */
|
||
source: string;
|
||
/** unix nanoseconds UTC */
|
||
created_at: number;
|
||
};
|
||
|
||
export type PlanDef = {
|
||
id: PlanId;
|
||
name: string;
|
||
price_label: string;
|
||
price_twd: number;
|
||
blurb: string;
|
||
blurb_key: string;
|
||
monthly_credits: number;
|
||
soft_caps: Record<UsageMeter, number>;
|
||
};
|
||
|
||
/** 每人方案/是否不擋額度(用量一律計算) */
|
||
export type UsageMemberPrefs = {
|
||
plan_id: PlanId;
|
||
/** 達方案上限仍可繼續用;AI/Search/點數照樣記帳 */
|
||
unlimited: boolean;
|
||
};
|
||
|
||
export const METER_META: Record<
|
||
UsageMeter,
|
||
{ label: string; hint: string; order: number; group: "ai" | "search" }
|
||
> = {
|
||
ai_copy: {
|
||
label: "AI 文案",
|
||
hint: "回覆草稿、靈感聊天、仿寫、結構分析",
|
||
order: 0,
|
||
group: "ai",
|
||
},
|
||
ai_research: {
|
||
label: "AI 研究",
|
||
hint: "海巡周邊知識、上網功課",
|
||
order: 1,
|
||
group: "ai",
|
||
},
|
||
web_search: {
|
||
label: "搜尋",
|
||
hint: "熱點刷新、關鍵字搜、研究檢索",
|
||
order: 2,
|
||
group: "search",
|
||
},
|
||
ai_image: {
|
||
label: "AI 生圖",
|
||
hint: "文生圖/配圖",
|
||
order: 3,
|
||
group: "ai",
|
||
},
|
||
};
|
||
|
||
export const PLANS: Record<PlanId, PlanDef> = {
|
||
free: {
|
||
id: "free",
|
||
name: "Free",
|
||
price_label: "NT$0/月",
|
||
price_twd: 0,
|
||
blurb: "試用與個人輕量經營",
|
||
blurb_key: "usage.plan.free.blurb",
|
||
monthly_credits: 80,
|
||
soft_caps: {
|
||
ai_copy: 40,
|
||
ai_research: 8,
|
||
web_search: 30,
|
||
ai_image: 3,
|
||
},
|
||
},
|
||
starter: {
|
||
id: "starter",
|
||
name: "Starter",
|
||
price_label: "NT$590/月",
|
||
price_twd: 590,
|
||
blurb: "小團隊日常發文與海巡",
|
||
blurb_key: "usage.plan.starter.blurb",
|
||
monthly_credits: 500,
|
||
soft_caps: {
|
||
ai_copy: 250,
|
||
ai_research: 60,
|
||
web_search: 200,
|
||
ai_image: 20,
|
||
},
|
||
},
|
||
pro: {
|
||
id: "pro",
|
||
name: "Pro",
|
||
price_label: "NT$1,990/月",
|
||
price_twd: 1990,
|
||
blurb: "多帳、重度 AI 與研究",
|
||
blurb_key: "usage.plan.pro.blurb",
|
||
monthly_credits: 2000,
|
||
soft_caps: {
|
||
ai_copy: 1000,
|
||
ai_research: 250,
|
||
web_search: 800,
|
||
ai_image: 80,
|
||
},
|
||
},
|
||
};
|
||
|
||
export const DEFAULT_CREDIT_COST: Record<UsageMeter, number> = {
|
||
ai_copy: 1,
|
||
ai_research: 3,
|
||
web_search: 1,
|
||
ai_image: 5,
|
||
};
|
||
|
||
export type UsageMonthSummary = {
|
||
month_key: string;
|
||
uid: string;
|
||
plan: PlanDef;
|
||
unlimited: boolean;
|
||
total_credits: number;
|
||
remaining_credits: number;
|
||
/** 無限時為 0;有上限才算用掉比例 */
|
||
pct: number;
|
||
/** AI 次數合計(文案 + 研究 + 生圖) */
|
||
ai_calls: number;
|
||
/** 搜尋次數 */
|
||
search_calls: number;
|
||
by_meter: Record<
|
||
UsageMeter,
|
||
{ credits: number; count: number; soft_cap: number; pct: number }
|
||
>;
|
||
events: UsageEvent[];
|
||
};
|
||
|
||
export type TenantUsageMemberRow = {
|
||
uid: string;
|
||
email: string;
|
||
display_name: string;
|
||
unlimited: boolean;
|
||
plan_id: PlanId;
|
||
/** 區間內方案配給(購買/額度) */
|
||
purchased_credits: number;
|
||
/** 區間內消耗 */
|
||
total_credits: number;
|
||
ai_calls: number;
|
||
search_calls: number;
|
||
remaining_credits: number;
|
||
pct: number;
|
||
};
|
||
|
||
export type TenantUsageSummary = {
|
||
month_key: string;
|
||
/** @deprecated 等同 consumed_credits;保留相容 */
|
||
total_credits: number;
|
||
purchased_credits: number;
|
||
consumed_credits: number;
|
||
remaining_credits: number;
|
||
ai_calls: number;
|
||
search_calls: number;
|
||
by_meter: Record<UsageMeter, { credits: number; count: number }>;
|
||
members: TenantUsageMemberRow[];
|
||
};
|
||
|
||
/** 圖表時間粒度 */
|
||
export type UsageGranularity = "day" | "month" | "year";
|
||
|
||
export type UsageTimeBucket = {
|
||
/** day: YYYY-MM-DD · month: YYYY-MM · year: YYYY */
|
||
key: string;
|
||
label: string;
|
||
purchased_credits: number;
|
||
consumed_credits: number;
|
||
ai_calls: number;
|
||
search_calls: number;
|
||
};
|
||
|
||
export type TenantUsageAnalytics = {
|
||
granularity: UsageGranularity;
|
||
/** 區間起迄(含)YYYY-MM-DD */
|
||
from: string;
|
||
to: string;
|
||
range_label: string;
|
||
purchased_credits: number;
|
||
consumed_credits: number;
|
||
remaining_credits: number;
|
||
pct: number;
|
||
ai_calls: number;
|
||
search_calls: number;
|
||
by_meter: Record<UsageMeter, { credits: number; count: number }>;
|
||
series: UsageTimeBucket[];
|
||
members: TenantUsageMemberRow[];
|
||
};
|
||
|
||
export type TenantAnalyticsQuery = {
|
||
granularity?: UsageGranularity;
|
||
/** YYYY-MM-DD */
|
||
from?: string;
|
||
/** YYYY-MM-DD */
|
||
to?: string;
|
||
};
|
||
|
||
function monthKeyFromMs(ms: number): string {
|
||
const d = new Date(ms);
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||
}
|
||
|
||
export function currentMonthKey(now = Date.now()): string {
|
||
return monthKeyFromMs(now);
|
||
}
|
||
|
||
function loadEvents(): UsageEvent[] {
|
||
const raw = readJson<(UsageEvent & { uid?: string })[]>(KEYS.usageEvents, []);
|
||
return raw.map((e) => ({
|
||
...e,
|
||
uid: e.uid || "unknown",
|
||
}));
|
||
}
|
||
|
||
function saveEvents(list: UsageEvent[]): void {
|
||
writeJson(KEYS.usageEvents, list.slice(0, 2000));
|
||
}
|
||
|
||
type PrefsMap = Record<string, UsageMemberPrefs>;
|
||
|
||
function loadPrefsMap(): PrefsMap {
|
||
return readJson(KEYS.usageMemberPrefs, {} as PrefsMap);
|
||
}
|
||
|
||
function savePrefsMap(map: PrefsMap): void {
|
||
writeJson(KEYS.usageMemberPrefs, map);
|
||
}
|
||
|
||
function normalizePlanId(id: unknown): PlanId {
|
||
if (id === "starter" || id === "pro" || id === "free") return id;
|
||
return "free";
|
||
}
|
||
|
||
export function getMemberPrefs(uid: string): UsageMemberPrefs {
|
||
const map = loadPrefsMap();
|
||
const row = map[uid];
|
||
if (row) {
|
||
return {
|
||
plan_id: normalizePlanId(row.plan_id),
|
||
unlimited: row.unlimited === true,
|
||
};
|
||
}
|
||
// 相容:舊全域方案
|
||
const legacy = readJson<string>(KEYS.usagePlanId, "free");
|
||
return { plan_id: normalizePlanId(legacy), unlimited: false };
|
||
}
|
||
|
||
export function setMemberPrefs(
|
||
uid: string,
|
||
patch: Partial<UsageMemberPrefs>,
|
||
): UsageMemberPrefs {
|
||
const map = loadPrefsMap();
|
||
const cur = getMemberPrefs(uid);
|
||
const next: UsageMemberPrefs = {
|
||
plan_id: patch.plan_id != null ? normalizePlanId(patch.plan_id) : cur.plan_id,
|
||
unlimited: patch.unlimited != null ? patch.unlimited === true : cur.unlimited,
|
||
};
|
||
map[uid] = next;
|
||
savePrefsMap(map);
|
||
return next;
|
||
}
|
||
|
||
/** @deprecated 改用 getMemberPrefs(uid) */
|
||
export function getPlanId(uid?: string): PlanId {
|
||
if (uid) return getMemberPrefs(uid).plan_id;
|
||
const s = getSession()?.member?.uid;
|
||
return getMemberPrefs(s || "unknown").plan_id;
|
||
}
|
||
|
||
/** @deprecated 改用 setMemberPrefs */
|
||
export function setPlanId(id: PlanId, uid?: string): void {
|
||
const s = uid || getSession()?.member?.uid || "unknown";
|
||
setMemberPrefs(s, { plan_id: id });
|
||
}
|
||
|
||
function sessionUid(): string {
|
||
return getSession()?.member?.uid || "unknown";
|
||
}
|
||
|
||
/**
|
||
* 記一筆用量。
|
||
* - 任何人(含「不擋額度」)每次呼叫都寫入,永不跳過計算
|
||
* - unlimited 不影響是否記帳,只表示超過方案仍可繼續用
|
||
*/
|
||
export function recordUsage(opts: {
|
||
meter: UsageMeter;
|
||
label: string;
|
||
source: string;
|
||
credits?: number;
|
||
uid?: string;
|
||
}): UsageEvent {
|
||
const uid = opts.uid || sessionUid();
|
||
const credits =
|
||
opts.credits != null && opts.credits > 0
|
||
? opts.credits
|
||
: DEFAULT_CREDIT_COST[opts.meter];
|
||
const ev: UsageEvent = {
|
||
id: newId("use"),
|
||
uid,
|
||
meter: opts.meter,
|
||
credits,
|
||
label: opts.label,
|
||
source: opts.source,
|
||
created_at: nowUnixNano(),
|
||
};
|
||
const list = loadEvents();
|
||
list.unshift(ev);
|
||
saveEvents(list);
|
||
return ev;
|
||
}
|
||
|
||
export function listUsageEvents(opts?: {
|
||
limit?: number;
|
||
uid?: string;
|
||
monthKey?: string;
|
||
}): UsageEvent[] {
|
||
const limit = opts?.limit ?? 100;
|
||
const monthKey = opts?.monthKey;
|
||
let list = loadEvents();
|
||
if (opts?.uid) list = list.filter((e) => e.uid === opts.uid);
|
||
if (monthKey) {
|
||
list = list.filter((e) => {
|
||
const ms = Math.floor(e.created_at / 1_000_000);
|
||
return monthKeyFromMs(ms) === monthKey;
|
||
});
|
||
}
|
||
return list.slice(0, limit);
|
||
}
|
||
|
||
function emptyByMeter(plan: PlanDef): UsageMonthSummary["by_meter"] {
|
||
const by_meter = {} as UsageMonthSummary["by_meter"];
|
||
for (const m of Object.keys(METER_META) as UsageMeter[]) {
|
||
by_meter[m] = {
|
||
credits: 0,
|
||
count: 0,
|
||
soft_cap: plan.soft_caps[m],
|
||
pct: 0,
|
||
};
|
||
}
|
||
return by_meter;
|
||
}
|
||
|
||
function aggregateEvents(
|
||
events: UsageEvent[],
|
||
plan: PlanDef,
|
||
_unlimited: boolean,
|
||
): Pick<
|
||
UsageMonthSummary,
|
||
"total_credits" | "remaining_credits" | "pct" | "by_meter" | "ai_calls" | "search_calls"
|
||
> {
|
||
// 用量一律完整計算(與是否「不擋額度」無關)
|
||
const by_meter = emptyByMeter(plan);
|
||
let total = 0;
|
||
for (const e of events) {
|
||
total += e.credits;
|
||
const row = by_meter[e.meter];
|
||
if (row) {
|
||
row.credits += e.credits;
|
||
row.count += 1;
|
||
}
|
||
}
|
||
for (const m of Object.keys(by_meter) as UsageMeter[]) {
|
||
const row = by_meter[m];
|
||
// 可超過 soft_cap:pct 最高顯示 100+ 也 cap 在 999 供 UI 參考
|
||
row.pct =
|
||
row.soft_cap > 0 ? Math.min(999, Math.round((row.count / row.soft_cap) * 100)) : 0;
|
||
}
|
||
const remaining = Math.max(0, plan.monthly_credits - total);
|
||
const pct =
|
||
plan.monthly_credits > 0
|
||
? Math.min(999, Math.round((total / plan.monthly_credits) * 100))
|
||
: 0;
|
||
|
||
let ai_calls = 0;
|
||
let search_calls = 0;
|
||
for (const m of Object.keys(METER_META) as UsageMeter[]) {
|
||
if (METER_META[m].group === "ai") ai_calls += by_meter[m].count;
|
||
else search_calls += by_meter[m].count;
|
||
}
|
||
|
||
return {
|
||
total_credits: total,
|
||
remaining_credits: remaining,
|
||
pct,
|
||
by_meter,
|
||
ai_calls,
|
||
search_calls,
|
||
};
|
||
}
|
||
|
||
export function summarizeUsage(
|
||
uid?: string,
|
||
monthKey = currentMonthKey(),
|
||
): UsageMonthSummary {
|
||
const target = uid || sessionUid();
|
||
const prefs = getMemberPrefs(target);
|
||
const plan = PLANS[prefs.plan_id];
|
||
const events = loadEvents().filter((e) => {
|
||
if (e.uid !== target) return false;
|
||
const ms = Math.floor(e.created_at / 1_000_000);
|
||
return monthKeyFromMs(ms) === monthKey;
|
||
});
|
||
|
||
const agg = aggregateEvents(events, plan, prefs.unlimited);
|
||
return {
|
||
month_key: monthKey,
|
||
uid: target,
|
||
plan,
|
||
unlimited: prefs.unlimited,
|
||
...agg,
|
||
events: events.slice(0, 80),
|
||
};
|
||
}
|
||
|
||
function pad2(n: number): string {
|
||
return String(n).padStart(2, "0");
|
||
}
|
||
|
||
function toDateKey(d: Date): string {
|
||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||
}
|
||
|
||
function parseDateKey(key: string): Date | null {
|
||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec((key || "").trim());
|
||
if (!m) return null;
|
||
const y = Number(m[1]);
|
||
const mo = Number(m[2]);
|
||
const day = Number(m[3]);
|
||
if (!y || mo < 1 || mo > 12 || day < 1 || day > 31) return null;
|
||
const d = new Date(y, mo - 1, day);
|
||
if (d.getFullYear() !== y || d.getMonth() !== mo - 1 || d.getDate() !== day) return null;
|
||
return d;
|
||
}
|
||
|
||
function startOfDay(d: Date): Date {
|
||
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||
}
|
||
|
||
function addDays(d: Date, n: number): Date {
|
||
const x = new Date(d);
|
||
x.setDate(x.getDate() + n);
|
||
return x;
|
||
}
|
||
|
||
function daysInMonth(y: number, monthIndex0: number): number {
|
||
return new Date(y, monthIndex0 + 1, 0).getDate();
|
||
}
|
||
|
||
function eventDayKey(e: UsageEvent): string {
|
||
return toDateKey(new Date(Math.floor(e.created_at / 1_000_000)));
|
||
}
|
||
|
||
function eventMonthKey(e: UsageEvent): string {
|
||
return monthKeyFromMs(Math.floor(e.created_at / 1_000_000));
|
||
}
|
||
|
||
function eventYearKey(e: UsageEvent): string {
|
||
return String(new Date(Math.floor(e.created_at / 1_000_000)).getFullYear());
|
||
}
|
||
|
||
function memberMonthlyQuota(uid: string): number {
|
||
return PLANS[getMemberPrefs(uid).plan_id].monthly_credits;
|
||
}
|
||
|
||
/** 區間內「購買/配給」點數:依每人方案月額度 × 涵蓋月數(日視圖按當月天數比例) */
|
||
function purchasedInRange(
|
||
memberUids: string[],
|
||
from: Date,
|
||
to: Date,
|
||
granularity: UsageGranularity,
|
||
): number {
|
||
let total = 0;
|
||
for (const uid of memberUids) {
|
||
const monthly = memberMonthlyQuota(uid);
|
||
if (granularity === "year") {
|
||
// 逐年:每年 12 個月 × 月額度(區間裁切到涵蓋月)
|
||
const start = new Date(from.getFullYear(), from.getMonth(), 1);
|
||
const end = new Date(to.getFullYear(), to.getMonth(), 1);
|
||
for (let y = start.getFullYear(); y <= end.getFullYear(); y++) {
|
||
const m0 = y === start.getFullYear() ? start.getMonth() : 0;
|
||
const m1 = y === end.getFullYear() ? end.getMonth() : 11;
|
||
total += monthly * (m1 - m0 + 1);
|
||
}
|
||
} else if (granularity === "month") {
|
||
const start = new Date(from.getFullYear(), from.getMonth(), 1);
|
||
const end = new Date(to.getFullYear(), to.getMonth(), 1);
|
||
let cur = start;
|
||
while (cur <= end) {
|
||
total += monthly;
|
||
cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1);
|
||
}
|
||
} else {
|
||
// day:每日配給 = 月額度 / 當月天數
|
||
let cur = startOfDay(from);
|
||
const end = startOfDay(to);
|
||
while (cur <= end) {
|
||
const dim = daysInMonth(cur.getFullYear(), cur.getMonth());
|
||
total += monthly / dim;
|
||
cur = addDays(cur, 1);
|
||
}
|
||
}
|
||
}
|
||
return Math.round(total);
|
||
}
|
||
|
||
function defaultRange(granularity: UsageGranularity): { from: Date; to: Date } {
|
||
const to = startOfDay(new Date());
|
||
if (granularity === "day") {
|
||
return { from: addDays(to, -29), to };
|
||
}
|
||
if (granularity === "year") {
|
||
return {
|
||
from: new Date(to.getFullYear() - 2, 0, 1),
|
||
to,
|
||
};
|
||
}
|
||
// month:近 12 個月
|
||
return {
|
||
from: new Date(to.getFullYear(), to.getMonth() - 11, 1),
|
||
to,
|
||
};
|
||
}
|
||
|
||
function bucketKeys(
|
||
from: Date,
|
||
to: Date,
|
||
granularity: UsageGranularity,
|
||
): { key: string; label: string; start: Date; end: Date }[] {
|
||
const out: { key: string; label: string; start: Date; end: Date }[] = [];
|
||
if (granularity === "day") {
|
||
let cur = startOfDay(from);
|
||
const end = startOfDay(to);
|
||
while (cur <= end) {
|
||
const key = toDateKey(cur);
|
||
out.push({
|
||
key,
|
||
label: `${cur.getMonth() + 1}/${cur.getDate()}`,
|
||
start: cur,
|
||
end: cur,
|
||
});
|
||
cur = addDays(cur, 1);
|
||
}
|
||
return out;
|
||
}
|
||
if (granularity === "month") {
|
||
let cur = new Date(from.getFullYear(), from.getMonth(), 1);
|
||
const end = new Date(to.getFullYear(), to.getMonth(), 1);
|
||
while (cur <= end) {
|
||
const y = cur.getFullYear();
|
||
const m = cur.getMonth();
|
||
const key = `${y}-${pad2(m + 1)}`;
|
||
const last = new Date(y, m + 1, 0);
|
||
out.push({
|
||
key,
|
||
label: `${y}/${pad2(m + 1)}`,
|
||
start: cur,
|
||
end: last,
|
||
});
|
||
cur = new Date(y, m + 1, 1);
|
||
}
|
||
return out;
|
||
}
|
||
// year
|
||
for (let y = from.getFullYear(); y <= to.getFullYear(); y++) {
|
||
out.push({
|
||
key: String(y),
|
||
label: `${y}`,
|
||
start: new Date(y, 0, 1),
|
||
end: new Date(y, 11, 31),
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function eventInBucket(e: UsageEvent, granularity: UsageGranularity, key: string): boolean {
|
||
if (granularity === "day") return eventDayKey(e) === key;
|
||
if (granularity === "month") return eventMonthKey(e) === key;
|
||
return eventYearKey(e) === key;
|
||
}
|
||
|
||
function purchasedForBucket(
|
||
memberUids: string[],
|
||
bucket: { start: Date; end: Date; key: string },
|
||
granularity: UsageGranularity,
|
||
): number {
|
||
return purchasedInRange(memberUids, bucket.start, bucket.end, granularity);
|
||
}
|
||
|
||
/**
|
||
* mock:若歷史事件太少,補近半年示意資料,方便圖表預覽。
|
||
* 只在 events < 12 時寫入一次。
|
||
*/
|
||
export function ensureDemoUsageHistory(
|
||
members: { uid: string }[],
|
||
): void {
|
||
const list = loadEvents();
|
||
if (list.length >= 12) return;
|
||
if (!members.length) return;
|
||
|
||
const meters = Object.keys(METER_META) as UsageMeter[];
|
||
const labels: Record<UsageMeter, string> = {
|
||
ai_copy: "示意:文案",
|
||
ai_research: "示意:研究",
|
||
web_search: "示意:搜尋",
|
||
ai_image: "示意:生圖",
|
||
};
|
||
const now = Date.now();
|
||
const seeded: UsageEvent[] = [];
|
||
for (let dayAgo = 160; dayAgo >= 0; dayAgo -= 2 + (dayAgo % 3)) {
|
||
const uid = members[dayAgo % members.length]!.uid;
|
||
const meter = meters[dayAgo % meters.length]!;
|
||
const ms = now - dayAgo * 86_400_000 - (dayAgo % 7) * 3_600_000;
|
||
seeded.push({
|
||
id: newId("use"),
|
||
uid,
|
||
meter,
|
||
credits: DEFAULT_CREDIT_COST[meter] * (1 + (dayAgo % 3)),
|
||
label: labels[meter],
|
||
source: "demo.seed",
|
||
created_at: ms * 1_000_000,
|
||
});
|
||
}
|
||
saveEvents([...seeded, ...list].slice(0, 2000));
|
||
}
|
||
|
||
/** 租戶區間分析:購買 vs 消耗、時間序列、每人 */
|
||
export function summarizeTenantAnalytics(
|
||
members: { uid: string; email: string; display_name: string }[],
|
||
query: TenantAnalyticsQuery = {},
|
||
): TenantUsageAnalytics {
|
||
const granularity: UsageGranularity =
|
||
query.granularity === "day" || query.granularity === "year" ? query.granularity : "month";
|
||
|
||
const defaults = defaultRange(granularity);
|
||
let from = parseDateKey(query.from || "") || defaults.from;
|
||
let to = parseDateKey(query.to || "") || defaults.to;
|
||
if (from > to) {
|
||
const tmp = from;
|
||
from = to;
|
||
to = tmp;
|
||
}
|
||
// 限縮避免爆量桶
|
||
const maxSpanDays =
|
||
granularity === "day" ? 93 : granularity === "month" ? 366 * 3 : 366 * 8;
|
||
if ((startOfDay(to).getTime() - startOfDay(from).getTime()) / 86_400_000 > maxSpanDays) {
|
||
from = addDays(to, -maxSpanDays);
|
||
}
|
||
|
||
const fromKey = toDateKey(from);
|
||
const toKey = toDateKey(to);
|
||
const uids = members.map((m) => m.uid);
|
||
|
||
const rangeStartMs = startOfDay(from).getTime();
|
||
const rangeEndMs = startOfDay(to).getTime() + 86_400_000 - 1;
|
||
|
||
const rangeEvents = loadEvents().filter((e) => {
|
||
const ms = Math.floor(e.created_at / 1_000_000);
|
||
return ms >= rangeStartMs && ms <= rangeEndMs;
|
||
});
|
||
|
||
const by_meter = {} as TenantUsageAnalytics["by_meter"];
|
||
for (const m of Object.keys(METER_META) as UsageMeter[]) {
|
||
by_meter[m] = { credits: 0, count: 0 };
|
||
}
|
||
let consumed_credits = 0;
|
||
for (const e of rangeEvents) {
|
||
consumed_credits += e.credits;
|
||
const row = by_meter[e.meter];
|
||
if (row) {
|
||
row.credits += e.credits;
|
||
row.count += 1;
|
||
}
|
||
}
|
||
let ai_calls = 0;
|
||
let search_calls = 0;
|
||
for (const m of Object.keys(METER_META) as UsageMeter[]) {
|
||
if (METER_META[m].group === "ai") ai_calls += by_meter[m].count;
|
||
else search_calls += by_meter[m].count;
|
||
}
|
||
|
||
const purchased_credits = purchasedInRange(uids, from, to, granularity);
|
||
const remaining_credits = Math.max(0, Math.round(purchased_credits - consumed_credits));
|
||
const pct =
|
||
purchased_credits > 0
|
||
? Math.min(999, Math.round((consumed_credits / purchased_credits) * 100))
|
||
: 0;
|
||
|
||
const buckets = bucketKeys(from, to, granularity);
|
||
const series: UsageTimeBucket[] = buckets.map((b) => {
|
||
const evs = rangeEvents.filter((e) => eventInBucket(e, granularity, b.key));
|
||
let c = 0;
|
||
let ai = 0;
|
||
let search = 0;
|
||
for (const e of evs) {
|
||
c += e.credits;
|
||
if (METER_META[e.meter]?.group === "ai") ai += 1;
|
||
else search += 1;
|
||
}
|
||
return {
|
||
key: b.key,
|
||
label: b.label,
|
||
purchased_credits: purchasedForBucket(uids, b, granularity),
|
||
consumed_credits: c,
|
||
ai_calls: ai,
|
||
search_calls: search,
|
||
};
|
||
});
|
||
|
||
const memberRows: TenantUsageMemberRow[] = members.map((m) => {
|
||
const prefs = getMemberPrefs(m.uid);
|
||
const plan = PLANS[prefs.plan_id];
|
||
const evs = rangeEvents.filter((e) => e.uid === m.uid);
|
||
let total = 0;
|
||
let ai = 0;
|
||
let search = 0;
|
||
for (const e of evs) {
|
||
total += e.credits;
|
||
if (METER_META[e.meter]?.group === "ai") ai += 1;
|
||
else search += 1;
|
||
}
|
||
const purchased = purchasedInRange([m.uid], from, to, granularity);
|
||
const remaining = Math.max(0, purchased - total);
|
||
const memberPct =
|
||
purchased > 0 ? Math.min(999, Math.round((total / purchased) * 100)) : 0;
|
||
return {
|
||
uid: m.uid,
|
||
email: m.email,
|
||
display_name: m.display_name,
|
||
unlimited: prefs.unlimited,
|
||
plan_id: plan.id,
|
||
purchased_credits: purchased,
|
||
total_credits: total,
|
||
ai_calls: ai,
|
||
search_calls: search,
|
||
remaining_credits: remaining,
|
||
pct: memberPct,
|
||
};
|
||
});
|
||
memberRows.sort((a, b) => b.total_credits - a.total_credits);
|
||
|
||
const unit =
|
||
granularity === "day" ? "日" : granularity === "month" ? "月" : "年";
|
||
const range_label = `${fromKey} → ${toKey} · 以${unit}檢視`;
|
||
|
||
return {
|
||
granularity,
|
||
from: fromKey,
|
||
to: toKey,
|
||
range_label,
|
||
purchased_credits,
|
||
consumed_credits,
|
||
remaining_credits,
|
||
pct,
|
||
ai_calls,
|
||
search_calls,
|
||
by_meter,
|
||
series,
|
||
members: memberRows,
|
||
};
|
||
}
|
||
|
||
/** 租戶本月全體:總體 + 每人(相容舊呼叫) */
|
||
export function summarizeTenantUsage(
|
||
members: { uid: string; email: string; display_name: string }[],
|
||
monthKey = currentMonthKey(),
|
||
): TenantUsageSummary {
|
||
const [y, mo] = monthKey.split("-").map(Number);
|
||
const from = `${y}-${pad2(mo)}-01`;
|
||
const last = daysInMonth(y, mo - 1);
|
||
const to = `${y}-${pad2(mo)}-${pad2(last)}`;
|
||
const a = summarizeTenantAnalytics(members, {
|
||
granularity: "month",
|
||
from,
|
||
to,
|
||
});
|
||
return {
|
||
month_key: monthKey,
|
||
total_credits: a.consumed_credits,
|
||
purchased_credits: a.purchased_credits,
|
||
consumed_credits: a.consumed_credits,
|
||
remaining_credits: a.remaining_credits,
|
||
ai_calls: a.ai_calls,
|
||
search_calls: a.search_calls,
|
||
by_meter: a.by_meter,
|
||
members: a.members,
|
||
};
|
||
}
|
||
|
||
export function usageWarning(summary: UsageMonthSummary): string | null {
|
||
// 不擋額度:只提示已超出,不說「用完」
|
||
if (summary.unlimited) {
|
||
if (summary.total_credits > summary.plan.monthly_credits) {
|
||
return `本月已用 ${summary.total_credits} 點(方案 ${summary.plan.monthly_credits},不擋額度,仍可繼續)。`;
|
||
}
|
||
return null;
|
||
}
|
||
if (summary.pct >= 100) {
|
||
return "本月點數已用完。可升級方案或等待下月重置。";
|
||
}
|
||
if (summary.pct >= 80) {
|
||
return `本月已用 ${summary.pct}% 點數。`;
|
||
}
|
||
for (const m of Object.keys(METER_META) as UsageMeter[]) {
|
||
const row = summary.by_meter[m];
|
||
if (row.pct >= 90) {
|
||
return `${METER_META[m].label} 接近單項上限(${row.count}/${row.soft_cap} 次)。`;
|
||
}
|
||
}
|
||
return null;
|
||
}
|