180 lines
5.9 KiB
TypeScript
180 lines
5.9 KiB
TypeScript
import {
|
||
addShanghaiDays,
|
||
previousShanghaiMonth,
|
||
previousShanghaiWeek,
|
||
shanghaiMonthRange,
|
||
shanghaiWeekRange,
|
||
shanghaiWeekday,
|
||
shanghaiYearMonth,
|
||
shanghaiYmd,
|
||
startOfShanghaiDay,
|
||
} from './shanghai-date';
|
||
|
||
export const WECOM_REPORT_KINDS = ['daily', 'weekly', 'monthly'] as const;
|
||
export type WecomReportKind = (typeof WECOM_REPORT_KINDS)[number];
|
||
|
||
export function isWecomReportKind(v: string): v is WecomReportKind {
|
||
return (WECOM_REPORT_KINDS as readonly string[]).includes(v);
|
||
}
|
||
|
||
export type WecomReportStats = {
|
||
usersTotal: number;
|
||
usersIncrement: number;
|
||
partnersTotal: number;
|
||
partnersIncrement: number;
|
||
storesTotal: number;
|
||
storesIncrement: number;
|
||
ordersTotal: number;
|
||
ordersIncrement: number;
|
||
orderAmountTotal: number;
|
||
orderAmountIncrement: number;
|
||
redeemsTotal: number;
|
||
redeemsIncrement: number;
|
||
redeemAmountTotal: number;
|
||
redeemAmountIncrement: number;
|
||
};
|
||
|
||
export type WecomReportPeriod = {
|
||
kind: WecomReportKind;
|
||
start: Date;
|
||
endExclusive: Date;
|
||
periodKey: string;
|
||
title: string;
|
||
rangeLabel: string;
|
||
incrementLabel: string;
|
||
};
|
||
|
||
export type WecomReportSchedule = {
|
||
enabled: boolean;
|
||
sendHour: number;
|
||
sendMinute: number;
|
||
/** 1=周一 … 7=周日,仅周报 */
|
||
sendWeekday: number;
|
||
/** 1–31,仅月报 */
|
||
sendMonthDay: number;
|
||
lastSentPeriod: string | null;
|
||
};
|
||
|
||
function pad2(n: number): string {
|
||
return String(n).padStart(2, '0');
|
||
}
|
||
|
||
function shanghaiDateTime(ymd: string, hour: number, minute: number): Date {
|
||
return new Date(`${ymd}T${pad2(hour)}:${pad2(minute)}:00+08:00`);
|
||
}
|
||
|
||
function lastDayOfShanghaiMonth(year: number, month: number): number {
|
||
return Number(shanghaiYmd(new Date(shanghaiMonthRange(year, month).endExclusive.getTime() - 1)).slice(8, 10));
|
||
}
|
||
|
||
/** 日报=当天;周报=上一自然周;月报=上一自然月(北京日历) */
|
||
export function wecomReportPeriod(kind: WecomReportKind, now = new Date()): WecomReportPeriod {
|
||
if (kind === 'daily') {
|
||
const start = startOfShanghaiDay(now);
|
||
const ymd = shanghaiYmd(start);
|
||
return {
|
||
kind,
|
||
start,
|
||
endExclusive: addShanghaiDays(start, 1),
|
||
periodKey: ymd,
|
||
title: `日报(${ymd})`,
|
||
rangeLabel: ymd,
|
||
incrementLabel: '当日新增',
|
||
};
|
||
}
|
||
if (kind === 'weekly') {
|
||
const { start, endExclusive } = previousShanghaiWeek(now);
|
||
const from = shanghaiYmd(start);
|
||
const to = shanghaiYmd(new Date(endExclusive.getTime() - 1));
|
||
return {
|
||
kind,
|
||
start,
|
||
endExclusive,
|
||
periodKey: from,
|
||
title: `周报(${from} ~ ${to})`,
|
||
rangeLabel: `${from} ~ ${to}`,
|
||
incrementLabel: '本期新增',
|
||
};
|
||
}
|
||
const { year, month } = previousShanghaiMonth(now);
|
||
const { start, endExclusive } = shanghaiMonthRange(year, month);
|
||
return {
|
||
kind,
|
||
start,
|
||
endExclusive,
|
||
periodKey: `${year}-${pad2(month)}`,
|
||
title: `月报(${year}年${month}月)`,
|
||
rangeLabel: `${shanghaiYmd(start)} ~ ${shanghaiYmd(new Date(endExclusive.getTime() - 1))}`,
|
||
incrementLabel: '本期新增',
|
||
};
|
||
}
|
||
|
||
/** 进行中的周期截到 now,已结束的周期用期末 */
|
||
export function wecomReportCutoff(period: WecomReportPeriod, now = new Date()): Date {
|
||
return now.getTime() < period.endExclusive.getTime() ? now : period.endExclusive;
|
||
}
|
||
|
||
export function wecomReportDueAt(kind: WecomReportKind, schedule: WecomReportSchedule, now = new Date()): Date {
|
||
const hour = Math.min(23, Math.max(0, Math.floor(schedule.sendHour)));
|
||
const minute = Math.min(59, Math.max(0, Math.floor(schedule.sendMinute)));
|
||
if (kind === 'daily') {
|
||
return shanghaiDateTime(shanghaiYmd(now), hour, minute);
|
||
}
|
||
if (kind === 'weekly') {
|
||
const { start } = shanghaiWeekRange(now);
|
||
const iso = Math.min(7, Math.max(1, Math.floor(schedule.sendWeekday) || 1));
|
||
const day = addShanghaiDays(start, iso - 1);
|
||
return shanghaiDateTime(shanghaiYmd(day), hour, minute);
|
||
}
|
||
const { year, month } = shanghaiYearMonth(now);
|
||
const last = lastDayOfShanghaiMonth(year, month);
|
||
const day = Math.min(last, Math.max(1, Math.floor(schedule.sendMonthDay) || 1));
|
||
return shanghaiDateTime(`${year}-${pad2(month)}-${pad2(day)}`, hour, minute);
|
||
}
|
||
|
||
export function wecomReportShouldFire(
|
||
kind: WecomReportKind,
|
||
schedule: WecomReportSchedule,
|
||
now = new Date(),
|
||
): boolean {
|
||
if (!schedule.enabled) return false;
|
||
const period = wecomReportPeriod(kind, now);
|
||
if (schedule.lastSentPeriod === period.periodKey) return false;
|
||
return now.getTime() >= wecomReportDueAt(kind, schedule, now).getTime();
|
||
}
|
||
|
||
function fmtCount(n: number): string {
|
||
return Math.round(n).toLocaleString('zh-CN');
|
||
}
|
||
|
||
function fmtMoney(n: number): string {
|
||
return Number(n || 0).toFixed(2);
|
||
}
|
||
|
||
function line(label: string, total: number, inc: number, incLabel: string, money = false): string {
|
||
const fmt = money ? fmtMoney : fmtCount;
|
||
return `${label}:${fmt(total)}(${incLabel} ${fmt(inc)})`;
|
||
}
|
||
|
||
export function formatWecomReportMarkdown(period: WecomReportPeriod, stats: WecomReportStats): string {
|
||
const inc = period.incrementLabel;
|
||
return [
|
||
`**杜康好客 · ${period.title}**`,
|
||
`统计区间:${period.rangeLabel}(北京时间)`,
|
||
'',
|
||
line('用户数量', stats.usersTotal, stats.usersIncrement, inc),
|
||
line('合伙人数量', stats.partnersTotal, stats.partnersIncrement, inc),
|
||
line('门店数量', stats.storesTotal, stats.storesIncrement, inc),
|
||
line('订单数量', stats.ordersTotal, stats.ordersIncrement, inc),
|
||
line('订单金额', stats.orderAmountTotal, stats.orderAmountIncrement, inc, true),
|
||
line('核销单数量', stats.redeemsTotal, stats.redeemsIncrement, inc),
|
||
line('核销单金额', stats.redeemAmountTotal, stats.redeemAmountIncrement, inc, true),
|
||
].join('\n');
|
||
}
|
||
|
||
/** 仅用于单测:避免把 shanghaiWeekday 的周日=0 与 ISO 周一=1 搞混 */
|
||
export function wecomReportIsoWeekday(d: Date): number {
|
||
const wd = shanghaiWeekday(d);
|
||
return wd === 0 ? 7 : wd;
|
||
}
|