v4.0.14概览和日报
CI / verify (pull_request) Waiting to run

This commit is contained in:
2026-09-02 21:11:58 +08:00
parent e5fe8b903a
commit 68a4b7f984
41 changed files with 3815 additions and 1098 deletions
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest';
import {
defaultShanghaiRange,
eachShanghaiBuckets,
periodMomRatio,
previousShanghaiRangeByGrain,
shanghaiBucketKey,
shanghaiQuarterIndex,
shanghaiQuarterRange,
} from './dashboard-period';
import { shanghaiYmd } from './shanghai-date';
describe('shanghaiBucketKey', () => {
it('周跨年落到周一 2025-12-29', () => {
expect(shanghaiBucketKey(new Date('2026-01-02T04:00:00+08:00'), 'week')).toBe('2025-12-29');
});
it('Q4 → Q1 分属两年', () => {
expect(shanghaiBucketKey(new Date('2025-12-15T00:00:00+08:00'), 'quarter')).toBe('2025-Q4');
expect(shanghaiBucketKey(new Date('2026-01-02T00:00:00+08:00'), 'quarter')).toBe('2026-Q1');
});
});
describe('eachShanghaiBuckets', () => {
it('周跨年包含 2025-12-29 与 2026-01-05', () => {
const keys = eachShanghaiBuckets(
new Date('2025-12-31T00:00:00+08:00'),
new Date('2026-01-06T00:00:00+08:00'),
'week',
).map((b) => b.key);
expect(keys).toEqual(['2025-12-29', '2026-01-05']);
});
it('季从 Q4 跨到 Q1', () => {
const keys = eachShanghaiBuckets(
new Date('2025-11-01T00:00:00+08:00'),
new Date('2026-02-01T00:00:00+08:00'),
'quarter',
).map((b) => b.key);
expect(keys).toEqual(['2025-Q4', '2026-Q1']);
});
});
describe('previousShanghaiRangeByGrain', () => {
it('日:昨天~今天 的环比是 前天~昨天', () => {
const prev = previousShanghaiRangeByGrain(
new Date('2026-09-01T00:00:00+08:00'),
new Date('2026-09-02T00:00:00+08:00'),
'day',
);
expect(shanghaiYmd(prev.from)).toBe('2026-08-31');
expect(shanghaiYmd(prev.to)).toBe('2026-09-01');
});
it('周:整段回退 7 天', () => {
const prev = previousShanghaiRangeByGrain(
new Date('2026-08-24T00:00:00+08:00'),
new Date('2026-09-02T00:00:00+08:00'),
'week',
);
expect(shanghaiYmd(prev.from)).toBe('2026-08-17');
expect(shanghaiYmd(prev.to)).toBe('2026-08-26');
});
it('月:整段回退 1 个月', () => {
const prev = previousShanghaiRangeByGrain(
new Date('2026-08-01T00:00:00+08:00'),
new Date('2026-09-02T00:00:00+08:00'),
'month',
);
expect(shanghaiYmd(prev.from)).toBe('2026-07-01');
expect(shanghaiYmd(prev.to)).toBe('2026-08-02');
});
});
describe('periodMomRatio', () => {
it('上期为 0:本期 0 为 null,本期 > 0 为 1', () => {
expect(periodMomRatio(0, 0)).toBeNull();
expect(periodMomRatio(8, 0)).toBe(1);
});
it('常规环比', () => {
expect(periodMomRatio(12, 10)).toBeCloseTo(0.2);
expect(periodMomRatio(8, 10)).toBeCloseTo(-0.2);
});
});
describe('shanghaiQuarterRange', () => {
it('Q1 为 1/14/1(不含)', () => {
const { start, endExclusive } = shanghaiQuarterRange(2026, 1);
expect(shanghaiYmd(start)).toBe('2026-01-01');
expect(shanghaiYmd(endExclusive)).toBe('2026-04-01');
expect(shanghaiQuarterIndex(start)).toEqual({ year: 2026, quarter: 1 });
});
});
describe('defaultShanghaiRange', () => {
it('日:昨天~今天', () => {
const r = defaultShanghaiRange('day', new Date('2026-09-02T12:00:00+08:00'));
expect(shanghaiYmd(r.from)).toBe('2026-09-01');
expect(shanghaiYmd(r.to)).toBe('2026-09-02');
});
it('周:上周一~今天', () => {
const r = defaultShanghaiRange('week', new Date('2026-09-02T12:00:00+08:00'));
expect(shanghaiYmd(r.from)).toBe('2026-08-24');
expect(shanghaiYmd(r.to)).toBe('2026-09-02');
});
it('月:上月1日~今天', () => {
const r = defaultShanghaiRange('month', new Date('2026-09-02T12:00:00+08:00'));
expect(shanghaiYmd(r.from)).toBe('2026-08-01');
expect(shanghaiYmd(r.to)).toBe('2026-09-02');
});
it('季:上季首日~今天', () => {
const r = defaultShanghaiRange('quarter', new Date('2026-09-02T12:00:00+08:00'));
expect(shanghaiYmd(r.from)).toBe('2026-04-01');
expect(shanghaiYmd(r.to)).toBe('2026-09-02');
});
it('年:去年1月1日~今天', () => {
const r = defaultShanghaiRange('year', new Date('2026-09-02T12:00:00+08:00'));
expect(shanghaiYmd(r.from)).toBe('2025-01-01');
expect(shanghaiYmd(r.to)).toBe('2026-09-02');
});
});
+237
View File
@@ -0,0 +1,237 @@
import {
addShanghaiDays,
parseShanghaiYmd,
shanghaiMonthRange,
shanghaiWeekRange,
shanghaiYearMonth,
shanghaiYmd,
startOfShanghaiDay,
} from './shanghai-date';
export const DASHBOARD_GRANULARITIES = ['day', 'week', 'month', 'quarter', 'year'] as const;
export type DashboardGranularity = (typeof DASHBOARD_GRANULARITIES)[number];
function pad2(n: number): string {
return String(n).padStart(2, '0');
}
export function shanghaiQuarterIndex(d: Date): { year: number; quarter: number } {
const [y, m] = shanghaiYmd(d).split('-').map(Number);
return { year: y, quarter: Math.ceil(m / 3) };
}
export function shanghaiQuarterRange(
year: number,
quarter: number,
): { start: Date; endExclusive: Date } {
const startMonth = (quarter - 1) * 3 + 1;
const endMonth = startMonth + 3;
const endYear = endMonth > 12 ? year + 1 : year;
const em = endMonth > 12 ? endMonth - 12 : endMonth;
return {
start: new Date(`${year}-${pad2(startMonth)}-01T00:00:00+08:00`),
endExclusive: new Date(`${endYear}-${pad2(em)}-01T00:00:00+08:00`),
};
}
export function shanghaiBucketKey(d: Date, grain: DashboardGranularity): string {
const ymd = shanghaiYmd(d);
const [y, m] = ymd.split('-').map(Number);
switch (grain) {
case 'day':
return ymd;
case 'week':
return shanghaiYmd(shanghaiWeekRange(d).start);
case 'month':
return `${y}-${pad2(m)}`;
case 'quarter':
return `${y}-Q${Math.ceil(m / 3)}`;
case 'year':
return String(y);
}
}
export function shanghaiBucketLabel(key: string, grain: DashboardGranularity): string {
switch (grain) {
case 'day':
return key.slice(5);
case 'week':
return `${key.slice(5)}`;
default:
return key;
}
}
export type ShanghaiBucket = {
key: string;
label: string;
start: Date;
endExclusive: Date;
};
export function eachShanghaiBuckets(
from: Date,
to: Date,
grain: DashboardGranularity,
): ShanghaiBucket[] {
const startDay = startOfShanghaiDay(from);
const endDay = startOfShanghaiDay(to);
const out: ShanghaiBucket[] = [];
if (grain === 'day') {
for (let d = startDay; d.getTime() <= endDay.getTime(); d = addShanghaiDays(d, 1)) {
const key = shanghaiYmd(d);
out.push({
key,
label: shanghaiBucketLabel(key, grain),
start: d,
endExclusive: addShanghaiDays(d, 1),
});
}
return out;
}
if (grain === 'week') {
let { start } = shanghaiWeekRange(startDay);
const lastStart = shanghaiWeekRange(endDay).start;
while (start.getTime() <= lastStart.getTime()) {
const key = shanghaiYmd(start);
const endExclusive = addShanghaiDays(start, 7);
out.push({ key, label: shanghaiBucketLabel(key, grain), start, endExclusive });
start = endExclusive;
}
return out;
}
if (grain === 'month') {
let { year, month } = shanghaiYearMonth(startDay);
const end = shanghaiYearMonth(endDay);
while (year < end.year || (year === end.year && month <= end.month)) {
const range = shanghaiMonthRange(year, month);
const key = `${year}-${pad2(month)}`;
out.push({ key, label: key, start: range.start, endExclusive: range.endExclusive });
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
return out;
}
if (grain === 'quarter') {
let { year, quarter } = shanghaiQuarterIndex(startDay);
const end = shanghaiQuarterIndex(endDay);
while (year < end.year || (year === end.year && quarter <= end.quarter)) {
const range = shanghaiQuarterRange(year, quarter);
const key = `${year}-Q${quarter}`;
out.push({ key, label: key, start: range.start, endExclusive: range.endExclusive });
quarter += 1;
if (quarter > 4) {
quarter = 1;
year += 1;
}
}
return out;
}
let year = Number(shanghaiYmd(startDay).slice(0, 4));
const endYear = Number(shanghaiYmd(endDay).slice(0, 4));
while (year <= endYear) {
out.push({
key: String(year),
label: String(year),
start: new Date(`${year}-01-01T00:00:00+08:00`),
endExclusive: new Date(`${year + 1}-01-01T00:00:00+08:00`),
});
year += 1;
}
return out;
}
/** 按粒度把日期往前挪一档(日/周/月/季/年),用于环比窗口 */
export function addShanghaiGrain(d: Date, grain: DashboardGranularity, delta: number): Date {
const start = startOfShanghaiDay(d);
switch (grain) {
case 'day':
return addShanghaiDays(start, delta);
case 'week':
return addShanghaiDays(start, delta * 7);
case 'month':
return addShanghaiMonths(start, delta);
case 'quarter':
return addShanghaiMonths(start, delta * 3);
case 'year':
return addShanghaiMonths(start, delta * 12);
}
}
function addShanghaiMonths(d: Date, delta: number): Date {
const [y, m, day] = shanghaiYmd(d).split('-').map(Number);
const utc = new Date(Date.UTC(y, m - 1 + delta, 1));
const ty = utc.getUTCFullYear();
const tm = utc.getUTCMonth() + 1;
const last = new Date(Date.UTC(ty, tm, 0)).getUTCDate();
return parseShanghaiYmd(`${ty}-${pad2(tm)}-${pad2(Math.min(day, last))}`);
}
/** 整段起止按粒度回退一档:日窗口昨天~今天 → 前天~昨天 */
export function previousShanghaiRangeByGrain(
from: Date,
to: Date,
grain: DashboardGranularity,
): { from: Date; to: Date } {
return {
from: addShanghaiGrain(from, grain, -1),
to: addShanghaiGrain(to, grain, -1),
};
}
/** 环比:(本期 − 上期) / 上期;上期 0 且本期 0 → null;上期 0 且本期 > 0 → 1 */
export function periodMomRatio(curr: number, prev: number): number | null {
if (prev === 0) return curr === 0 ? null : 1;
return (curr - prev) / prev;
}
/** 默认窗口 = 上一档起点 ~ 今天(日=昨天~今天,周=上周一~今天,以此类推) */
export function defaultShanghaiRange(
grain: DashboardGranularity,
anchor = new Date(),
): { from: Date; to: Date } {
const to = startOfShanghaiDay(anchor);
switch (grain) {
case 'day':
return { from: addShanghaiDays(to, -1), to };
case 'week': {
const thisMonday = shanghaiWeekRange(to).start;
return { from: addShanghaiDays(thisMonday, -7), to };
}
case 'month': {
const { year, month } = shanghaiYearMonth(to);
const prev = month === 1 ? { year: year - 1, month: 12 } : { year, month: month - 1 };
return { from: shanghaiMonthRange(prev.year, prev.month).start, to };
}
case 'quarter': {
const { year, quarter } = shanghaiQuarterIndex(to);
let y = year;
let q = quarter - 1;
if (q <= 0) {
q = 4;
y -= 1;
}
return { from: shanghaiQuarterRange(y, q).start, to };
}
case 'year': {
const y = Number(shanghaiYmd(to).slice(0, 4));
return { from: new Date(`${y - 1}-01-01T00:00:00+08:00`), to };
}
}
}
export function defaultShanghaiRangeYmds(
grain: DashboardGranularity,
anchor = new Date(),
): { from: string; to: string } {
const r = defaultShanghaiRange(grain, anchor);
return { from: shanghaiYmd(r.from), to: shanghaiYmd(r.to) };
}
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import {
DASHBOARD_SERIES_ALL,
DASHBOARD_SERIES_NONE,
DASHBOARD_SERIES_OTHER,
buildDimensionLines,
buildSingleLine,
cumulativeValues,
normalizeSeriesId,
pickTopSeriesIds,
} from './dashboard-series';
describe('normalizeSeriesId', () => {
it('空值归 none', () => {
expect(normalizeSeriesId(null)).toBe(DASHBOARD_SERIES_NONE);
expect(normalizeSeriesId('')).toBe(DASHBOARD_SERIES_NONE);
});
});
describe('cumulativeValues', () => {
it('带期初存量按桶累加', () => {
expect(cumulativeValues([1, 2, 3], 10)).toEqual([11, 13, 16]);
});
});
describe('pickTopSeriesIds', () => {
it('保留 none,其余按权重取 TopN', () => {
const { keep, rest } = pickTopSeriesIds(
[
{ id: 'none', weight: 1 },
{ id: 'a', weight: 9 },
{ id: 'b', weight: 8 },
{ id: 'c', weight: 1 },
],
2,
);
expect(keep).toEqual(['none', 'a', 'b']);
expect(rest).toEqual(['c']);
});
});
describe('buildDimensionLines', () => {
it('总量为期初+增量累计,长尾并入其他', () => {
const { total, increment } = buildDimensionLines({
periodKeys: ['d1', 'd2'],
points: [
{ seriesId: 'a', period: 'd1', value: 2 },
{ seriesId: 'a', period: 'd2', value: 1 },
{ seriesId: 'b', period: 'd1', value: 4 },
{ seriesId: 'c', period: 'd2', value: 1 },
{ seriesId: 'none', period: 'd1', value: 3 },
],
baselines: [
{ seriesId: 'a', value: 10 },
{ seriesId: 'b', value: 1 },
],
names: new Map([
['a', '码A'],
['b', '码B'],
['c', '码C'],
]),
noneLabel: '自然量',
topN: 1,
});
expect(increment.map((s) => s.id)).toEqual(['none', 'a', DASHBOARD_SERIES_OTHER]);
expect(increment.find((s) => s.id === 'a')?.values).toEqual([2, 1]);
expect(total.find((s) => s.id === 'a')?.values).toEqual([12, 13]);
expect(increment.find((s) => s.id === DASHBOARD_SERIES_OTHER)?.values).toEqual([4, 1]);
expect(total.find((s) => s.id === DASHBOARD_SERIES_OTHER)?.values).toEqual([5, 6]);
expect(increment.find((s) => s.id === 'none')?.name).toBe('自然量');
});
});
describe('buildSingleLine', () => {
it('无维度时只有一条 all', () => {
const { increment, total } = buildSingleLine({
periodKeys: ['d1', 'd2'],
points: [
{ seriesId: 'x', period: 'd1', value: 2 },
{ seriesId: 'y', period: 'd2', value: 3 },
],
baseline: 5,
name: '合伙人',
});
expect(increment).toEqual({ id: DASHBOARD_SERIES_ALL, name: '合伙人', values: [2, 3] });
expect(total.values).toEqual([7, 10]);
});
});
+184
View File
@@ -0,0 +1,184 @@
export const DASHBOARD_LINE_TOP_N = 10;
export const DASHBOARD_SERIES_NONE = 'none';
export const DASHBOARD_SERIES_OTHER = 'other';
export const DASHBOARD_SERIES_ALL = 'all';
export type DashboardSeriesPoint = {
seriesId: string;
period: string;
value: number;
};
export type DashboardNamedSeries = {
id: string;
name: string;
values: number[];
};
export function normalizeSeriesId(raw: string | number | bigint | null | undefined): string {
if (raw == null) return DASHBOARD_SERIES_NONE;
const s = String(raw).trim();
return s ? s : DASHBOARD_SERIES_NONE;
}
export function periodValueMap(
periodKeys: string[],
points: DashboardSeriesPoint[],
): Map<string, number[]> {
const index = new Map(periodKeys.map((k, i) => [k, i]));
const map = new Map<string, number[]>();
const ensure = (id: string) => {
let arr = map.get(id);
if (!arr) {
arr = periodKeys.map(() => 0);
map.set(id, arr);
}
return arr;
};
for (const p of points) {
const i = index.get(p.period);
if (i === undefined) continue;
ensure(p.seriesId)[i] += p.value;
}
return map;
}
export function cumulativeValues(increments: number[], baseline = 0): number[] {
let acc = baseline;
return increments.map((n) => {
acc += n;
return acc;
});
}
export function seriesEndWeight(increments: number[], baseline = 0): number {
return baseline + increments.reduce((sum, n) => sum + n, 0);
}
export function pickTopSeriesIds(
weights: Array<{ id: string; weight: number }>,
topN = DASHBOARD_LINE_TOP_N,
reserved: string[] = [DASHBOARD_SERIES_NONE],
): { keep: string[]; rest: string[] } {
const reservedSet = new Set(reserved);
const reservedIds = weights
.filter((w) => reservedSet.has(w.id) && w.weight !== 0)
.map((w) => w.id);
const ranked = weights
.filter((w) => !reservedSet.has(w.id))
.sort((a, b) => b.weight - a.weight || a.id.localeCompare(b.id));
return {
keep: [...reservedIds, ...ranked.slice(0, topN).map((w) => w.id)],
rest: ranked.slice(topN).map((w) => w.id),
};
}
function sumSeries(
incrementMap: Map<string, number[]>,
baselineMap: Map<string, number>,
ids: string[],
periodLen: number,
): { increments: number[]; baseline: number } {
const increments = Array.from({ length: periodLen }, () => 0);
let baseline = 0;
for (const id of ids) {
const arr = incrementMap.get(id);
if (arr) {
for (let i = 0; i < periodLen; i += 1) increments[i] += arr[i] ?? 0;
}
baseline += baselineMap.get(id) ?? 0;
}
return { increments, baseline };
}
function applyRound(values: number[], round?: (n: number) => number): number[] {
return round ? values.map(round) : values;
}
export function buildDimensionLines(opts: {
periodKeys: string[];
points: DashboardSeriesPoint[];
baselines?: Array<{ seriesId: string; value: number }>;
names?: Map<string, string>;
noneLabel: string;
otherLabel?: string;
topN?: number;
round?: (n: number) => number;
}): { total: DashboardNamedSeries[]; increment: DashboardNamedSeries[] } {
const periodLen = opts.periodKeys.length;
const incrementMap = periodValueMap(opts.periodKeys, opts.points);
const baselineMap = new Map<string, number>();
for (const row of opts.baselines ?? []) {
const id = normalizeSeriesId(row.seriesId);
baselineMap.set(id, (baselineMap.get(id) ?? 0) + row.value);
if (!incrementMap.has(id)) incrementMap.set(id, opts.periodKeys.map(() => 0));
}
for (const id of incrementMap.keys()) {
if (!baselineMap.has(id)) baselineMap.set(id, 0);
}
const weights = [...incrementMap.keys()].map((id) => ({
id,
weight: seriesEndWeight(incrementMap.get(id) ?? [], baselineMap.get(id) ?? 0),
}));
const { keep, rest } = pickTopSeriesIds(weights, opts.topN ?? DASHBOARD_LINE_TOP_N);
const nameOf = (id: string) => {
if (id === DASHBOARD_SERIES_NONE) return opts.noneLabel;
if (id === DASHBOARD_SERIES_OTHER) return opts.otherLabel ?? '其他';
return opts.names?.get(id) || `#${id}`;
};
const orderedIds = [...keep];
if (rest.length) orderedIds.push(DASHBOARD_SERIES_OTHER);
const total: DashboardNamedSeries[] = [];
const increment: DashboardNamedSeries[] = [];
for (const id of orderedIds) {
const packed =
id === DASHBOARD_SERIES_OTHER
? sumSeries(incrementMap, baselineMap, rest, periodLen)
: {
increments: incrementMap.get(id) ?? opts.periodKeys.map(() => 0),
baseline: baselineMap.get(id) ?? 0,
};
increment.push({
id,
name: nameOf(id),
values: applyRound(packed.increments, opts.round),
});
total.push({
id,
name: nameOf(id),
values: applyRound(cumulativeValues(packed.increments, packed.baseline), opts.round),
});
}
return { total, increment };
}
export function buildSingleLine(opts: {
periodKeys: string[];
points: DashboardSeriesPoint[];
baseline?: number;
name: string;
round?: (n: number) => number;
}): { total: DashboardNamedSeries; increment: DashboardNamedSeries } {
const merged: DashboardSeriesPoint[] = opts.points.map((p) => ({
...p,
seriesId: DASHBOARD_SERIES_ALL,
}));
const incrementMap = periodValueMap(opts.periodKeys, merged);
const increments = incrementMap.get(DASHBOARD_SERIES_ALL) ?? opts.periodKeys.map(() => 0);
const baseline = opts.baseline ?? 0;
return {
increment: {
id: DASHBOARD_SERIES_ALL,
name: opts.name,
values: applyRound(increments, opts.round),
},
total: {
id: DASHBOARD_SERIES_ALL,
name: opts.name,
values: applyRound(cumulativeValues(increments, baseline), opts.round),
},
};
}
+3
View File
@@ -410,4 +410,7 @@ export * from './dev-plan';
export * from './support-ticket';
export * from './phone';
export * from './shanghai-date';
export * from './dashboard-period';
export * from './dashboard-series';
export * from './wecom-report';
export * from './shipping-address';
+133
View File
@@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest';
import {
formatWecomReportMarkdown,
wecomReportCutoff,
wecomReportDueAt,
wecomReportPeriod,
wecomReportShouldFire,
} from './wecom-report';
const emptyStats = {
usersTotal: 10,
usersIncrement: 2,
partnersTotal: 3,
partnersIncrement: 1,
storesTotal: 4,
storesIncrement: 0,
ordersTotal: 20,
ordersIncrement: 5,
orderAmountTotal: 1000,
orderAmountIncrement: 80.5,
redeemsTotal: 8,
redeemsIncrement: 3,
redeemAmountTotal: 200,
redeemAmountIncrement: 40,
};
describe('wecomReportPeriod', () => {
it('daily is the Shanghai calendar day', () => {
const p = wecomReportPeriod('daily', new Date('2026-09-02T20:00:00+08:00'));
expect(p.periodKey).toBe('2026-09-02');
expect(p.incrementLabel).toBe('当日新增');
expect(p.start.toISOString()).toBe(new Date('2026-09-02T00:00:00+08:00').toISOString());
});
it('weekly is the previous natural week', () => {
const p = wecomReportPeriod('weekly', new Date('2026-09-02T09:00:00+08:00'));
expect(p.periodKey).toBe('2026-08-24');
expect(p.rangeLabel).toBe('2026-08-24 ~ 2026-08-30');
expect(p.incrementLabel).toBe('本期新增');
});
it('monthly is the previous natural month', () => {
const p = wecomReportPeriod('monthly', new Date('2026-09-01T09:00:00+08:00'));
expect(p.periodKey).toBe('2026-08');
expect(p.title).toBe('月报(2026年8月)');
});
});
describe('wecomReportCutoff', () => {
it('caps an in-progress daily period at now', () => {
const now = new Date('2026-09-02T20:00:00+08:00');
const p = wecomReportPeriod('daily', now);
expect(wecomReportCutoff(p, now).getTime()).toBe(now.getTime());
});
it('uses period end for a completed week', () => {
const now = new Date('2026-09-02T09:00:00+08:00');
const p = wecomReportPeriod('weekly', now);
expect(wecomReportCutoff(p, now).getTime()).toBe(p.endExclusive.getTime());
});
});
describe('wecomReportShouldFire', () => {
const base = {
enabled: true,
sendHour: 20,
sendMinute: 0,
sendWeekday: 1,
sendMonthDay: 1,
lastSentPeriod: null as string | null,
};
it('does not fire before due time', () => {
expect(
wecomReportShouldFire('daily', base, new Date('2026-09-02T19:59:00+08:00')),
).toBe(false);
});
it('fires after due time if this period not sent', () => {
expect(
wecomReportShouldFire('daily', base, new Date('2026-09-02T20:00:00+08:00')),
).toBe(true);
});
it('does not fire twice for the same period', () => {
expect(
wecomReportShouldFire(
'daily',
{ ...base, lastSentPeriod: '2026-09-02' },
new Date('2026-09-02T21:00:00+08:00'),
),
).toBe(false);
});
it('weekly fires Monday 09:00 for last week', () => {
expect(
wecomReportShouldFire(
'weekly',
{ ...base, sendHour: 9, sendMinute: 0, sendWeekday: 1 },
new Date('2026-09-07T09:00:00+08:00'),
),
).toBe(true);
});
});
describe('wecomReportDueAt', () => {
it('monthly clamps to last day of month', () => {
const due = wecomReportDueAt(
'monthly',
{
enabled: true,
sendHour: 9,
sendMinute: 0,
sendWeekday: 1,
sendMonthDay: 31,
lastSentPeriod: null,
},
new Date('2026-09-10T00:00:00+08:00'),
);
expect(due.toISOString()).toBe(new Date('2026-09-30T09:00:00+08:00').toISOString());
});
});
describe('formatWecomReportMarkdown', () => {
it('renders stock plus increment lines', () => {
const p = wecomReportPeriod('daily', new Date('2026-09-02T20:00:00+08:00'));
const md = formatWecomReportMarkdown(p, emptyStats);
expect(md).toContain('**杜康好客 · 日报(2026-09-02**');
expect(md).toContain('用户数量:10(当日新增 2');
expect(md).toContain('订单金额:1000.00(当日新增 80.50');
expect(md).toContain('核销单数量:8(当日新增 3)');
});
});
+179
View File
@@ -0,0 +1,179 @@
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;
}
+1
View File
@@ -30,6 +30,7 @@ export * from './fulfillment-provider';
export * from './system-config';
export * from './wecom-bot';
export * from './wecom-message-push';
export * from './wecom-report';
export * from './llm-config';
export * from './knowledge-base';
export * from './legal';
+35
View File
@@ -68,3 +68,38 @@ export interface DeployTriggerResult {
started?: boolean;
message: string;
}
export const DASHBOARD_GRANULARITIES = ['day', 'week', 'month', 'quarter', 'year'] as const;
export type DashboardGranularity = (typeof DASHBOARD_GRANULARITIES)[number];
export type DashboardDateRange = { from: string; to: string };
export type DashboardLineHref =
| 'users'
| 'partners'
| 'stores'
| 'orders'
| 'redeems';
export type DashboardLineUnit = 'count' | 'amount';
export type DashboardLineSeries = {
id: string;
name: string;
values: number[];
};
export type DashboardLineChart = {
key: string;
title: string;
unit: DashboardLineUnit;
href: DashboardLineHref;
series: DashboardLineSeries[];
};
export type DashboardAnalytics = {
granularity: DashboardGranularity;
range: DashboardDateRange;
periods: Array<{ key: string; label: string }>;
charts: DashboardLineChart[];
};
+79
View File
@@ -0,0 +1,79 @@
export const WECOM_REPORT_KINDS = ['daily', 'weekly', 'monthly'] as const;
export type WecomReportKind = (typeof WECOM_REPORT_KINDS)[number];
export const WECOM_REPORT_KIND_LABELS: Record<WecomReportKind, string> = {
daily: '日报',
weekly: '周报',
monthly: '月报',
};
export const WECOM_REPORT_WEEKDAY_OPTIONS: Array<{ value: number; label: string }> = [
{ value: 1, label: '周一' },
{ value: 2, label: '周二' },
{ value: 3, label: '周三' },
{ value: 4, label: '周四' },
{ value: 5, label: '周五' },
{ value: 6, label: '周六' },
{ value: 7, label: '周日' },
];
export type WecomReportStatsDto = {
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 WecomReportPushDto = {
id: string;
kind: WecomReportKind;
name: string;
webhookUrl: string;
webhookUrlMasked: string;
enabled: boolean;
mentionWecomUserId: string | null;
sendHour: number;
sendMinute: number;
sendWeekday: number;
sendMonthDay: number;
lastSentPeriod: string | null;
lastSentAt: string | null;
createdAt: string;
updatedAt: string;
};
export type UpdateWecomReportPushRequest = {
name?: string;
webhookUrl?: string;
enabled?: boolean;
mentionWecomUserId?: string | null;
sendHour?: number;
sendMinute?: number;
sendWeekday?: number;
sendMonthDay?: number;
};
export type WecomReportPreviewDto = {
kind: WecomReportKind;
periodKey: string;
title: string;
rangeLabel: string;
markdown: string;
stats: WecomReportStatsDto;
};
export type WecomReportSendResultDto = {
ok: boolean;
message: string;
periodKey: string;
};