@@ -574,7 +476,7 @@ export default function DashboardPage() {
-
-
+
+ setGranularity(v as DashboardGranularity)}
+ />
+
+
+
+
+
+
+ {DATE_PRESETS.map((preset) => (
+
+ ))}
+
- {canPromo ? (
-
-
- ) : null}
- {canPartners ? (
-
-
- ) : null}
+
+ setMetric(v as ChartMetric)}
+ />
+
+
+ }
+ loading={pdfLoading}
+ disabled={!hasAnalytics || analyticsLoading || chartOptions.length === 0}
+ onClick={() => void handleDownloadPdf()}
+ >
+ 下载PDF
+
+
- {canUsers || canOrders ? (
-
- {canOrders ? 查看订单 : null}
- {canUsers ? 查看用户 : null}
-
- }
- >
-
- {canUsers ? (
-
-
-
- ) : null}
- {canOrders ? (
-
-
-
- ) : null}
- {canOrders ? (
-
-
-
- ) : null}
-
-
-
-
-
-
-
-
-
-
- {
- const row = (analytics?.byCity ?? []).find((r) => r.cityName === params.name);
- if (row) setCityFilter(row.cityId);
- },
- }}
- />
-
-
- {canPromo ? (
-
-
- {
- const row = (analytics?.byPromo ?? []).find(
- (r) => (r.name || r.code) === params.name,
- );
- if (row) setPromoFilter(row.promoCodeId ?? 'none');
- },
- }}
- />
-
-
- ) : null}
-
-
+ {permsReady && !hasAnalytics ? (
+
+ 当前账号无可视指标
+
) : null}
- {canPartners || canStores || canBenefit ? (
-
- {canPartners ? 查看合伙人 : null}
- {canStores ? 查看门店 : null}
- {canBenefit ? 查看核销 : null}
-
- }
- >
-
- {canPartners ? (
-
-
-
- ) : null}
- {canStores ? (
-
-
-
- ) : null}
- {canBenefit ? (
-
-
-
- ) : null}
- {canBenefit ? (
-
-
-
- ) : null}
-
-
-
-
-
-
-
-
-
+ {hasAnalytics ? (
+
+ {chartOptions.map(({ chart, option }) => (
查看}
loading={analyticsLoading}
- size="small"
+ style={{ marginBottom: 16 }}
>
- {
- const row = (analytics?.byCity ?? []).find((r) => r.cityName === params.name);
- if (row) setCityFilter(row.cityId);
- },
- }}
- />
+
-
- {canBenefit ? (
-
-
- {
- const row = (analytics?.byCity ?? []).find((r) => r.cityName === params.name);
- if (row) setCityFilter(row.cityId);
- },
- }}
- />
-
-
- ) : null}
- {canPartners && (canStores || canBenefit) ? (
-
-
- {
- const row = (analytics?.byPartner ?? []).find(
- (r) => r.companyName === params.name,
- );
- if (row) setPartnerFilter(row.partnerAccountId);
- },
- }}
- />
-
-
- ) : null}
-
-
+ ))}
+
) : null}
{canFinance || canTickets ? (
diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx
index 2254ee8..2ee1d47 100644
--- a/apps/admin-web/src/pages/OrdersPage.tsx
+++ b/apps/admin-web/src/pages/OrdersPage.tsx
@@ -327,6 +327,9 @@ export default function OrdersPage() {
const [searchParams] = useSearchParams();
const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
const initialPromoCodeId = searchParams.get('promoCodeId')?.trim() || '';
+ const initialCityId = searchParams.get('cityId')?.trim() || '';
+ const initialCreatedFrom = searchParams.get('createdFrom')?.trim() || '';
+ const initialCreatedTo = searchParams.get('createdTo')?.trim() || '';
const initialStatusParam = searchParams.getAll('status').join(',');
const initialStatuses = useMemo(
() => initialStatusParam.split(',').map((s) => s.trim()).filter(Boolean),
@@ -389,6 +392,18 @@ export default function OrdersPage() {
}
}, [form, initialOrderNo]);
+ useEffect(() => {
+ const patch: { cityId?: string; dateRange?: [Dayjs, Dayjs] } = {};
+ if (initialCityId) patch.cityId = initialCityId;
+ if (
+ /^\d{4}-\d{2}-\d{2}$/.test(initialCreatedFrom) &&
+ /^\d{4}-\d{2}-\d{2}$/.test(initialCreatedTo)
+ ) {
+ patch.dateRange = [dayjs(initialCreatedFrom), dayjs(initialCreatedTo)];
+ }
+ if (Object.keys(patch).length) form.setFieldsValue(patch);
+ }, [form, initialCityId, initialCreatedFrom, initialCreatedTo]);
+
useEffect(() => {
if (initialStatuses.length) {
form.setFieldsValue({ status: initialStatuses });
@@ -437,7 +452,7 @@ export default function OrdersPage() {
const statusList = statuses.length ? statuses : initialStatuses;
for (const status of statusList) qs.append('status', status);
if (values.orderType) qs.set('orderType', values.orderType);
- if (values.cityId) qs.set('cityId', values.cityId);
+ if (values.cityId || initialCityId) qs.set('cityId', values.cityId || initialCityId);
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
if (values.userKeyword) qs.set('userKeyword', values.userKeyword);
if (values.productKeyword) qs.set('productKeyword', values.productKeyword);
@@ -445,14 +460,16 @@ export default function OrdersPage() {
if (values.deliveryType) qs.set('deliveryType', values.deliveryType);
if (values.assocPartnerAccountId) qs.set('assocPartnerAccountId', values.assocPartnerAccountId);
if (initialPromoCodeId) qs.set('promoCodeId', initialPromoCodeId);
- if (values.dateRange?.[0]) qs.set('createdFrom', values.dateRange[0].format('YYYY-MM-DD'));
- if (values.dateRange?.[1]) qs.set('createdTo', values.dateRange[1].format('YYYY-MM-DD'));
+ const createdFrom = values.dateRange?.[0]?.format('YYYY-MM-DD') || initialCreatedFrom;
+ const createdTo = values.dateRange?.[1]?.format('YYYY-MM-DD') || initialCreatedTo;
+ if (createdFrom) qs.set('createdFrom', createdFrom);
+ if (createdTo) qs.set('createdTo', createdTo);
const res = await request>(`/admin/orders?${qs}`);
setData(res);
} finally {
setLoading(false);
}
- }, [form, page, pageSize, initialOrderNo, initialPromoCodeId, initialStatuses]);
+ }, [form, page, pageSize, initialOrderNo, initialPromoCodeId, initialStatuses, initialCityId, initialCreatedFrom, initialCreatedTo]);
useEffect(() => {
void load();
diff --git a/apps/admin-web/src/pages/WecomReportsPage.tsx b/apps/admin-web/src/pages/WecomReportsPage.tsx
new file mode 100644
index 0000000..8112e1d
--- /dev/null
+++ b/apps/admin-web/src/pages/WecomReportsPage.tsx
@@ -0,0 +1,239 @@
+import { useCallback, useEffect, useState } from 'react';
+import {
+ Button,
+ Card,
+ Form,
+ Input,
+ InputNumber,
+ Modal,
+ Select,
+ Space,
+ Switch,
+ Tabs,
+ TimePicker,
+ Typography,
+ message,
+} from 'antd';
+import dayjs, { type Dayjs } from 'dayjs';
+import {
+ WECOM_REPORT_KIND_LABELS,
+ WECOM_REPORT_KINDS,
+ WECOM_REPORT_WEEKDAY_OPTIONS,
+ type WecomReportKind,
+ type WecomReportPreviewDto,
+ type WecomReportPushDto,
+} from '@dukang/shared-types';
+import { request } from '../lib/api';
+import { fmtTime } from '../lib/constants';
+
+type FormValues = {
+ name: string;
+ webhookUrl: string;
+ enabled: boolean;
+ mentionWecomUserId?: string;
+ sendTime: Dayjs;
+ sendWeekday: number;
+ sendMonthDay: number;
+};
+
+function ReportKindPane({
+ kind,
+ row,
+ onSaved,
+}: {
+ kind: WecomReportKind;
+ row: WecomReportPushDto | undefined;
+ onSaved: () => void;
+}) {
+ const [form] = Form.useForm();
+ const [saving, setSaving] = useState(false);
+ const [previewing, setPreviewing] = useState(false);
+ const [sending, setSending] = useState(false);
+ const [preview, setPreview] = useState(null);
+
+ useEffect(() => {
+ if (!row) return;
+ form.setFieldsValue({
+ name: row.name,
+ webhookUrl: row.webhookUrl,
+ enabled: row.enabled,
+ mentionWecomUserId: row.mentionWecomUserId ?? undefined,
+ sendTime: dayjs().hour(row.sendHour).minute(row.sendMinute).second(0),
+ sendWeekday: row.sendWeekday,
+ sendMonthDay: row.sendMonthDay,
+ });
+ }, [form, row]);
+
+ async function save() {
+ const values = await form.validateFields();
+ setSaving(true);
+ try {
+ await request(`/admin/wecom-reports/${kind}`, {
+ method: 'PUT',
+ body: JSON.stringify({
+ name: values.name,
+ webhookUrl: values.webhookUrl,
+ enabled: values.enabled,
+ mentionWecomUserId: values.mentionWecomUserId?.trim() || null,
+ sendHour: values.sendTime.hour(),
+ sendMinute: values.sendTime.minute(),
+ sendWeekday: values.sendWeekday,
+ sendMonthDay: values.sendMonthDay,
+ }),
+ });
+ message.success('已保存');
+ onSaved();
+ } catch (e) {
+ message.error(e instanceof Error ? e.message : '保存失败');
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function openPreview() {
+ setPreviewing(true);
+ try {
+ const data = await request(`/admin/wecom-reports/${kind}/preview`, {
+ method: 'POST',
+ body: '{}',
+ });
+ setPreview(data);
+ } catch (e) {
+ message.error(e instanceof Error ? e.message : '预览失败');
+ } finally {
+ setPreviewing(false);
+ }
+ }
+
+ async function sendNow() {
+ setSending(true);
+ try {
+ const res = await request<{ message: string }>(`/admin/wecom-reports/${kind}/send`, {
+ method: 'POST',
+ body: '{}',
+ });
+ message.success(res.message || '已发送');
+ onSaved();
+ } catch (e) {
+ message.error(e instanceof Error ? e.message : '发送失败');
+ } finally {
+ setSending(false);
+ }
+ }
+
+ const label = WECOM_REPORT_KIND_LABELS[kind];
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {kind === 'weekly' ? (
+
+
+
+ ) : null}
+ {kind === 'monthly' ? (
+
+
+
+ ) : null}
+
+ {row?.lastSentAt
+ ? `上次发送:${fmtTime(row.lastSentAt)}(${row.lastSentPeriod ?? '—'})`
+ : '尚未发送'}
+
+
+
+
+
+
+
+ setPreview(null)}
+ footer={}
+ width={560}
+ >
+
+ 区间 {preview?.rangeLabel}
+
+ {preview?.markdown}
+
+ >
+ );
+}
+
+export default function WecomReportsPage() {
+ const [rows, setRows] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const load = useCallback(() => {
+ setLoading(true);
+ return request('/admin/wecom-reports')
+ .then(setRows)
+ .catch((e) => message.error(e instanceof Error ? e.message : '加载失败'))
+ .finally(() => setLoading(false));
+ }, []);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ const byKind = new Map(rows.map((r) => [r.kind, r]));
+
+ return (
+
+ 企微机器人 · 报告
+
+ 日报 / 周报 / 月报走企微群机器人 Webhook,配置与「消息推送」相互独立。口径与概览一致:数量为期末存量,新增为区间内发生额;订单金额按已付
+ payAmount,核销按核销单。
+
+
+ ({
+ key: kind,
+ label: WECOM_REPORT_KIND_LABELS[kind],
+ children: void load()} />,
+ }))}
+ />
+
+
+ );
+}
diff --git a/docs/杜康好客-v3-PRD.md b/docs/杜康好客-v3-PRD.md
index 4e56550..ade861d 100644
--- a/docs/杜康好客-v3-PRD.md
+++ b/docs/杜康好客-v3-PRD.md
@@ -34,6 +34,7 @@
| 3.5.11 | 08-26 | 城市三种履约起购;企微通知补提交人/规格物流/核销用户(明文手机+备注);结算任务失败通知 + 提现通过/四账单(城市合伙人、笔数、累计金额) | [`v3.5.11`](./杜康好客-v3.5.11-开发文档.md) |
| 3.5.12 | 08-26 | 发布会订单大屏循环 BGM;HQ 日志/订单状态流转/用户行为时间线展示中文;修复删除门店分类被默认树回种;HQ 侧栏顺序(业务前 11 项,系统设置置底) | [`v3.5.12`](./杜康好客-v3.5.12-开发文档.md) |
| 3.5.14 | 08-26 | 修复 C 端提交订单/支付成功日志误标 H5;线上 `order_submit`/`pay_success` 回填为小程序 | [`v3.5.14`](./杜康好客-v3.5.14-开发文档.md) |
+| 3.5.15 | 09-02 | 企微机器人「报告」:日报/周报/月报;Webhook 与消息推送分开 | [`v3.5.15`](./杜康好客-v3.5.15-开发文档.md) |
**4.0 起**不再写入本表,见 [`v4-PRD`](./杜康好客-v4-PRD.md)。
diff --git a/docs/杜康好客-v3-现状对照.md b/docs/杜康好客-v3-现状对照.md
index b6f386c..b0a3309 100644
--- a/docs/杜康好客-v3-现状对照.md
+++ b/docs/杜康好客-v3-现状对照.md
@@ -32,7 +32,7 @@
| **mini-user** | 购酒/权益/核销/门店/物流/版本门控 | 规则弹窗、发票、四类型工单、问卷 |
| **h5-shop** | 扫码核销、记录、营业、iOS 扫码 OAuth | 手机号核销、提现、子账号、弱网兜底 |
| **h5-partner** | 子账号、拓店、订单/账单、套餐 | 试核销100、负责人复核、代下单 |
-| **admin-web** | 商品/开城/门店/订单(含 Excel/PDF 导出)/权益/核销/结算/推广码 metrics/开发计划/技术支持 | 完整 SOP 审核 UI、发票、热力图 |
+| **admin-web** | 商品/开城/门店/订单(含 Excel/PDF 导出)/权益/核销/结算/推广码 metrics/开发计划/技术支持/企微报告 | 完整 SOP 审核 UI、发票、热力图 |
| **后端** | 主模块、支付、权益、核销、payout、Courier 适配 | 30min 取消 job、部分 Wave3 |
## 3. 场景 SC-01~09
diff --git a/docs/杜康好客-v3.5.15-开发文档.md b/docs/杜康好客-v3.5.15-开发文档.md
new file mode 100644
index 0000000..cf74755
--- /dev/null
+++ b/docs/杜康好客-v3.5.15-开发文档.md
@@ -0,0 +1,74 @@
+# 杜康好客 · v3.5.15 开发文档
+
+> **2026-09-02** · ops / admin-web / domain / shared-types
+> **主题**:企微机器人增加经营报告(日报 / 周报 / 月报)
+
+---
+
+## 1. 版本目标
+
+| # | 任务 | 类型 | 交付 |
+|---|------|------|------|
+| 1 | 报告模块 | 需求 | HQ「企微机器人 → 报告」,与「消息推送」分开 |
+| 2 | 三种报 | 需求 | 日报、周报、月报:存量 + 本期新增 |
+| 3 | 推送 | 需求 | 企微群机器人 Webhook markdown;可预览 / 立即发送 / 定时 |
+
+**不做**:并入消息推送条件、新权限键、城市范围裁剪、同比。
+
+---
+
+## 2. 规则
+
+配置在报告页,每种报一份 Webhook / 启用 / 发送时刻(周报另选星期,月报另选每月几号)。不占用「消息推送」的事件条件。
+
+口径(北京日历,与概览折线一致):
+
+| 指标 | 存量 | 新增 |
+|------|------|------|
+| 用户数量 | `status=1` 且未合并,`createdAt` < 期末 | 区间内创建 |
+| 合伙人数量 | 主账号 `is_primary=1` | 区间内创建 |
+| 门店数量 | 全部门店 | 区间内创建 |
+| 订单数量 | 按下单 `createdAt` | 区间内下单 |
+| 订单金额 | 已付 `payAmount`(`paidAt`) | 区间内支付 |
+| 核销单数量 / 金额 | `RedeemRecord` | 区间内核销 |
+
+- **日报**:当天;默认 20:00 发送;新增文案「当日新增」。
+- **周报**:上一自然周(周一~周日);默认周一 09:00。
+- **月报**:上一自然月;默认每月 1 日 09:00。
+
+错过发送时刻会在之后补发一次(按 `last_sent_period` 去重)。
+
+---
+
+## 3. API
+
+`GET /admin/wecom-reports`
+`GET/PUT /admin/wecom-reports/:kind`(`daily` \| `weekly` \| `monthly`)
+`POST /admin/wecom-reports/:kind/preview`
+`POST /admin/wecom-reports/:kind/send`
+
+权限:`wecom_bots`。
+
+表 `wecom_report_push`(`kind` 唯一)。
+
+---
+
+## 4. 变更面
+
+| 层 | 路径 |
+|----|------|
+| domain | `wecom-report.ts` |
+| shared-types | `wecom-report.ts` |
+| Prisma | `WecomReportPush`;`prisma/migrate-wecom-report.ts` |
+| API | `admin-wecom-reports.*` |
+| HQ | `WecomReportsPage.tsx`;侧栏「报告」 |
+
+---
+
+## 5. 验收
+
+- [ ] 侧栏企微机器人下有「报告」,与消息推送分开
+- [ ] 日报/周报/月报可分别填 Webhook、启停、时刻并保存
+- [ ] 预览文案含用户/合伙人/门店/订单数量与金额/核销数量与金额的存量+新增
+- [ ] 立即发送走该报自己的 Webhook,不触发消息推送事件
+- [ ] 启用后到达时刻只发一次该周期
diff --git a/docs/杜康好客-v3编码手册.md b/docs/杜康好客-v3编码手册.md
index eda31df..73722e2 100644
--- a/docs/杜康好客-v3编码手册.md
+++ b/docs/杜康好客-v3编码手册.md
@@ -1,7 +1,7 @@
# 杜康好客 · V3 编码手册(交付业务版)
> **事实源**:[`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) · **审计**:[`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md)
-> **佣金归属 / 关联码 / 合伙人账单明细(v4.0.1)· 活动图(v4.0.2)· 周结算与预付款(v4.0.9)**:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md),冲突时 **V4 > V3**。
+> **佣金归属 / 关联码 / 合伙人账单明细(v4.0.1)· 活动图(v4.0.2)· 周结算与预付款(v4.0.9)· HQ 概览(v4.0.14 / v4.0.15)**:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md),冲突时 **V4 > V3**。
> V2/preV1 **非需求依据**。总部交付 = **`apps/admin-web`**(非 H5)。
## 1. 交付目标(六条)
@@ -44,6 +44,10 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
**HQ 权限(v3.5.8)**:生效 =(角色 ∪ 追加)− 撤销。城市范围绑在账号上(空=全国;「城市门店服务」必须勾城)。门店 API 按 `cityIds` 强制过滤。城市门店服务可新增分类、不可删除;概览按权限与城市范围裁剪。
+**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.9)**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。
**C 端(v3.5.10)**:门店详情无顶栏分享按钮。同城送提示取开城仓库绑定承运商的 `delivery_hint_html`(`GET /catalog/local-deliveries`,按收货市是否开城);空则回退「同城配送,预计24小时内送到」。在线客服优先 `wx.openCustomerServiceChat`(`CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`);未配 CorpID 回退小程序原生客服。
diff --git a/docs/杜康好客-v4-PRD.md b/docs/杜康好客-v4-PRD.md
index d9e5a46..305714b 100644
--- a/docs/杜康好客-v4-PRD.md
+++ b/docs/杜康好客-v4-PRD.md
@@ -1,8 +1,8 @@
# 杜康好客 · V4 PRD
-> **v4.0**(2026-08-29)· 关联码与分佣事实源;**v4.0.6** 酒厂对账;**v4.0.7** HQ 活动图快链与勾选导出;**v4.0.9** 合伙人 H5 周结算与用户管理
-> 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、合伙人账单明细、活动图、酒厂对账、合伙人周结算)。
-> 实现:[`v4.0.1 开发文档`](./杜康好客-v4.0.1-开发文档.md) · [`v4.0.2 开发文档`](./杜康好客-v4.0.2-开发文档.md) · [`v4.0.6 开发文档`](./杜康好客-v4.0.6-开发文档.md) · [`v4.0.7 开发文档`](./杜康好客-v4.0.7-开发文档.md) · [`v4.0.9 开发文档`](./杜康好客-v4.0.9-开发文档.md) · 审计:[`v4-现状对照`](./杜康好客-v4-现状对照.md)
+> **v4.0**(2026-08-29)· 关联码与分佣事实源;**v4.0.6** 酒厂对账;**v4.0.7** HQ 活动图快链与勾选导出;**v4.0.9** 合伙人 H5 周结算与用户管理;**v4.0.14** HQ 概览粒度;**v4.0.15** HQ 概览折线图
+> 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、合伙人账单明细、活动图、酒厂对账、合伙人周结算、HQ 概览)。
+> 实现:[`v4.0.1 开发文档`](./杜康好客-v4.0.1-开发文档.md) · [`v4.0.2 开发文档`](./杜康好客-v4.0.2-开发文档.md) · [`v4.0.6 开发文档`](./杜康好客-v4.0.6-开发文档.md) · [`v4.0.7 开发文档`](./杜康好客-v4.0.7-开发文档.md) · [`v4.0.9 开发文档`](./杜康好客-v4.0.9-开发文档.md) · [`v4.0.14 开发文档`](./杜康好客-v4.0.14-开发文档.md) · [`v4.0.15 开发文档`](./杜康好客-v4.0.15-开发文档.md) · 审计:[`v4-现状对照`](./杜康好客-v4-现状对照.md)
## 0. 版本
@@ -13,6 +13,8 @@
| 4.0.6 | 08-31 | 酒厂 T+3=每 3 天出一期;全部已完成已付订单(含现场提货);应付为 0 仍出账;核对补生成 | [`v4.0.6`](./杜康好客-v4.0.6-开发文档.md) |
| 4.0.7 | 09-01 | HQ 城市合伙人活动图快链;指定一张活动图为单个或勾选主合伙人合成下载(PNG / zip) | [`v4.0.7`](./杜康好客-v4.0.7-开发文档.md) |
| 4.0.9 | 09-02 | 子账号默认启用;关联码已扫码计数;零元账单不同步;主账号自填银行账号;周账周一 08:00 出账;预付款预估;子账号用户管理(无活动图) | [`v4.0.9`](./杜康好客-v4.0.9-开发文档.md) |
+| 4.0.14 | 09-02 | HQ 概览:日/周/月/季/年、环比、全局城市/时间 + 五板块筛;订单/核销笔数与金额 | [`v4.0.14`](./杜康好客-v4.0.14-开发文档.md) |
+| 4.0.15 | 09-02 | HQ 概览改为全宽折线图:粒度分桶、总量/增量、维度线条;查看快链只带全局筛选 | [`v4.0.15`](./杜康好客-v4.0.15-开发文档.md) |
## 1. 锚点(沿用 V3,佣金归属改写)
diff --git a/docs/杜康好客-v4-现状对照.md b/docs/杜康好客-v4-现状对照.md
index e77170f..67a736a 100644
--- a/docs/杜康好客-v4-现状对照.md
+++ b/docs/杜康好客-v4-现状对照.md
@@ -7,7 +7,7 @@
| 维度 | 结论 |
|------|------|
-| 版本线 | **v4.0.13** 收货地址禁「全市」+ 小飞侠拒单履约可感知 |
+| 版本线 | **v4.0.15** HQ 概览折线图(粒度分桶、总量/增量、维度线条) |
| 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 |
| 账单 | 酒订单 / 核销订单分列;合伙人改为周账(周一 08:00);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) |
| 活动图 | HQ 上传底图/码栏/文案;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 |
@@ -22,6 +22,8 @@
| 4.0.7 | [`活动图快链与勾选导出`](./杜康好客-v4.0.7-开发文档.md) | ✅ 已实现 |
| 4.0.9 | [`合伙人 H5 周结算与用户管理`](./杜康好客-v4.0.9-开发文档.md) | ✅ 已实现 |
| 4.0.13 | [`收货地址把关与拒单可感知`](./杜康好客-v4.0.13-开发文档.md) | ✅ 已实现 |
+| 4.0.14 | [`HQ 概览粒度与环比`](./杜康好客-v4.0.14-开发文档.md) | ✅ 已实现 |
+| 4.0.15 | [`HQ 概览折线图`](./杜康好客-v4.0.15-开发文档.md) | ✅ 已实现 |
| 日期 | 说明 |
|------|------|
@@ -33,3 +35,5 @@
| 2026-09-01 | v4.0.7:HQ 城市合伙人活动图快链;单张/勾选导出合成图(PNG / zip) |
| 2026-09-02 | v4.0.9:子账号默认启用;关联码已扫码;零元账单不同步;银行账号自填;周账周一 08:00;预付款预估;子账号用户管理无活动图 |
| 2026-09-02 | v4.0.13:收货禁「全市」;脏地址下单拦截;小飞侠超区/推单失败挂 `fulfillmentHold`(不做仓/收件坐标) |
+| 2026-09-02 | v4.0.14:HQ 概览日/周/月/季/年、环比;全局城市/时间 + 五板块筛;订单/核销笔数与金额 |
+| 2026-09-02 | v4.0.15:HQ 概览改为全宽折线图;去掉板块筛;查看快链只带全局城市与日期 |
diff --git a/docs/杜康好客-v4.0.14-开发文档.md b/docs/杜康好客-v4.0.14-开发文档.md
new file mode 100644
index 0000000..245bc76
--- /dev/null
+++ b/docs/杜康好客-v4.0.14-开发文档.md
@@ -0,0 +1,95 @@
+# 杜康好客 · v4.0.14 开发文档
+
+> **2026-09-02** · ops / admin-web / domain / shared-types
+> **主题**:HQ 概览日/周/月/季/年分桶、环比、板块筛、订单/核销金额
+
+---
+
+## 1. 版本目标
+
+| # | 任务 | 类型 | 交付 |
+|---|------|------|------|
+| 1 | 时间粒度 | 体验 | `day/week/month/quarter/year` + 自选日期 |
+| 2 | 环比 | 需求 | 整段按粒度回退一档;上期 0 且本期 0 为 `—`,本期 > 0 为 `+100%` |
+| 3 | 筛选分层 | 体验 | 全局只筛城市 + 时间;五板块各自筛选 |
+| 4 | 金额 | 需求 | 订单/核销同时出笔数与金额 |
+| 5 | 权限 | 安全 | 无权限模块后端不算不返回;城市范围只看负责城 |
+
+**不做**:同比、测试单开关、新权限键、Prisma 迁移。
+
+---
+
+## 2. 规则
+
+### 2.1 筛选分层
+
+**全局**:粒度 + 日期 + 城市。环比窗口仍按全局日期、按粒度回退一档。
+
+**板块筛**(只影响本板块 KPI / 趋势该系列 / 城市该系列):
+
+| 板块 | 筛选 | 口径 |
+|------|------|------|
+| 用户 | 推广码、关联合伙人、活动 | 推广码=`user_promo_attribution`;关联合伙人=`assoc_partner_account_id`;活动=关联合伙人当前所选活动图(`partner_account.activity_poster_id`) |
+| 合伙人 | 无 | 仅全局 |
+| 门店 | 合伙人 | 门店归属主合伙人 `store.partner_account_id` |
+| 订单 | 推广码、关联合伙人、活动、商品 | 推广码=`order.promo_code_id`;关联合伙人/活动同用户(下单用户);商品=`order.product_id`。笔数=`createdAt`;金额=已付 `payAmount`(`paidAt`) |
+| 核销单 | 门店、关联合伙人 | 门店=`redeem.store_id`;关联合伙人=核销用户 `assoc_partner_account_id`。笔数与 `RedeemRecord.amount` |
+
+下拉:合伙人「企业-个人」;活动=活动图标题(需 `activity_posters`)。缺对应权限则忽略该筛。
+
+### 2.2 模块口径
+
+| 模块 | 权限键 | 数量 | 新增 | 金额 |
+|------|--------|------|------|------|
+| 用户 | `users` | 区间末日存量 | `createdAt` 落入区间 | 有 `orders` 时交叉付费用户 / 客单价(跟用户筛,不跟订单筛) |
+| 合伙人 | `partners` | 主账号期末存量 | 区间新签 | 交叉:支付时归属 GMV / 名下门店核销额(仅全局) |
+| 门店 | `stores` | 期末存量 | 区间新签 | 交叉:本板块门店筛下的核销额(需 `benefit`) |
+| 订单 | `orders` | 区间下单笔数 | 同左 | 已付 `payAmount` |
+| 核销单 | `benefit` | 区间核销笔数 | 同左 | `RedeemRecord.amount` |
+
+时间一律北京日历。周=周一~周日;季=自然季;年=自然年。
+
+默认窗口:日=昨天~今天;周=上周一~今天;月=上月 1 日~今天;季=上季首日~今天;年=去年 1 月 1 日~今天。
+
+### 2.3 权限与城市
+
+- 进页仍需 `dashboard`。
+- 无权限模块:后端不算、响应省略。
+- 城市范围:只统计负责城市;筛选项无「未选城」。
+
+---
+
+## 3. API
+
+`GET /admin/dashboard/analytics`
+
+全局:`granularity`、`dateFrom`、`dateTo`、`cityId`(`none`=用户未选城)。
+
+板块:`usersPromoCodeId`(`none`=无归因)、`usersPartnerAccountId`、`usersActivityPosterId`;`storesPartnerAccountId`;`ordersPromoCodeId`(`none`=无推广码)、`ordersPartnerAccountId`、`ordersActivityPosterId`、`ordersProductId`;`redeemsStoreId`、`redeemsPartnerAccountId`。
+
+响应:`granularity`、`range`、`prevRange`、`modules`、`byPeriod`、`byCity`、`byProduct`、`byPartner`。
+
+`GET /admin/dashboard/stats`、`/version` 不变。
+
+---
+
+## 4. 变更面
+
+| 层 | 路径 |
+|----|------|
+| domain | `dashboard-period.ts`(分桶 / 上期 / 环比) |
+| shared-types | `ops.ts` 概览 DTO |
+| API | `admin-dashboard.service.ts`、`admin-dashboard-analytics.ts`、`admin-query.dto.ts` |
+| HQ | `DashboardPage.tsx` |
+
+---
+
+## 5. 验收
+
+- [ ] 全局只有城市与时间;五板块筛互不影响
+- [ ] 日默认昨天~今天,环比为前天~昨天;周月季年按粒度回退一档
+- [ ] 订单/核销同时展示笔数与金额
+- [ ] 用户筛关联合伙人后只计 `assoc_partner_account_id`
+- [ ] 客服(仅 users/orders):无合伙人/门店/核销卡
+- [ ] 待办卡与超管发版区行为不变
+- [ ] domain 周期/环比单测通过
diff --git a/docs/杜康好客-v4.0.15-开发文档.md b/docs/杜康好客-v4.0.15-开发文档.md
new file mode 100644
index 0000000..ef2a4b7
--- /dev/null
+++ b/docs/杜康好客-v4.0.15-开发文档.md
@@ -0,0 +1,79 @@
+# 杜康好客 · v4.0.15 开发文档
+
+> **2026-09-02** · ops / admin-web / domain / shared-types
+> **主题**:HQ 概览改为分指标折线图;全局筛选不变
+
+---
+
+## 1. 版本目标
+
+| # | 任务 | 类型 | 交付 |
+|---|------|------|------|
+| 1 | 横轴 | 体验 | 粒度 = 时间分桶(日/周/月/季/年) |
+| 2 | 纵轴 | 需求 | 总量 / 增量;订单与核销再拆笔数与金额 |
+| 3 | 线条 | 需求 | 用户:推广码 / 关联合伙人 / 活动;门店:关联合伙人;订单:用户 / 关联合伙人 / 商品;核销:门店 / 关联合伙人;合伙人无维度单线 |
+| 4 | 布局 | 体验 | 一图一行、宽度拉满;改全局筛选全部重绘 |
+| 5 | 快链 | 体验 | 「查看」只带全局城市 + 日期,不带线条维度 |
+
+**不做**:同比、板块筛、环比卡、Prisma 迁移、新权限键。
+
+---
+
+## 2. 规则
+
+**全局筛选**:粒度 + 日期 + 城市;日期旁快捷「上周 / 上月 / 上季度」;查询左侧总量 / 增量单选(互斥,只渲染对应折线图);查询右侧「下载PDF」只导出当前指标下的折线图(不含 KPI / 待办 / 版本)。改粒度**不**改日期区间。
+
+**总量** = 该桶结束时的累计存量(含区间前基线)。**增量** = 落入该桶的新增/发生额。订单笔数按 `createdAt`,订单金额按已付 `payAmount`(`paidAt`);核销按 `RedeemRecord`。
+
+**线条口径**(与 v4.0.14 归因一致):
+
+| 板块 | 维度 | 字段 |
+|------|------|------|
+| 用户 | 推广码 | `user_promo_attribution`;空=自然量 |
+| 用户 | 关联合伙人 | `assoc_partner_account_id`;空=未关联 |
+| 用户 | 活动 | 关联合伙人当前 `activity_poster_id`;空=无活动 |
+| 门店 | 关联合伙人 | `store.partner_account_id` |
+| 订单 | 用户 | `order.user_id` |
+| 订单 | 关联合伙人 | 下单用户 `assoc_partner_account_id` |
+| 订单 | 商品 | `order.product_id` |
+| 核销 | 门店 | `redeem.store_id` |
+| 核销 | 关联合伙人 | 核销用户 `assoc_partner_account_id` |
+
+高基数维度:按期末存量 Top 10,长尾并入「其他」;`none` 有数据则始终保留。
+
+无对应权限则不返回该图;无任何维度权限时用户/门店/核销退回单线。城市范围只统计负责城。时间北京日历。
+
+---
+
+## 3. API
+
+`GET /admin/dashboard/analytics?granularity&dateFrom&dateTo&cityId`
+
+响应:`granularity`、`range`、`periods[]`、`charts[]`(`key/title/unit/href/series`)。不再返回 `modules` / `byPeriod` / `byCity` / `byProduct` / `byPartner` / 板块筛参数。
+
+`GET /admin/dashboard/stats`、`/version` 不变。
+
+---
+
+## 4. 变更面
+
+| 层 | 路径 |
+|----|------|
+| domain | `dashboard-series.ts`(累计 / TopN) |
+| shared-types | `ops.ts` 折线 DTO |
+| API | `admin-dashboard-lines.ts`、`admin-dashboard.service.ts`、`admin-query.dto.ts` |
+| HQ | `DashboardPage.tsx`;订单/合伙人列表消费快链城市与日期 |
+
+---
+
+## 5. 验收
+
+- [ ] 全局:粒度、日期、城市;日期快捷上周/上月/上季度;查询左侧总量/增量单选只出对应图
+- [ ] 查询右侧下载 PDF:仅当前总量或增量折线图;不含 KPI / 待办 / 版本
+- [ ] 改粒度不改日期区间;改全局筛选(除指标单选)全部折线重绘
+- [ ] 每个指标×维度一张全宽折线图;合伙人仅单线
+- [ ] 日粒度横轴为日期区间按日划分
+- [ ] 「查看」只带 `cityId` + `createdFrom/createdTo`,不带推广码/合伙人/商品等
+- [ ] 客服无合伙人/门店/核销图
+- [ ] 待办卡与超管发版区不变
+- [ ] domain 折线单测通过
diff --git a/hq-dashboard-filters.png b/hq-dashboard-filters.png
new file mode 100644
index 0000000..b46c9f8
Binary files /dev/null and b/hq-dashboard-filters.png differ
diff --git a/hq-dashboard-pdf-button.png b/hq-dashboard-pdf-button.png
new file mode 100644
index 0000000..8396228
Binary files /dev/null and b/hq-dashboard-pdf-button.png differ
diff --git a/packages/domain/src/dashboard-period.test.ts b/packages/domain/src/dashboard-period.test.ts
new file mode 100644
index 0000000..d33d9c6
--- /dev/null
+++ b/packages/domain/src/dashboard-period.test.ts
@@ -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');
+ });
+});
diff --git a/packages/domain/src/dashboard-period.ts b/packages/domain/src/dashboard-period.ts
new file mode 100644
index 0000000..fbff169
--- /dev/null
+++ b/packages/domain/src/dashboard-period.ts
@@ -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) };
+}
diff --git a/packages/domain/src/dashboard-series.test.ts b/packages/domain/src/dashboard-series.test.ts
new file mode 100644
index 0000000..f8dbd02
--- /dev/null
+++ b/packages/domain/src/dashboard-series.test.ts
@@ -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]);
+ });
+});
diff --git a/packages/domain/src/dashboard-series.ts b/packages/domain/src/dashboard-series.ts
new file mode 100644
index 0000000..a63fd30
--- /dev/null
+++ b/packages/domain/src/dashboard-series.ts
@@ -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 {
+ const index = new Map(periodKeys.map((k, i) => [k, i]));
+ const map = new Map();
+ 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,
+ baselineMap: Map,
+ 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;
+ 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();
+ 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),
+ },
+ };
+}
diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts
index f575a9e..7d47a42 100644
--- a/packages/domain/src/index.ts
+++ b/packages/domain/src/index.ts
@@ -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';
diff --git a/packages/domain/src/wecom-report.test.ts b/packages/domain/src/wecom-report.test.ts
new file mode 100644
index 0000000..5905951
--- /dev/null
+++ b/packages/domain/src/wecom-report.test.ts
@@ -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)');
+ });
+});
diff --git a/packages/domain/src/wecom-report.ts b/packages/domain/src/wecom-report.ts
new file mode 100644
index 0000000..c5137d9
--- /dev/null
+++ b/packages/domain/src/wecom-report.ts
@@ -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;
+}
diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts
index e65541b..d78ea2b 100644
--- a/packages/shared-types/src/index.ts
+++ b/packages/shared-types/src/index.ts
@@ -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';
diff --git a/packages/shared-types/src/ops.ts b/packages/shared-types/src/ops.ts
index c6fc2d1..f1f8689 100644
--- a/packages/shared-types/src/ops.ts
+++ b/packages/shared-types/src/ops.ts
@@ -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[];
+};
diff --git a/packages/shared-types/src/wecom-report.ts b/packages/shared-types/src/wecom-report.ts
new file mode 100644
index 0000000..33b28e7
--- /dev/null
+++ b/packages/shared-types/src/wecom-report.ts
@@ -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 = {
+ 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;
+};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a538c97..845d68a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -50,6 +50,9 @@ importers:
element-china-area-data:
specifier: ^6.1.0
version: 6.1.0
+ jspdf:
+ specifier: ^4.2.1
+ version: 4.2.1
react:
specifier: ^18.3.1
version: 18.3.1
@@ -2811,6 +2814,9 @@ packages:
'@types/node@25.9.5':
resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==}
+ '@types/pako@2.0.4':
+ resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
+
'@types/pdfkit@0.17.6':
resolution: {integrity: sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==}
@@ -2826,6 +2832,9 @@ packages:
'@types/qs@6.15.1':
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
+ '@types/raf@3.4.3':
+ resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
+
'@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
@@ -2856,6 +2865,9 @@ packages:
'@types/serve-static@1.15.10':
resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==}
+ '@types/trusted-types@2.0.7':
+ resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+
'@types/validator@13.15.10':
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
@@ -3313,6 +3325,10 @@ packages:
bare-url@2.5.2:
resolution: {integrity: sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==}
+ base64-arraybuffer@1.0.2:
+ resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
+ engines: {node: '>= 0.6.0'}
+
base64-js@0.0.8:
resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==}
engines: {node: '>= 0.4'}
@@ -3476,6 +3492,10 @@ packages:
caniuse-lite@1.0.30001799:
resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==}
+ canvg@3.0.11:
+ resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==}
+ engines: {node: '>=10.0.0'}
+
capital-case@1.0.4:
resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==}
@@ -3749,6 +3769,9 @@ packages:
resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==}
engines: {node: '>=12'}
+ css-line-break@2.1.0:
+ resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
+
css-tree@3.2.1:
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
@@ -3906,6 +3929,9 @@ packages:
resolution: {integrity: sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==}
engines: {node: '>=10'}
+ dompurify@3.4.14:
+ resolution: {integrity: sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==}
+
dot-case@3.0.4:
resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==}
@@ -4190,6 +4216,9 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+ fast-png@6.4.0:
+ resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==}
+
fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
@@ -4591,6 +4620,10 @@ packages:
resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==}
engines: {node: '>=8'}
+ html2canvas@1.4.1:
+ resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
+ engines: {node: '>=8.0.0'}
+
http-cache-semantics@3.8.1:
resolution: {integrity: sha512-5ai2iksyV8ZXmnZhHH4rWPoxxistEexSi5936zIQ1bnNTW5VnA85B6P/VpXiRM017IgRvb2kKo1a//y+0wSp3w==}
@@ -4687,6 +4720,9 @@ packages:
resolution: {integrity: sha512-TcdjPibTksa1NQximqep2r17ISRiNE9fwlfbg3F8ANdvP5/yrFTew86VcO//jk4QTaMlbjypPBq76HN2zaKfZQ==}
engines: {node: '>=4'}
+ iobuffer@5.4.0:
+ resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==}
+
ioredis@5.10.1:
resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==}
engines: {node: '>=12.22.0'}
@@ -4906,6 +4942,9 @@ packages:
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
engines: {node: '>=12', npm: '>=6'}
+ jspdf@4.2.1:
+ resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==}
+
jstoxml@2.2.9:
resolution: {integrity: sha512-OYWlK0j+roh+eyaMROlNbS5cd5R25Y+IUpdl7cNdB8HNrkgwQzIS7L9MegxOiWNBj9dQhA/yAxiMwCC5mwNoBw==}
@@ -5476,6 +5515,9 @@ packages:
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+ pako@2.2.0:
+ resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==}
+
param-case@2.1.1:
resolution: {integrity: sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w==}
@@ -5563,6 +5605,9 @@ packages:
pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
+ performance-now@2.1.0:
+ resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -5788,6 +5833,9 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+ raf@3.4.1:
+ resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==}
+
randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
@@ -6190,6 +6238,10 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+ rgbcolor@1.0.1:
+ resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==}
+ engines: {node: '>= 0.8.15'}
+
rimraf@2.7.1:
resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==}
deprecated: Rimraf versions prior to v4 are no longer supported
@@ -6433,6 +6485,10 @@ packages:
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+ stackblur-canvas@2.7.0:
+ resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==}
+ engines: {node: '>=0.1.14'}
+
standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
@@ -6547,6 +6603,10 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
+ svg-pathdata@6.0.3:
+ resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==}
+ engines: {node: '>=12.0.0'}
+
svg-tags@1.0.0:
resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==}
@@ -6634,6 +6694,9 @@ packages:
text-decoder@1.2.7:
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
+ text-segmentation@1.0.3:
+ resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
+
text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
@@ -6930,6 +6993,9 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
+ utrie@1.0.2:
+ resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
+
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
@@ -9830,6 +9896,8 @@ snapshots:
dependencies:
undici-types: 7.24.6
+ '@types/pako@2.0.4': {}
+
'@types/pdfkit@0.17.6':
dependencies:
'@types/node': 20.19.43
@@ -9847,6 +9915,9 @@ snapshots:
'@types/qs@6.15.1': {}
+ '@types/raf@3.4.3':
+ optional: true
+
'@types/range-parser@1.2.7': {}
'@types/react-dom@18.3.7(@types/react@18.3.31)':
@@ -9885,6 +9956,9 @@ snapshots:
'@types/node': 20.19.43
'@types/send': 0.17.6
+ '@types/trusted-types@2.0.7':
+ optional: true
+
'@types/validator@13.15.10': {}
'@types/xml2js@0.4.14':
@@ -10552,6 +10626,9 @@ snapshots:
dependencies:
bare-path: 3.1.1
+ base64-arraybuffer@1.0.2:
+ optional: true
+
base64-js@0.0.8: {}
base64-js@1.5.1: {}
@@ -10745,6 +10822,18 @@ snapshots:
caniuse-lite@1.0.30001799: {}
+ canvg@3.0.11:
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@types/raf': 3.4.3
+ core-js: 3.49.0
+ raf: 3.4.1
+ regenerator-runtime: 0.13.11
+ rgbcolor: 1.0.1
+ stackblur-canvas: 2.7.0
+ svg-pathdata: 6.0.3
+ optional: true
+
capital-case@1.0.4:
dependencies:
no-case: 3.0.4
@@ -11049,6 +11138,11 @@ snapshots:
css-functions-list@3.3.3: {}
+ css-line-break@2.1.0:
+ dependencies:
+ utrie: 1.0.2
+ optional: true
+
css-tree@3.2.1:
dependencies:
mdn-data: 2.27.1
@@ -11191,6 +11285,11 @@ snapshots:
xml: 1.0.1
xml-js: 1.6.11
+ dompurify@3.4.14:
+ optionalDependencies:
+ '@types/trusted-types': 2.0.7
+ optional: true
+
dot-case@3.0.4:
dependencies:
no-case: 3.0.4
@@ -11597,6 +11696,12 @@ snapshots:
fast-levenshtein@2.0.6: {}
+ fast-png@6.4.0:
+ dependencies:
+ '@types/pako': 2.0.4
+ iobuffer: 5.4.0
+ pako: 2.2.0
+
fast-safe-stringify@2.1.1: {}
fast-uri@3.1.2: {}
@@ -12051,6 +12156,12 @@ snapshots:
html-tags@3.3.1: {}
+ html2canvas@1.4.1:
+ dependencies:
+ css-line-break: 2.1.0
+ text-segmentation: 1.0.3
+ optional: true
+
http-cache-semantics@3.8.1: {}
http-cache-semantics@4.2.0: {}
@@ -12178,6 +12289,8 @@ snapshots:
from2: 2.3.0
p-is-promise: 1.1.0
+ iobuffer@5.4.0: {}
+
ioredis@5.10.1:
dependencies:
'@ioredis/commands': 1.5.1
@@ -12382,6 +12495,17 @@ snapshots:
ms: 2.1.3
semver: 7.8.5
+ jspdf@4.2.1:
+ dependencies:
+ '@babel/runtime': 7.29.7
+ fast-png: 6.4.0
+ fflate: 0.8.3
+ optionalDependencies:
+ canvg: 3.0.11
+ core-js: 3.49.0
+ dompurify: 3.4.14
+ html2canvas: 1.4.1
+
jstoxml@2.2.9: {}
jszip@3.10.1:
@@ -12884,6 +13008,8 @@ snapshots:
pako@1.0.11: {}
+ pako@2.2.0: {}
+
param-case@2.1.1:
dependencies:
no-case: 2.3.2
@@ -12968,6 +13094,9 @@ snapshots:
pend@1.2.0: {}
+ performance-now@2.1.0:
+ optional: true
+
picocolors@1.1.1: {}
picomatch@2.3.2: {}
@@ -13161,6 +13290,11 @@ snapshots:
queue-microtask@1.2.3: {}
+ raf@3.4.1:
+ dependencies:
+ performance-now: 2.1.0
+ optional: true
+
randombytes@2.1.0:
dependencies:
safe-buffer: 5.2.1
@@ -13658,6 +13792,9 @@ snapshots:
reusify@1.1.0: {}
+ rgbcolor@1.0.1:
+ optional: true
+
rimraf@2.7.1:
dependencies:
glob: 7.2.3
@@ -13984,6 +14121,9 @@ snapshots:
stackback@0.0.2: {}
+ stackblur-canvas@2.7.0:
+ optional: true
+
standard-as-callback@2.1.0: {}
statuses@1.5.0: {}
@@ -14133,6 +14273,9 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
+ svg-pathdata@6.0.3:
+ optional: true
+
svg-tags@1.0.0: {}
swiper@11.1.15: {}
@@ -14222,6 +14365,11 @@ snapshots:
transitivePeerDependencies:
- react-native-b4a
+ text-segmentation@1.0.3:
+ dependencies:
+ utrie: 1.0.2
+ optional: true
+
text-table@0.2.0: {}
thenify-all@1.6.0:
@@ -14502,6 +14650,11 @@ snapshots:
utils-merge@1.0.1: {}
+ utrie@1.0.2:
+ dependencies:
+ base64-arraybuffer: 1.0.2
+ optional: true
+
uuid@8.3.2: {}
v8-compile-cache-lib@3.0.1: {}
diff --git a/server/dukang-api/package.json b/server/dukang-api/package.json
index 562581f..af68495 100644
--- a/server/dukang-api/package.json
+++ b/server/dukang-api/package.json
@@ -20,6 +20,7 @@
"prisma:seed-legacy": "ts-node --transpile-only prisma/seed-prev1.ts",
"prisma:seed-wecom-push": "ts-node --transpile-only prisma/seed-wecom-message-push.ts",
"prisma:migrate-wecom-push": "ts-node --transpile-only prisma/migrate-wecom-message-push.ts",
+ "prisma:migrate-wecom-report": "ts-node --transpile-only prisma/migrate-wecom-report.ts",
"prisma:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.ts",
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts",
"prisma:merge-spu-dk000007-008": "ts-node --transpile-only prisma/merge-spu-dk000007-008.ts",
diff --git a/server/dukang-api/prisma/migrate-wecom-report.ts b/server/dukang-api/prisma/migrate-wecom-report.ts
new file mode 100644
index 0000000..0d9da1a
--- /dev/null
+++ b/server/dukang-api/prisma/migrate-wecom-report.ts
@@ -0,0 +1,54 @@
+/**
+ * 创建 wecom_report_push(若不存在)并写入日/周/月默认行。
+ * 不依赖 Prisma Client 新 model,可在 generate 之前执行。
+ */
+import { PrismaClient } from '@prisma/client';
+
+const prisma = new PrismaClient();
+const PLACEHOLDER = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=PENDING';
+
+async function main() {
+ await prisma.$executeRawUnsafe(`
+ CREATE TABLE IF NOT EXISTS wecom_report_push (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ kind VARCHAR(16) NOT NULL,
+ name VARCHAR(64) NOT NULL,
+ webhook_url VARCHAR(512) NOT NULL,
+ enabled TINYINT(1) NOT NULL DEFAULT 0,
+ mention_wecom_user_id VARCHAR(64) NULL,
+ send_hour INT NOT NULL DEFAULT 20,
+ send_minute INT NOT NULL DEFAULT 0,
+ send_weekday INT NOT NULL DEFAULT 1,
+ send_month_day INT NOT NULL DEFAULT 1,
+ last_sent_period VARCHAR(16) NULL,
+ last_sent_at DATETIME(3) NULL,
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+ UNIQUE KEY wecom_report_push_kind_key (kind)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+ `);
+
+ const seeds: Array<[string, string, number]> = [
+ ['daily', '经营日报', 20],
+ ['weekly', '经营周报', 9],
+ ['monthly', '经营月报', 9],
+ ];
+ for (const [kind, name, hour] of seeds) {
+ 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');
+}
+
+main()
+ .catch((e) => {
+ console.error(e);
+ process.exit(1);
+ })
+ .finally(async () => {
+ await prisma.$disconnect();
+ });
diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma
index 10dec36..d04ab39 100644
--- a/server/dukang-api/prisma/schema.prisma
+++ b/server/dukang-api/prisma/schema.prisma
@@ -546,6 +546,26 @@ model WecomPushTemplate {
@@map("wecom_push_template")
}
+/// 企微群机器人经营报告推送(日报 / 周报 / 月报,与事件消息推送分开)
+model WecomReportPush {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ kind String @unique @db.VarChar(16)
+ name String @db.VarChar(64)
+ webhookUrl String @map("webhook_url") @db.VarChar(512)
+ enabled Boolean @default(false)
+ mentionWecomUserId String? @map("mention_wecom_user_id") @db.VarChar(64)
+ sendHour Int @default(20) @map("send_hour")
+ sendMinute Int @default(0) @map("send_minute")
+ sendWeekday Int @default(1) @map("send_weekday")
+ sendMonthDay Int @default(1) @map("send_month_day")
+ 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 @updatedAt @map("updated_at") @db.DateTime(3)
+
+ @@map("wecom_report_push")
+}
+
/// HQ 语言模型 API 配置(非超管仅可见/可开关自己创建的)
model LlmApiConfig {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts
index 5785506..ff2afb2 100644
--- a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts
+++ b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts
@@ -95,6 +95,8 @@ export const HqOperationAction = {
WECOM_MESSAGE_PUSH_UPDATE: 'WECOM_MESSAGE_PUSH_UPDATE',
WECOM_MESSAGE_PUSH_DELETE: 'WECOM_MESSAGE_PUSH_DELETE',
WECOM_MESSAGE_PUSH_TEST: 'WECOM_MESSAGE_PUSH_TEST',
+ WECOM_REPORT_UPDATE: 'WECOM_REPORT_UPDATE',
+ WECOM_REPORT_SEND: 'WECOM_REPORT_SEND',
LLM_CONFIG_CREATE: 'LLM_CONFIG_CREATE',
LLM_CONFIG_UPDATE: 'LLM_CONFIG_UPDATE',
LLM_CONFIG_DELETE: 'LLM_CONFIG_DELETE',
@@ -230,6 +232,8 @@ export const HQ_OPERATION_ACTION_LABELS: Record = {
[HqOperationAction.WECOM_MESSAGE_PUSH_UPDATE]: '编辑企微消息推送',
[HqOperationAction.WECOM_MESSAGE_PUSH_DELETE]: '删除企微消息推送',
[HqOperationAction.WECOM_MESSAGE_PUSH_TEST]: '测试企微消息推送',
+ [HqOperationAction.WECOM_REPORT_UPDATE]: '编辑企微经营报告',
+ [HqOperationAction.WECOM_REPORT_SEND]: '发送企微经营报告',
[HqOperationAction.LLM_CONFIG_CREATE]: '创建语言模型配置',
[HqOperationAction.LLM_CONFIG_UPDATE]: '更新语言模型配置',
[HqOperationAction.LLM_CONFIG_DELETE]: '删除语言模型配置',
diff --git a/server/dukang-api/src/modules/ops/admin-dashboard-analytics.ts b/server/dukang-api/src/modules/ops/admin-dashboard-analytics.ts
new file mode 100644
index 0000000..ecf7cac
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-dashboard-analytics.ts
@@ -0,0 +1,249 @@
+import { Prisma } from '@prisma/client';
+import type { DashboardGranularity } from '@dukang/shared-types';
+
+const COL_ALLOW = new Set([
+ 'u.created_at',
+ 'o.created_at',
+ 'o.paid_at',
+ 'p.created_at',
+ 's.created_at',
+ 'r.created_at',
+]);
+
+const SERIES_COL_ALLOW = new Set([
+ 'promo.promo_code_id',
+ 'u.assoc_partner_account_id',
+ 'ap.activity_poster_id',
+ 's.partner_account_id',
+ 'o.user_id',
+ 'o.product_id',
+ 'r.store_id',
+]);
+
+export function sqlSeriesId(column: string): Prisma.Sql {
+ if (!SERIES_COL_ALLOW.has(column)) throw new Error(`bad series col ${column}`);
+ return Prisma.sql`COALESCE(CAST(${Prisma.raw(column)} AS CHAR), 'none')`;
+}
+
+export function shanghaiSqlBucket(column: string, grain: DashboardGranularity): Prisma.Sql {
+ if (!COL_ALLOW.has(column)) throw new Error(`bad column ${column}`);
+ const col = Prisma.raw(column);
+ switch (grain) {
+ case 'day':
+ return Prisma.sql`DATE_FORMAT(CONVERT_TZ(${col}, '+00:00', '+08:00'), '%Y-%m-%d')`;
+ case 'week':
+ return Prisma.sql`DATE_FORMAT(DATE_SUB(DATE(CONVERT_TZ(${col}, '+00:00', '+08:00')), INTERVAL WEEKDAY(DATE(CONVERT_TZ(${col}, '+00:00', '+08:00'))) DAY), '%Y-%m-%d')`;
+ case 'month':
+ return Prisma.sql`DATE_FORMAT(CONVERT_TZ(${col}, '+00:00', '+08:00'), '%Y-%m')`;
+ case 'quarter':
+ return Prisma.sql`CONCAT(YEAR(CONVERT_TZ(${col}, '+00:00', '+08:00')), '-Q', QUARTER(CONVERT_TZ(${col}, '+00:00', '+08:00')))`;
+ case 'year':
+ return Prisma.sql`DATE_FORMAT(CONVERT_TZ(${col}, '+00:00', '+08:00'), '%Y')`;
+ }
+}
+
+export type CityFilter =
+ | { kind: 'all' }
+ | { kind: 'none' }
+ | { kind: 'empty' }
+ | { kind: 'ids'; ids: bigint[]; codes: string[] };
+
+export function sqlAnd(parts: Prisma.Sql[]): Prisma.Sql {
+ return parts.length ? Prisma.join(parts, ' AND ') : Prisma.sql`1=1`;
+}
+
+export function sqlInBigints(columnSql: Prisma.Sql, ids: bigint[]): Prisma.Sql {
+ if (!ids.length) return Prisma.sql`1=0`;
+ return Prisma.sql`${columnSql} IN (${Prisma.join(ids)})`;
+}
+
+export function sqlInStrings(columnSql: Prisma.Sql, values: string[]): Prisma.Sql {
+ if (!values.length) return Prisma.sql`1=0`;
+ return Prisma.sql`${columnSql} IN (${Prisma.join(values)})`;
+}
+
+export function sqlRange(columnSql: Prisma.Sql, start: Date, endExclusive: Date): Prisma.Sql {
+ return Prisma.sql`${columnSql} >= ${start} AND ${columnSql} < ${endExclusive}`;
+}
+
+export type PromoPick = { none?: boolean; id?: bigint };
+
+export type UserWhereOpts = {
+ city: CityFilter;
+ promo?: PromoPick;
+ assocPartnerId?: bigint;
+ activityPosterId?: bigint;
+ createdStart?: Date | null;
+ createdEndExclusive?: Date | null;
+ createdLte?: Date | null;
+};
+
+export type OrderWhereOpts = {
+ city: CityFilter;
+ promo?: PromoPick;
+ assocPartnerId?: bigint;
+ activityPosterId?: bigint;
+ productId?: bigint;
+ createdStart?: Date | null;
+ createdEndExclusive?: Date | null;
+};
+
+function sqlAssocPartnerUsers(assocPartnerId: bigint): Prisma.Sql {
+ return Prisma.sql`SELECT id FROM user_user WHERE assoc_partner_account_id = ${assocPartnerId} AND status = 1 AND merged_into_user_id IS NULL`;
+}
+
+function sqlActivityPosterUsers(activityPosterId: bigint): Prisma.Sql {
+ return Prisma.sql`SELECT u.id FROM user_user u INNER JOIN partner_account p ON p.id = u.assoc_partner_account_id WHERE p.activity_poster_id = ${activityPosterId} AND p.is_primary = 1 AND u.status = 1 AND u.merged_into_user_id IS NULL`;
+}
+
+function sqlActivityPosterPartners(activityPosterId: bigint): Prisma.Sql {
+ return Prisma.sql`SELECT id FROM partner_account WHERE activity_poster_id = ${activityPosterId} AND is_primary = 1`;
+}
+
+function applyPromo(parts: Prisma.Sql[], columnSql: Prisma.Sql, promo?: PromoPick) {
+ if (promo?.none) parts.push(Prisma.sql`${columnSql} IS NULL`);
+ else if (promo?.id !== undefined) parts.push(Prisma.sql`${columnSql} = ${promo.id}`);
+}
+
+export function userSqlWhere(opts: UserWhereOpts): Prisma.Sql {
+ const parts: Prisma.Sql[] = [
+ Prisma.sql`u.status = 1`,
+ Prisma.sql`u.merged_into_user_id IS NULL`,
+ ];
+ if (opts.createdStart && opts.createdEndExclusive) {
+ parts.push(sqlRange(Prisma.sql`u.created_at`, opts.createdStart, opts.createdEndExclusive));
+ } else if (opts.createdLte) {
+ parts.push(Prisma.sql`u.created_at <= ${opts.createdLte}`);
+ }
+ if (opts.city.kind === 'empty') parts.push(Prisma.sql`1=0`);
+ else if (opts.city.kind === 'none') {
+ parts.push(Prisma.sql`(pref.id IS NULL OR pref.selected_city_code IS NULL)`);
+ } else if (opts.city.kind === 'ids') {
+ parts.push(sqlInStrings(Prisma.sql`pref.selected_city_code`, opts.city.codes));
+ }
+ if (opts.promo?.none) parts.push(Prisma.sql`promo.id IS NULL`);
+ else if (opts.promo?.id !== undefined) parts.push(Prisma.sql`promo.promo_code_id = ${opts.promo.id}`);
+ if (opts.assocPartnerId !== undefined) {
+ parts.push(Prisma.sql`u.assoc_partner_account_id = ${opts.assocPartnerId}`);
+ }
+ if (opts.activityPosterId !== undefined) {
+ parts.push(Prisma.sql`u.assoc_partner_account_id IN (${sqlActivityPosterPartners(opts.activityPosterId)})`);
+ }
+ return sqlAnd(parts);
+}
+
+export function orderSqlWhere(opts: OrderWhereOpts): Prisma.Sql {
+ const parts: Prisma.Sql[] = [];
+ if (opts.createdStart && opts.createdEndExclusive) {
+ parts.push(sqlRange(Prisma.sql`o.created_at`, opts.createdStart, opts.createdEndExclusive));
+ }
+ if (opts.city.kind === 'none' || opts.city.kind === 'empty') parts.push(Prisma.sql`1=0`);
+ else if (opts.city.kind === 'ids') parts.push(sqlInBigints(Prisma.sql`o.city_id`, opts.city.ids));
+ applyPromo(parts, Prisma.sql`o.promo_code_id`, opts.promo);
+ if (opts.assocPartnerId !== undefined) {
+ parts.push(Prisma.sql`o.user_id IN (${sqlAssocPartnerUsers(opts.assocPartnerId)})`);
+ }
+ if (opts.activityPosterId !== undefined) {
+ parts.push(Prisma.sql`o.user_id IN (${sqlActivityPosterUsers(opts.activityPosterId)})`);
+ }
+ if (opts.productId !== undefined) parts.push(Prisma.sql`o.product_id = ${opts.productId}`);
+ return sqlAnd(parts.length ? parts : [Prisma.sql`1=1`]);
+}
+
+export function paidOrderSqlWhere(
+ opts: OrderWhereOpts & {
+ paidStart: Date;
+ paidEndExclusive: Date;
+ partnerIdAtPay?: bigint;
+ },
+): Prisma.Sql {
+ const parts: Prisma.Sql[] = [
+ Prisma.sql`o.pay_status = 'PAID'`,
+ sqlRange(Prisma.sql`o.paid_at`, opts.paidStart, opts.paidEndExclusive),
+ ];
+ if (opts.city.kind === 'none' || opts.city.kind === 'empty') parts.push(Prisma.sql`1=0`);
+ else if (opts.city.kind === 'ids') parts.push(sqlInBigints(Prisma.sql`o.city_id`, opts.city.ids));
+ applyPromo(parts, Prisma.sql`o.promo_code_id`, opts.promo);
+ if (opts.partnerIdAtPay !== undefined) {
+ parts.push(Prisma.sql`o.partner_account_id_at_pay = ${opts.partnerIdAtPay}`);
+ }
+ if (opts.assocPartnerId !== undefined) {
+ parts.push(Prisma.sql`o.user_id IN (${sqlAssocPartnerUsers(opts.assocPartnerId)})`);
+ }
+ if (opts.activityPosterId !== undefined) {
+ parts.push(Prisma.sql`o.user_id IN (${sqlActivityPosterUsers(opts.activityPosterId)})`);
+ }
+ if (opts.productId !== undefined) parts.push(Prisma.sql`o.product_id = ${opts.productId}`);
+ return sqlAnd(parts);
+}
+
+export function partnerSqlWhere(
+ city: CityFilter,
+ partnerId: bigint | undefined,
+ createdStart: Date | null,
+ createdEndExclusive: Date | null,
+ createdLte: Date | null,
+): Prisma.Sql {
+ const parts: Prisma.Sql[] = [Prisma.sql`p.is_primary = 1`];
+ if (createdStart && createdEndExclusive) {
+ parts.push(sqlRange(Prisma.sql`p.created_at`, createdStart, createdEndExclusive));
+ } else if (createdLte) {
+ parts.push(Prisma.sql`p.created_at <= ${createdLte}`);
+ }
+ if (city.kind === 'empty') parts.push(Prisma.sql`1=0`);
+ else if (city.kind === 'none') parts.push(Prisma.sql`p.city_id IS NULL`);
+ else if (city.kind === 'ids') parts.push(sqlInBigints(Prisma.sql`p.city_id`, city.ids));
+ if (partnerId !== undefined) parts.push(Prisma.sql`p.id = ${partnerId}`);
+ return sqlAnd(parts);
+}
+
+export function storeSqlWhere(
+ city: CityFilter,
+ partnerId: bigint | undefined,
+ createdStart: Date | null,
+ createdEndExclusive: Date | null,
+ createdLte: Date | null,
+): Prisma.Sql {
+ const parts: Prisma.Sql[] = [];
+ if (createdStart && createdEndExclusive) {
+ parts.push(sqlRange(Prisma.sql`s.created_at`, createdStart, createdEndExclusive));
+ } else if (createdLte) {
+ parts.push(Prisma.sql`s.created_at <= ${createdLte}`);
+ }
+ if (city.kind === 'none' || city.kind === 'empty') parts.push(Prisma.sql`1=0`);
+ else if (city.kind === 'ids') parts.push(sqlInBigints(Prisma.sql`s.city_id`, city.ids));
+ if (partnerId !== undefined) parts.push(Prisma.sql`s.partner_account_id = ${partnerId}`);
+ return sqlAnd(parts.length ? parts : [Prisma.sql`1=1`]);
+}
+
+export function redeemSqlWhere(
+ city: CityFilter,
+ createdStart: Date,
+ createdEndExclusive: Date,
+ opts?: { storeId?: bigint; assocPartnerId?: bigint; storePartnerId?: bigint },
+): Prisma.Sql {
+ const parts: Prisma.Sql[] = [
+ sqlRange(Prisma.sql`r.created_at`, createdStart, createdEndExclusive),
+ ];
+ if (city.kind === 'none' || city.kind === 'empty') parts.push(Prisma.sql`1=0`);
+ else if (city.kind === 'ids') parts.push(sqlInBigints(Prisma.sql`s.city_id`, city.ids));
+ if (opts?.storeId !== undefined) parts.push(Prisma.sql`r.store_id = ${opts.storeId}`);
+ if (opts?.storePartnerId !== undefined) {
+ parts.push(Prisma.sql`s.partner_account_id = ${opts.storePartnerId}`);
+ }
+ if (opts?.assocPartnerId !== undefined) {
+ parts.push(Prisma.sql`r.user_id IN (${sqlAssocPartnerUsers(opts.assocPartnerId)})`);
+ }
+ return sqlAnd(parts);
+}
+
+export function money(v: unknown): number {
+ if (v == null) return 0;
+ const n = typeof v === 'number' ? v : Number(v);
+ return Math.round(n * 100) / 100;
+}
+
+export function toCount(v: unknown): number {
+ if (v == null) return 0;
+ return typeof v === 'number' ? v : Number(v);
+}
diff --git a/server/dukang-api/src/modules/ops/admin-dashboard-lines.ts b/server/dukang-api/src/modules/ops/admin-dashboard-lines.ts
new file mode 100644
index 0000000..798190a
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-dashboard-lines.ts
@@ -0,0 +1,909 @@
+import type {
+ DashboardLineChart,
+ DashboardLineHref,
+ DashboardLineUnit,
+ DashboardGranularity,
+ HqPermissionKey,
+} from '@dukang/shared-types';
+import {
+ buildDimensionLines,
+ buildSingleLine,
+ normalizeSeriesId,
+ shanghaiYmd,
+ type DashboardNamedSeries,
+ type DashboardSeriesPoint,
+} from '@dukang/domain';
+import type { PrismaService } from '../../common/prisma/prisma.module';
+import {
+ type CityFilter,
+ money,
+ orderSqlWhere,
+ paidOrderSqlWhere,
+ partnerSqlWhere,
+ redeemSqlWhere,
+ shanghaiSqlBucket,
+ sqlSeriesId,
+ storeSqlWhere,
+ toCount,
+ userSqlWhere,
+} from './admin-dashboard-analytics';
+
+const EPOCH = new Date(0);
+
+type AggRow = {
+ period: string | Date;
+ series_id: string | null;
+ series_name: string | null;
+ value: unknown;
+};
+
+type BaseRow = {
+ series_id: string | null;
+ series_name: string | null;
+ value: unknown;
+};
+
+export type LineQueryCtx = {
+ prisma: PrismaService;
+ grain: DashboardGranularity;
+ city: CityFilter;
+ rangeStart: Date;
+ rangeEndExclusive: Date;
+ periodKeys: string[];
+ can: (key: HqPermissionKey) => boolean;
+};
+
+function namesFrom(rows: Array<{ series_id: string | null; series_name: string | null }>): Map {
+ const map = new Map();
+ for (const row of rows) {
+ const id = normalizeSeriesId(row.series_id);
+ const name = (row.series_name || '').trim();
+ if (id !== 'none' && name) map.set(id, name);
+ }
+ return map;
+}
+
+function periodKey(raw: string | Date): string {
+ if (raw instanceof Date) return shanghaiYmd(raw);
+ return String(raw);
+}
+
+function toPoints(rows: AggRow[], asMoney: boolean): DashboardSeriesPoint[] {
+ return rows.map((row) => ({
+ seriesId: normalizeSeriesId(row.series_id),
+ period: periodKey(row.period),
+ value: asMoney ? money(row.value) : toCount(row.value),
+ }));
+}
+
+function toBaselines(rows: BaseRow[], asMoney: boolean): Array<{ seriesId: string; value: number }> {
+ return rows.map((row) => ({
+ seriesId: normalizeSeriesId(row.series_id),
+ value: asMoney ? money(row.value) : toCount(row.value),
+ }));
+}
+
+function pairCharts(
+ href: DashboardLineHref,
+ unit: DashboardLineUnit,
+ totalKey: string,
+ totalTitle: string,
+ incKey: string,
+ incTitle: string,
+ lines: { total: DashboardNamedSeries[]; increment: DashboardNamedSeries[] },
+): DashboardLineChart[] {
+ return [
+ { key: totalKey, title: totalTitle, unit, href, series: lines.total },
+ { key: incKey, title: incTitle, unit, href, series: lines.increment },
+ ];
+}
+
+function pairSingle(
+ href: DashboardLineHref,
+ unit: DashboardLineUnit,
+ totalKey: string,
+ totalTitle: string,
+ incKey: string,
+ incTitle: string,
+ line: { total: DashboardNamedSeries; increment: DashboardNamedSeries },
+): DashboardLineChart[] {
+ return pairCharts(href, unit, totalKey, totalTitle, incKey, incTitle, {
+ total: [line.total],
+ increment: [line.increment],
+ });
+}
+
+function mergeNames(...maps: Array