Files
dukang/packages/domain/src/wecom-report.ts
T
jacy 68a4b7f984
CI / verify (pull_request) Waiting to run
v4.0.14概览和日报
2026-09-02 21:11:58 +08:00

180 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/** 131,仅月报 */
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;
}