Files
dukang/packages/domain/src/wecom-report.ts
T
2026-09-08 10:07:34 +08:00

200 lines
6.4 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 const WECOM_REPORT_EXCLUDED_ORDER_STATUSES = [
'PENDING_PAY',
'CANCELLED',
'REFUNDING',
] as const;
export type WecomReportExcludedOrderStatus =
(typeof WECOM_REPORT_EXCLUDED_ORDER_STATUSES)[number];
export function wecomReportCountedOrderWhere() {
return { status: { notIn: [...WECOM_REPORT_EXCLUDED_ORDER_STATUSES] } };
}
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));
}
/** 发送日北京 0 点 = 前一天 24 点,账期不含发送当天 */
export function wecomReportAsOf(now = new Date()): Date {
return startOfShanghaiDay(now);
}
/** 日报=昨日;周报=上一自然周;月报=上一自然月。期末均为某日 0 点。 */
export function wecomReportPeriod(kind: WecomReportKind, now = new Date()): WecomReportPeriod {
const asOf = wecomReportAsOf(now);
if (kind === 'daily') {
const start = addShanghaiDays(asOf, -1);
const ymd = shanghaiYmd(start);
return {
kind,
start,
endExclusive: asOf,
periodKey: ymd,
title: `日报(${ymd}`,
rangeLabel: ymd,
incrementLabel: '当日新增',
};
}
if (kind === 'weekly') {
const { start, endExclusive } = previousShanghaiWeek(asOf);
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(asOf);
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: '本期新增',
};
}
/** 账期期末:发送日 0 点或上一周/月结束 0 点,不含发送当天发生额 */
export function wecomReportCutoff(period: WecomReportPeriod): Date {
return 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;
}