diff --git a/apps/admin-web/src/pages/WecomReportsPage.tsx b/apps/admin-web/src/pages/WecomReportsPage.tsx index 8112e1d..5adda69 100644 --- a/apps/admin-web/src/pages/WecomReportsPage.tsx +++ b/apps/admin-web/src/pages/WecomReportsPage.tsx @@ -148,10 +148,10 @@ function ReportKindPane({ label="发送时刻" extra={ kind === 'daily' - ? '按北京时间当天该时刻发送当日数据。' + ? '按北京时间该时刻发送;数据截止发送日 0 点(前一天 24 点),即完整昨日。' : kind === 'weekly' - ? '按北京时间该星期该时刻发送上一自然周。' - : '按北京时间每月该日该时刻发送上一自然月。' + ? '按北京时间该星期该时刻发送上一自然周;不含发送当天。' + : '按北京时间每月该日该时刻发送上一自然月;不含发送当天。' } rules={[{ required: true, message: '请选择时刻' }]} > diff --git a/docs/杜康好客-v3.5.15-开发文档.md b/docs/杜康好客-v3.5.15-开发文档.md index cf74755..78e6f52 100644 --- a/docs/杜康好客-v3.5.15-开发文档.md +++ b/docs/杜康好客-v3.5.15-开发文档.md @@ -32,9 +32,11 @@ | 订单金额 | 已付 `payAmount`(`paidAt`) | 区间内支付 | | 核销单数量 / 金额 | `RedeemRecord` | 区间内核销 | -- **日报**:当天;默认 20:00 发送;新增文案「当日新增」。 -- **周报**:上一自然周(周一~周日);默认周一 09:00。 -- **月报**:上一自然月;默认每月 1 日 09:00。 +- **日报**:发送日前一自然日(截账至发送日 0 点 / 前一天 24 点);默认 20:00 发送;新增文案「当日新增」。 +- **周报**:上一自然周(周一~周日);默认周一 09:00;期末为该周日 24 点。 +- **月报**:上一自然月;默认每月 1 日 09:00;期末为该月最后一日 24 点。 + +三种报都不含发送当天发生额。 错过发送时刻会在之后补发一次(按 `last_sent_period` 去重)。 diff --git a/docs/杜康好客-v3编码手册.md b/docs/杜康好客-v3编码手册.md index 73722e2..ef34b9f 100644 --- a/docs/杜康好客-v3编码手册.md +++ b/docs/杜康好客-v3编码手册.md @@ -46,7 +46,7 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd **HQ 概览(v4.0.14 / v4.0.15)**:`GET /admin/dashboard/analytics` 支持日/周/月/季/年分桶;默认窗口为上一档起点~今天。全局筛城市+时间;日期快捷上周/上月/上季度;总量/增量单选分开展示。改粒度不改日期。查询右侧可下载当前折线图 PDF(不含 KPI/待办)。v4.0.15 起为多张全宽折线图:总量=桶末日存量,增量=桶内新增;用户线=推广码/关联合伙人/活动,门店线=关联合伙人,订单线=用户/关联合伙人/商品,核销线=门店/关联合伙人,合伙人单线。订单与核销同时出笔数和金额。关联合伙人=`assoc_partner_account_id`;活动=关联合伙人当前活动图。「查看」只带全局城市与日期。无权限模块后端不算不返回。时间按北京日历。 -**HQ 企微报告(v3.5.15)**:企微机器人下「报告」与「消息推送」分开。日报/周报/月报各配 Webhook 与发送时刻;走群机器人 markdown。日报=当天存量+当日新增;周报/月报=上一自然周/月期末存量+本期新增。用户=有效未合并;合伙人=主账号;订单金额=已付 `payAmount`(`paidAt`);核销=`RedeemRecord`。 +**HQ 企微报告(v3.5.15)**:企微机器人下「报告」与「消息推送」分开。日报/周报/月报各配 Webhook 与发送时刻;走群机器人 markdown。账期截在发送日北京 0 点(前一天 24 点),不含发送当天:日报=昨日存量+当日新增;周报/月报=上一自然周/月期末存量+本期新增。用户=有效未合并;合伙人=主账号;订单金额=已付 `payAmount`(`paidAt`);核销=`RedeemRecord`。 **HQ 列表(v3.5.9)**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。 diff --git a/packages/domain/src/wecom-report.test.ts b/packages/domain/src/wecom-report.test.ts index 5905951..708a342 100644 --- a/packages/domain/src/wecom-report.test.ts +++ b/packages/domain/src/wecom-report.test.ts @@ -25,11 +25,12 @@ const emptyStats = { }; describe('wecomReportPeriod', () => { - it('daily is the Shanghai calendar day', () => { + it('daily is the previous Shanghai calendar day (closed at send-day 00:00)', () => { const p = wecomReportPeriod('daily', new Date('2026-09-02T20:00:00+08:00')); - expect(p.periodKey).toBe('2026-09-02'); + expect(p.periodKey).toBe('2026-09-01'); expect(p.incrementLabel).toBe('当日新增'); - expect(p.start.toISOString()).toBe(new Date('2026-09-02T00:00:00+08:00').toISOString()); + expect(p.start.toISOString()).toBe(new Date('2026-09-01T00:00:00+08:00').toISOString()); + expect(p.endExclusive.toISOString()).toBe(new Date('2026-09-02T00:00:00+08:00').toISOString()); }); it('weekly is the previous natural week', () => { @@ -37,26 +38,28 @@ describe('wecomReportPeriod', () => { expect(p.periodKey).toBe('2026-08-24'); expect(p.rangeLabel).toBe('2026-08-24 ~ 2026-08-30'); expect(p.incrementLabel).toBe('本期新增'); + expect(p.endExclusive.toISOString()).toBe(new Date('2026-08-31T00:00:00+08:00').toISOString()); }); 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月)'); + expect(p.endExclusive.toISOString()).toBe(new Date('2026-09-01T00:00:00+08:00').toISOString()); }); }); describe('wecomReportCutoff', () => { - it('caps an in-progress daily period at now', () => { + it('daily closes at send-day midnight, not send clock time', () => { const now = new Date('2026-09-02T20:00:00+08:00'); const p = wecomReportPeriod('daily', now); - expect(wecomReportCutoff(p, now).getTime()).toBe(now.getTime()); + expect(wecomReportCutoff(p).getTime()).toBe(new Date('2026-09-02T00:00:00+08:00').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()); + expect(wecomReportCutoff(p).getTime()).toBe(p.endExclusive.getTime()); }); }); @@ -86,7 +89,7 @@ describe('wecomReportShouldFire', () => { expect( wecomReportShouldFire( 'daily', - { ...base, lastSentPeriod: '2026-09-02' }, + { ...base, lastSentPeriod: '2026-09-01' }, new Date('2026-09-02T21:00:00+08:00'), ), ).toBe(false); @@ -125,7 +128,7 @@ 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('**杜康好客 · 日报(2026-09-01)**'); expect(md).toContain('用户数量:10(当日新增 2)'); expect(md).toContain('订单金额:1000.00(当日新增 80.50)'); expect(md).toContain('核销单数量:8(当日新增 3)'); diff --git a/packages/domain/src/wecom-report.ts b/packages/domain/src/wecom-report.ts index c5137d9..7931741 100644 --- a/packages/domain/src/wecom-report.ts +++ b/packages/domain/src/wecom-report.ts @@ -67,15 +67,21 @@ 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 = startOfShanghaiDay(now); + const start = addShanghaiDays(asOf, -1); const ymd = shanghaiYmd(start); return { kind, start, - endExclusive: addShanghaiDays(start, 1), + endExclusive: asOf, periodKey: ymd, title: `日报(${ymd})`, rangeLabel: ymd, @@ -83,7 +89,7 @@ export function wecomReportPeriod(kind: WecomReportKind, now = new Date()): Weco }; } if (kind === 'weekly') { - const { start, endExclusive } = previousShanghaiWeek(now); + const { start, endExclusive } = previousShanghaiWeek(asOf); const from = shanghaiYmd(start); const to = shanghaiYmd(new Date(endExclusive.getTime() - 1)); return { @@ -96,7 +102,7 @@ export function wecomReportPeriod(kind: WecomReportKind, now = new Date()): Weco incrementLabel: '本期新增', }; } - const { year, month } = previousShanghaiMonth(now); + const { year, month } = previousShanghaiMonth(asOf); const { start, endExclusive } = shanghaiMonthRange(year, month); return { kind, @@ -109,9 +115,9 @@ export function wecomReportPeriod(kind: WecomReportKind, now = new Date()): Weco }; } -/** 进行中的周期截到 now,已结束的周期用期末 */ -export function wecomReportCutoff(period: WecomReportPeriod, now = new Date()): Date { - return now.getTime() < period.endExclusive.getTime() ? now : period.endExclusive; +/** 账期期末:发送日 0 点或上一周/月结束 0 点,不含发送当天发生额 */ +export function wecomReportCutoff(period: WecomReportPeriod): Date { + return period.endExclusive; } export function wecomReportDueAt(kind: WecomReportKind, schedule: WecomReportSchedule, now = new Date()): Date {