analytics and reports for a collector: store-only configs, --json snapshot, App Usage and Play install stock numbers, per-day sales ranges for both stores

This commit is contained in:
CRTkafa 2026-10-26 10:20:27 +07:00
parent b59665c661
commit 5833776a4b
15 changed files with 651 additions and 60 deletions

View file

@ -148,3 +148,95 @@ export const parsePlayCsv = (text: string): Record<string, string>[] => {
const keys = head.map(h => h.trim());
return rest.map(r => Object.fromEntries(keys.map((k, i) => [k, (r[i] ?? '').trim()])));
};
// ---- day ranges (a collector reads N days in one JSON) ----
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
const addDay = (d: string, n: number): string => new Date(Date.parse(`${d}T00:00:00Z`) + n * 86_400_000).toISOString().slice(0, 10);
export const MAX_RANGE_DAYS = 366;
/** Every date from..to inclusive (YYYY-MM-DD). Pure. */
export function dateRange(from: string, to: string): string[] {
const out: string[] = [];
for (let d = from; d <= to; d = addDay(d, 1)) out.push(d);
return out;
}
/**
* --days N (the N days ending yesterday) or --from/--to (inclusive; --to defaults to yesterday): the dates to read, or
* null when none of the three is given. Daily reports of both stores come out the next day, so the newest day may be
* missing; that is reported, not an error. Pure.
*/
export function reportRange(o: { days?: string; from?: string; to?: string }, now = new Date()): { from: string; to: string; dates: string[] } | null {
if (o.days === undefined && o.from === undefined && o.to === undefined) return null;
if (o.days !== undefined && (o.from !== undefined || o.to !== undefined)) throw new Error('--days or --from/--to, not both');
const yesterday = addDay(now.toISOString().slice(0, 10), -1);
let from: string, to: string;
if (o.days !== undefined) {
const n = Number(o.days);
if (!Number.isInteger(n) || n < 1 || n > MAX_RANGE_DAYS) throw new Error(`--days: 1-${MAX_RANGE_DAYS}`);
to = yesterday; from = addDay(to, -(n - 1));
} else {
if (o.from === undefined) throw new Error('--to needs --from YYYY-MM-DD');
to = o.to ?? yesterday; from = o.from;
if (!DAY_RE.test(from) || !DAY_RE.test(to) || Number.isNaN(Date.parse(from)) || Number.isNaN(Date.parse(to))) throw new Error('--from / --to: YYYY-MM-DD');
if (from > to) throw new Error('--from is after --to');
if (dateRange(from, to).length > MAX_RANGE_DAYS) throw new Error(`--from/--to: at most ${MAX_RANGE_DAYS} days`);
}
return { from, to, dates: dateRange(from, to) };
}
export interface DaySummary { date: string; rows: number; summary: SummaryRow[]; totals: { currency: string; units: number; amount: number }[] }
/** The days of a range as the collector reads them: per-day summary and totals, totals over the range per currency
* (never added across currencies), and the dates that had no report. Pure. */
export function rangeReport(days: DaySummary[], dates: string[]): { days: DaySummary[]; totals: { currency: string; units: number; amount: number }[]; missing: string[] } {
const have = new Set(days.map(d => d.date));
const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date));
return { days: sorted, totals: totalsByCurrency(sorted.flatMap(d => d.summary)), missing: dates.filter(d => !have.has(d)) };
}
export const rangeCsvRows = (days: DaySummary[]): (string | number)[][] =>
[['date', 'key', 'title', 'currency', 'units', 'amount', 'rows'], ...days.flatMap(d => d.summary.map(r => [d.date, r.key, r.title, r.currency, r.units, r.amount, r.rows]))];
export interface PlayDaySales { date: string; currency: string; orders: number; refunds: number; itemPrice: number; taxes: number; charged: number; refunded: number; net: number }
/**
* Play estimated sales rows -> one line per order-charged date and currency of sale. charged = Charged Amount (tax
* included) of the Charged rows, refunded = the Refund rows (always negative), net = charged + refunded; itemPrice and
* taxes are the Charged rows' Item Price and Taxes Collected. Rows outside from..to are left out. Estimated figures
* before Google's service fee: the earnings report (monthly) has the proceeds. Pure.
*/
export function summarizePlaySalesByDay(rows: Record<string, string>[], from: string, to: string): PlayDaySales[] {
const m = new Map<string, PlayDaySales>();
for (const r of rows) {
const date = (r['Order Charged Date'] ?? '').slice(0, 10);
if (!DAY_RE.test(date) || date < from || date > to) continue;
const status = r['Financial Status'] ?? '';
const refund = /refund|chargeback/i.test(status);
if (!refund && !/^charge/i.test(status)) continue;
const currency = r['Currency of Sale'] ?? '';
const id = `${date}\u0000${currency}`;
const x = m.get(id) ?? { date, currency, orders: 0, refunds: 0, itemPrice: 0, taxes: 0, charged: 0, refunded: 0, net: 0 };
const amount = num(r['Charged Amount']);
if (refund) { x.refunds++; x.refunded += -Math.abs(amount); }
else { x.orders++; x.charged += amount; x.itemPrice += num(r['Item Price']); x.taxes += num(r['Taxes Collected']); }
m.set(id, x);
}
return [...m.values()].map(x => ({ ...x, itemPrice: round2(x.itemPrice), taxes: round2(x.taxes), charged: round2(x.charged), refunded: round2(x.refunded), net: round2(x.charged + x.refunded) }))
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : a.currency < b.currency ? -1 : a.currency > b.currency ? 1 : 0));
}
/** Range totals per currency of the daily Play sales lines. Pure. */
export function playSalesTotals(days: PlayDaySales[]): { currency: string; orders: number; refunds: number; charged: number; refunded: number; net: number }[] {
const m = new Map<string, { currency: string; orders: number; refunds: number; charged: number; refunded: number; net: number }>();
for (const d of days) {
const t = m.get(d.currency) ?? { currency: d.currency, orders: 0, refunds: 0, charged: 0, refunded: 0, net: 0 };
t.orders += d.orders; t.refunds += d.refunds; t.charged = round2(t.charged + d.charged); t.refunded = round2(t.refunded + d.refunded); t.net = round2(t.net + d.net);
m.set(d.currency, t);
}
return [...m.values()].sort((a, b) => (a.currency < b.currency ? -1 : a.currency > b.currency ? 1 : 0));
}
/** The months (YYYY-MM) a date window touches, for the Play sales file names. Pure. */
export const monthsOfRange = (from: string, to: string): string[] => [...new Set(dateRange(from, to).map(d => d.slice(0, 7)))];