Compare commits

..

1 Commits

Author SHA1 Message Date
jacy 42c1b65632 Merge pull request 'Dev' (#67) from dev into main
CI / verify (push) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/67
2026-09-02 21:13:16 +08:00
8 changed files with 115 additions and 114 deletions
@@ -148,10 +148,10 @@ function ReportKindPane({
label="发送时刻"
extra={
kind === 'daily'
? '按北京时间该时刻发送;数据截止发送日 0 点(前一天 24 点),即完整昨日。'
? '按北京时间当天该时刻发送当日数据。'
: kind === 'weekly'
? '按北京时间该星期该时刻发送上一自然周;不含发送当天。'
: '按北京时间每月该日该时刻发送上一自然月;不含发送当天。'
? '按北京时间该星期该时刻发送上一自然周。'
: '按北京时间每月该日该时刻发送上一自然月。'
}
rules={[{ required: true, message: '请选择时刻' }]}
>
+3 -5
View File
@@ -32,11 +32,9 @@
| 订单金额 | 已付 `payAmount``paidAt` | 区间内支付 |
| 核销单数量 / 金额 | `RedeemRecord` | 区间内核销 |
- **日报**发送日前一自然日(截账至发送日 0 点 / 前一天 24 点);默认 20:00 发送;新增文案「当日新增」。
- **周报**:上一自然周(周一~周日);默认周一 09:00;期末为该周日 24 点
- **月报**:上一自然月;默认每月 1 日 09:00;期末为该月最后一日 24 点
三种报都不含发送当天发生额。
- **日报**当天;默认 20:00 发送;新增文案「当日新增」。
- **周报**:上一自然周(周一~周日);默认周一 09:00。
- **月报**:上一自然月;默认每月 1 日 09:00。
错过发送时刻会在之后补发一次(按 `last_sent_period` 去重)。
+1 -1
View File
@@ -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。账期截在发送日北京 0 点(前一天 24 点),不含发送当天:日报=昨日存量+当日新增;周报/月报=上一自然周/月期末存量+本期新增。用户=有效未合并;合伙人=主账号;订单金额=已付 `payAmount``paidAt`);核销=`RedeemRecord`
**HQ 企微报告(v3.5.15**:企微机器人下「报告」与「消息推送」分开。日报/周报/月报各配 Webhook 与发送时刻;走群机器人 markdown。日报=当天存量+当日新增;周报/月报=上一自然周/月期末存量+本期新增。用户=有效未合并;合伙人=主账号;订单金额=已付 `payAmount``paidAt`);核销=`RedeemRecord`
**HQ 列表(v3.5.9**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。
+8 -11
View File
@@ -25,12 +25,11 @@ const emptyStats = {
};
describe('wecomReportPeriod', () => {
it('daily is the previous Shanghai calendar day (closed at send-day 00:00)', () => {
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-01');
expect(p.periodKey).toBe('2026-09-02');
expect(p.incrementLabel).toBe('当日新增');
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());
expect(p.start.toISOString()).toBe(new Date('2026-09-02T00:00:00+08:00').toISOString());
});
it('weekly is the previous natural week', () => {
@@ -38,28 +37,26 @@ 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('daily closes at send-day midnight, not send clock time', () => {
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).getTime()).toBe(new Date('2026-09-02T00:00:00+08:00').getTime());
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).getTime()).toBe(p.endExclusive.getTime());
expect(wecomReportCutoff(p, now).getTime()).toBe(p.endExclusive.getTime());
});
});
@@ -89,7 +86,7 @@ describe('wecomReportShouldFire', () => {
expect(
wecomReportShouldFire(
'daily',
{ ...base, lastSentPeriod: '2026-09-01' },
{ ...base, lastSentPeriod: '2026-09-02' },
new Date('2026-09-02T21:00:00+08:00'),
),
).toBe(false);
@@ -128,7 +125,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-01**');
expect(md).toContain('**杜康好客 · 日报(2026-09-02**');
expect(md).toContain('用户数量:10(当日新增 2');
expect(md).toContain('订单金额:1000.00(当日新增 80.50');
expect(md).toContain('核销单数量:8(当日新增 3)');
+8 -14
View File
@@ -67,21 +67,15 @@ 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 start = startOfShanghaiDay(now);
const ymd = shanghaiYmd(start);
return {
kind,
start,
endExclusive: asOf,
endExclusive: addShanghaiDays(start, 1),
periodKey: ymd,
title: `日报(${ymd}`,
rangeLabel: ymd,
@@ -89,7 +83,7 @@ export function wecomReportPeriod(kind: WecomReportKind, now = new Date()): Weco
};
}
if (kind === 'weekly') {
const { start, endExclusive } = previousShanghaiWeek(asOf);
const { start, endExclusive } = previousShanghaiWeek(now);
const from = shanghaiYmd(start);
const to = shanghaiYmd(new Date(endExclusive.getTime() - 1));
return {
@@ -102,7 +96,7 @@ export function wecomReportPeriod(kind: WecomReportKind, now = new Date()): Weco
incrementLabel: '本期新增',
};
}
const { year, month } = previousShanghaiMonth(asOf);
const { year, month } = previousShanghaiMonth(now);
const { start, endExclusive } = shanghaiMonthRange(year, month);
return {
kind,
@@ -115,9 +109,9 @@ export function wecomReportPeriod(kind: WecomReportKind, now = new Date()): Weco
};
}
/** 账期期末:发送日 0 点或上一周/月结束 0 点,不含发送当天发生额 */
export function wecomReportCutoff(period: WecomReportPeriod): Date {
return period.endExclusive;
/** 进行中的周期截到 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 {
@@ -34,15 +34,12 @@ async function main() {
['monthly', '经营月报', 9],
];
for (const [kind, name, hour] of seeds) {
await prisma.$executeRawUnsafe(
`INSERT IGNORE INTO wecom_report_push
(kind, name, webhook_url, enabled, send_hour, send_minute, send_weekday, send_month_day, created_at, updated_at)
VALUES (?, ?, ?, 0, ?, 0, 1, 1, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3))`,
kind,
name,
PLACEHOLDER,
hour,
);
await prisma.$executeRaw`
INSERT IGNORE INTO wecom_report_push
(kind, name, webhook_url, enabled, send_hour, send_minute, send_weekday, send_month_day)
VALUES
(${kind}, ${name}, ${PLACEHOLDER}, 0, ${hour}, 0, 1, 1)
`;
}
console.log('migrate-wecom-report done');
}
+1 -1
View File
@@ -561,7 +561,7 @@ model WecomReportPush {
lastSentPeriod String? @map("last_sent_period") @db.VarChar(16)
lastSentAt DateTime? @map("last_sent_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@map("wecom_report_push")
}
@@ -6,7 +6,7 @@ import {
OnModuleInit,
} from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { Prisma, type WecomReportPush } from '@prisma/client';
import { Prisma } from '@prisma/client';
import {
formatWecomReportMarkdown,
isWecomReportKind,
@@ -56,12 +56,30 @@ function clampMinute(n: number | undefined, fallback: number): number {
return Math.min(59, Math.max(0, Math.floor(n)));
}
type ReportRow = WecomReportPush;
type ReportRow = {
id: bigint;
kind: string;
name: string;
webhookUrl: string;
enabled: boolean | number;
mentionWecomUserId: string | null;
sendHour: number;
sendMinute: number;
sendWeekday: number;
sendMonthDay: number;
lastSentPeriod: string | null;
lastSentAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
function asBool(v: boolean | number): boolean {
return v === true || v === 1;
}
@Injectable()
export class AdminWecomReportsService implements OnModuleInit {
private readonly logger = new Logger(AdminWecomReportsService.name);
private defaultsReady = false;
constructor(
private readonly prisma: PrismaService,
@@ -78,48 +96,42 @@ export class AdminWecomReportsService implements OnModuleInit {
}
}
/**
* 早期 INSERT IGNORE 未写 updated_atPrisma db push 的列又无默认值,
* MySQL 会落成 0000-00-00,后续 Prisma 读行即报 invalid datetime。
*/
private async repairZeroDatetimes(): Promise<void> {
await this.prisma.$executeRawUnsafe(`
UPDATE wecom_report_push
SET created_at = CURRENT_TIMESTAMP(3)
WHERE CAST(created_at AS CHAR) REGEXP '^0000|-00-'
`);
await this.prisma.$executeRawUnsafe(`
UPDATE wecom_report_push
SET updated_at = CURRENT_TIMESTAMP(3)
WHERE CAST(updated_at AS CHAR) REGEXP '^0000|-00-'
`);
await this.prisma.$executeRawUnsafe(`
UPDATE wecom_report_push
SET last_sent_at = NULL
WHERE last_sent_at IS NOT NULL AND CAST(last_sent_at AS CHAR) REGEXP '^0000|-00-'
`);
async ensureDefaults(): Promise<void> {
for (const seed of KIND_SEED) {
await this.prisma.$executeRaw`
INSERT IGNORE INTO wecom_report_push
(kind, name, webhook_url, enabled, send_hour, send_minute, send_weekday, send_month_day)
VALUES
(${seed.kind}, ${seed.name}, ${WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK}, 0, ${seed.sendHour}, ${seed.sendMinute}, 1, 1)
`;
}
}
async ensureDefaults(): Promise<void> {
if (this.defaultsReady) return;
await this.repairZeroDatetimes();
for (const seed of KIND_SEED) {
const existing = await this.prisma.wecomReportPush.findUnique({ where: { kind: seed.kind } });
if (existing) continue;
await this.prisma.wecomReportPush.create({
data: {
kind: seed.kind,
name: seed.name,
webhookUrl: WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK,
enabled: false,
sendHour: seed.sendHour,
sendMinute: seed.sendMinute,
sendWeekday: 1,
sendMonthDay: 1,
},
});
private async findByKind(kind: string): Promise<ReportRow | null> {
const rows = await this.prisma.$queryRaw<ReportRow[]>`
SELECT id, kind, name,
webhook_url AS webhookUrl, enabled,
mention_wecom_user_id AS mentionWecomUserId,
send_hour AS sendHour, send_minute AS sendMinute,
send_weekday AS sendWeekday, send_month_day AS sendMonthDay,
last_sent_period AS lastSentPeriod, last_sent_at AS lastSentAt,
created_at AS createdAt, updated_at AS updatedAt
FROM wecom_report_push WHERE kind = ${kind} LIMIT 1
`;
return rows[0] ?? null;
}
this.defaultsReady = true;
private async findAll(): Promise<ReportRow[]> {
return this.prisma.$queryRaw<ReportRow[]>`
SELECT id, kind, name,
webhook_url AS webhookUrl, enabled,
mention_wecom_user_id AS mentionWecomUserId,
send_hour AS sendHour, send_minute AS sendMinute,
send_weekday AS sendWeekday, send_month_day AS sendMonthDay,
last_sent_period AS lastSentPeriod, last_sent_at AS lastSentAt,
created_at AS createdAt, updated_at AS updatedAt
FROM wecom_report_push
`;
}
parseKind(raw: string): WecomReportKind {
@@ -131,7 +143,7 @@ export class AdminWecomReportsService implements OnModuleInit {
async list(): Promise<WecomReportPushDto[]> {
await this.ensureDefaults();
const rows = await this.prisma.wecomReportPush.findMany();
const rows = await this.findAll();
const byKind = new Map(rows.map((r) => [r.kind, r]));
return KIND_SEED.map((s) => {
const row = byKind.get(s.kind);
@@ -142,14 +154,14 @@ export class AdminWecomReportsService implements OnModuleInit {
async detail(kind: WecomReportKind): Promise<WecomReportPushDto> {
await this.ensureDefaults();
const row = await this.prisma.wecomReportPush.findUnique({ where: { kind } });
const row = await this.findByKind(kind);
if (!row) throw new NotFoundException('报告配置不存在');
return this.toDto(row);
}
async update(kind: WecomReportKind, dto: UpdateWecomReportPushRequest): Promise<WecomReportPushDto> {
await this.ensureDefaults();
const existing = await this.prisma.wecomReportPush.findUnique({ where: { kind } });
const existing = await this.findByKind(kind);
if (!existing) throw new NotFoundException('报告配置不存在');
const webhookUrl =
@@ -157,8 +169,8 @@ export class AdminWecomReportsService implements OnModuleInit {
if (!webhookUrl) throw new BadRequestException('请填写 Webhook URL');
const name = dto.name !== undefined ? dto.name.trim() || existing.name : existing.name;
const enabled = dto.enabled !== undefined ? dto.enabled : existing.enabled;
const mentionWecomUserId =
const enabled = dto.enabled !== undefined ? (dto.enabled ? 1 : 0) : asBool(existing.enabled) ? 1 : 0;
const mention =
dto.mentionWecomUserId === undefined
? existing.mentionWecomUserId
: dto.mentionWecomUserId?.trim() || null;
@@ -174,19 +186,20 @@ export class AdminWecomReportsService implements OnModuleInit {
? Math.min(31, Math.max(1, Math.floor(dto.sendMonthDay) || 1))
: existing.sendMonthDay;
const row = await this.prisma.wecomReportPush.update({
where: { kind },
data: {
name,
webhookUrl,
enabled,
mentionWecomUserId,
sendHour,
sendMinute,
sendWeekday,
sendMonthDay,
},
});
await this.prisma.$executeRaw`
UPDATE wecom_report_push SET
name = ${name},
webhook_url = ${webhookUrl},
enabled = ${enabled},
mention_wecom_user_id = ${mention},
send_hour = ${sendHour},
send_minute = ${sendMinute},
send_weekday = ${sendWeekday},
send_month_day = ${sendMonthDay}
WHERE kind = ${kind}
`;
const row = await this.findByKind(kind);
if (!row) throw new NotFoundException('报告配置不存在');
return this.toDto(row);
}
@@ -205,7 +218,7 @@ export class AdminWecomReportsService implements OnModuleInit {
async send(kind: WecomReportKind, opts?: { markSent?: boolean }): Promise<WecomReportSendResultDto> {
await this.ensureDefaults();
const row = await this.prisma.wecomReportPush.findUnique({ where: { kind } });
const row = await this.findByKind(kind);
if (!row) throw new NotFoundException('报告配置不存在');
const url = row.webhookUrl.trim();
if (!url || url.includes('key=PENDING')) {
@@ -223,10 +236,12 @@ export class AdminWecomReportsService implements OnModuleInit {
throw new BadRequestException('Webhook 发送失败,请检查地址或群机器人是否可用');
}
if (opts?.markSent !== false) {
await this.prisma.wecomReportPush.update({
where: { kind },
data: { lastSentPeriod: period.periodKey, lastSentAt: new Date() },
});
const sentAt = new Date();
await this.prisma.$executeRaw`
UPDATE wecom_report_push
SET last_sent_period = ${period.periodKey}, last_sent_at = ${sentAt}
WHERE kind = ${kind}
`;
}
return {
ok: true,
@@ -243,14 +258,14 @@ export class AdminWecomReportsService implements OnModuleInit {
return;
}
const now = new Date();
const rows = await this.prisma.wecomReportPush.findMany();
const rows = await this.findAll();
for (const row of rows) {
if (!row.enabled) continue;
if (!asBool(row.enabled)) continue;
if (!isWecomReportKind(row.kind)) continue;
const due = wecomReportShouldFire(
row.kind,
{
enabled: row.enabled,
enabled: asBool(row.enabled),
sendHour: row.sendHour,
sendMinute: row.sendMinute,
sendWeekday: row.sendWeekday,
@@ -351,7 +366,7 @@ export class AdminWecomReportsService implements OnModuleInit {
name: row.name,
webhookUrl: row.webhookUrl,
webhookUrlMasked: maskWecomWebhookUrl(row.webhookUrl),
enabled: row.enabled,
enabled: asBool(row.enabled),
mentionWecomUserId: row.mentionWecomUserId,
sendHour: row.sendHour,
sendMinute: row.sendMinute,