@@ -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/1~4/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');
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
/** 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user