{ACCOUNT_STATUS_LABELS[s] || s},
- },
- {
- title: '操作',
- width: 80,
- render: (_, staff) => (
- void deleteStaff(staff.id)}
- >
-
-
- ),
- },
- ]}
- />
- >
- ) : (
-
- 暂无子账号
-
- )}
+
+
+ 子账号({detail.staff?.length ?? 0})· 仅主账号可添加,不可多级
+
+
+
+
>
)}
@@ -321,6 +387,87 @@ export default function StoreAccountsPage() {
+ setStaffOpen(false)}
+ onOk={async () => {
+ if (!detail) return;
+ const v = await staffForm.validateFields();
+ await request(`/admin/store-accounts/${detail.id}/staff`, {
+ method: 'POST',
+ body: JSON.stringify(v),
+ });
+ message.success('子账号已创建');
+ setStaffOpen(false);
+ await refreshDetail(detail.id);
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setStaffEditOpen(false)}
+ onOk={async () => {
+ if (!detail || !editingStaffId) return;
+ const v = await staffEditForm.validateFields();
+ await request(`/admin/store-accounts/${detail.id}/staff/${editingStaffId}`, {
+ method: 'PUT',
+ body: JSON.stringify(v),
+ });
+ message.success('子账号已更新');
+ setStaffEditOpen(false);
+ await refreshDetail(detail.id);
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/apps/mini-user/src/lib/store-display.ts b/apps/mini-user/src/lib/store-display.ts
index d91fb79..82bca37 100644
--- a/apps/mini-user/src/lib/store-display.ts
+++ b/apps/mini-user/src/lib/store-display.ts
@@ -17,19 +17,25 @@ export function storeStarCount(rating?: number | string | null): number {
return Math.min(5, Math.max(1, Math.round(n)));
}
-/** 省市区县 + 详细地址 拼接;空则回退占位文案 */
-export function fullStoreAddress(
- store: {
- province?: string | null;
- cityName?: string | null;
- city?: string | null;
- district?: string | null;
- address?: string | null;
- },
- fallback = '地址待完善',
-): string {
- const city = store.cityName || store.city || '';
- const text = `${store.province || ''}${city}${store.district || ''}${store.address || ''}`.trim();
+export type StoreAddressParts = {
+ province?: string | null;
+ cityName?: string | null;
+ city?: string | null;
+ district?: string | null;
+ address?: string | null;
+};
+
+function addressPart(value: unknown): string {
+ return typeof value === 'string' ? value.trim() : '';
+}
+
+/** 省 + 市 + 区 + 详细地址原样拼接;详细地址已含省市区时不去重 */
+export function fullStoreAddress(store: StoreAddressParts, fallback = '地址待完善'): string {
+ const province = addressPart(store.province);
+ const city = addressPart(store.cityName) || addressPart(store.city);
+ const district = addressPart(store.district);
+ const detail = addressPart(store.address);
+ const text = `${province}${city}${district}${detail}`;
return text || fallback;
}
diff --git a/apps/mini-user/src/pages/store-detail/index.tsx b/apps/mini-user/src/pages/store-detail/index.tsx
index 5780198..ba3f393 100644
--- a/apps/mini-user/src/pages/store-detail/index.tsx
+++ b/apps/mini-user/src/pages/store-detail/index.tsx
@@ -21,6 +21,7 @@ import { toMoneyNumber } from '../../lib/money';
import { maskPhone, toDialablePhone } from '../../lib/phone';
import { track } from '../../lib/analytics';
import {
+ fullStoreAddress,
storeCategoryTags,
storeStarCount,
type StoreCategoryTreeNode,
@@ -115,8 +116,7 @@ function envPhotoUrls(store: Store) {
}
function fullAddress(store: Store) {
- const city = store.cityName || store.city || '';
- return `${store.province || ''}${city}${store.district || ''}${store.address || ''}`.trim();
+ return fullStoreAddress(store, '');
}
function pickStoreId(raw?: string | null) {
@@ -447,8 +447,7 @@ export default function StoreDetailPage() {
- {store.district ? `${store.district} · ` : ''}
- {store.address || '地址待完善'}
+ {fullStoreAddress(store)}
导航
diff --git a/apps/mini-user/src/styles/store-detail.css b/apps/mini-user/src/styles/store-detail.css
index 7f70062..909f7c1 100644
--- a/apps/mini-user/src/styles/store-detail.css
+++ b/apps/mini-user/src/styles/store-detail.css
@@ -177,6 +177,7 @@
flex: 1;
min-width: 0;
margin-bottom: 0;
+ word-break: break-word;
}
.store-detail-row {
diff --git a/docs/企微API插件-配置手册.md b/docs/企微API插件-配置手册.md
index 035cbbc..abd2ce0 100644
--- a/docs/企微API插件-配置手册.md
+++ b/docs/企微API插件-配置手册.md
@@ -146,8 +146,8 @@ HQ 页也会展示 Base URL 与 Header 名(不含密钥)。
| partnersTotal / partnersIncrement | Integer | 合伙人 |
| storesTotal / storesIncrement | Integer | 门店存量/增量 |
| **newStores** | Integer | **新增门店数**(与 storesIncrement 相同) |
-| ordersTotal / ordersIncrement | Integer | 订单笔数 |
-| orderAmountTotal / orderAmountIncrement | Number | 订单金额(已付 payAmount) |
+| ordersTotal / ordersIncrement | Integer | 订单笔数(排除待支付 / 已取消 / 退款中) |
+| orderAmountTotal / orderAmountIncrement | Number | 订单金额(已付 payAmount,排除待支付 / 已取消 / 退款中) |
| redeemsTotal / redeemsIncrement | Integer | 核销笔数 |
| redeemAmountTotal / redeemAmountIncrement | Number | 核销金额 |
diff --git a/docs/杜康好客-v3-现状对照.md b/docs/杜康好客-v3-现状对照.md
index fb6dd71..63c2022 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** | 商品/开城/门店/门店账号含子账号 CRUD/订单(含 Excel/PDF 导出)/权益/核销/结算/推广码 metrics/开发计划/技术支持/企微报告 | 完整 SOP 审核 UI、发票、热力图 |
| **后端** | 主模块、支付、权益、核销、payout、Courier 适配 | 30min 取消 job、部分 Wave3 |
## 3. 场景 SC-01~09
@@ -73,6 +73,7 @@
| 日期 | 说明 |
|------|------|
+| 2026-09-08 | HQ「门店账户」主账号详情可 CRUD 店员子账号(`/admin/store-accounts/:id/staff`) |
| 2026-09-06 | v3.5.17:企微 API 插件迁入 HQ「企微机器人 → API 插件」;多实例 Key + 工具权限;不再依赖 `WECOM_PLUGIN_*` env |
| 2026-09-03 | v3.5.16:企微 API 插件只读数据面 `GET /api/v1/wecom/plugin/*`(X-Api-Key);与长连接 Bot 独立 |
| 2026-08-30 | HQ 门店账单确认打款支持上传凭证照片(`payment_proof_urls`) |
diff --git a/docs/杜康好客-v3.5.15-开发文档.md b/docs/杜康好客-v3.5.15-开发文档.md
index 78e6f52..583ac0b 100644
--- a/docs/杜康好客-v3.5.15-开发文档.md
+++ b/docs/杜康好客-v3.5.15-开发文档.md
@@ -28,8 +28,8 @@
| 用户数量 | `status=1` 且未合并,`createdAt` < 期末 | 区间内创建 |
| 合伙人数量 | 主账号 `is_primary=1` | 区间内创建 |
| 门店数量 | 全部门店 | 区间内创建 |
-| 订单数量 | 按下单 `createdAt` | 区间内下单 |
-| 订单金额 | 已付 `payAmount`(`paidAt`) | 区间内支付 |
+| 订单数量 | 按下单 `createdAt`,排除待支付 / 已取消 / 退款中 | 区间内下单 |
+| 订单金额 | 已付 `payAmount`(`paidAt`),排除待支付 / 已取消 / 退款中 | 区间内支付 |
| 核销单数量 / 金额 | `RedeemRecord` | 区间内核销 |
- **日报**:发送日前一自然日(截账至发送日 0 点 / 前一天 24 点);默认 20:00 发送;新增文案「当日新增」。
diff --git a/docs/杜康好客-v3.5.16-开发文档.md b/docs/杜康好客-v3.5.16-开发文档.md
index 4c62b34..852b7c5 100644
--- a/docs/杜康好客-v3.5.16-开发文档.md
+++ b/docs/杜康好客-v3.5.16-开发文档.md
@@ -56,7 +56,7 @@ WECOM_PLUGIN_API_KEY=<随机长密钥>
| GET | `/promo-codes/:code/stats` | 推广码统计 |
| GET | `/metrics?kind=` | `today` \| `daily` \| `weekly` \| `monthly` |
-经营指标口径与 v3.5.15 报告一致:用户=有效未合并;订单金额=已付 `payAmount`(`paidAt`);核销=`RedeemRecord`。`today` 期末为当前时刻。
+经营指标口径与 v3.5.15 报告一致:用户=有效未合并;订单笔数与金额均排除待支付 / 已取消 / 退款中;订单金额=已付 `payAmount`(`paidAt`);核销=`RedeemRecord`。`today` 期末为当前时刻。
审计:`log_wecom_bot.botKey=plugin`。
diff --git a/docs/杜康好客-v3编码手册.md b/docs/杜康好客-v3编码手册.md
index 599b9de..0826780 100644
--- a/docs/杜康好客-v3编码手册.md
+++ b/docs/杜康好客-v3编码手册.md
@@ -46,12 +46,14 @@ 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。账期截在发送日北京 0 点(前一天 24 点),不含发送当天:日报=昨日存量+当日新增;周报/月报=上一自然周/月期末存量+本期新增。用户=有效未合并;合伙人=主账号;订单笔数与金额均排除待支付 / 已取消 / 退款中;订单金额=已付 `payAmount`(`paidAt`);核销=`RedeemRecord`。
**企微 API 插件(v3.5.17)**:实例在 HQ「企微机器人 → API 插件」维护;共用 `GET /api/v1/wecom/plugin/*`,Header `X-Api-Key` 区分实例并按权限放行。经营指标含新增门店(`newStores`);门店搜索含经营数据;支持门店/信息/套餐审核对照只读查询;支持按合伙人查关联用户/门店/订单(`from`/`to` 时间筛选)。
**HQ 列表(v3.5.9)**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。
+**HQ 门店账号子账号**:`GET /admin/store-accounts/:id` 详情含 `staff`;`POST /admin/store-accounts/:id/staff` · `PUT /admin/store-accounts/:id/staff/:staffId` · `DELETE /admin/store-accounts/:id/staff/:staffId`。仅主账号可挂子账号(不可多级);`storeIds` 必须是主账号已绑定门店;创建默认 `ACTIVE`、角色默认收银员、权限默认核销+记录;改登录手机解绑微信。门店端自管仍走 `GET/POST/PUT/DELETE /shop/staff`。
+
**C 端(v3.5.10)**:门店详情无顶栏分享按钮。同城送提示取开城仓库绑定承运商的 `delivery_hint_html`(`GET /catalog/local-deliveries`,按收货市是否开城);空则回退「同城配送,预计24小时内送到」。在线客服优先 `wx.openCustomerServiceChat`(`CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`);未配 CorpID 回退小程序原生客服。
**起购(v3.5.11)**:现场提货 / 同城 / 跨城阈值在城市 `pickup_min_qty` / `local_min_qty` / `cross_min_qty`(瓶当量)。承运商「起送瓶数」只用于物流计价,不拦下单。
diff --git a/docs/杜康好客-v4-PRD.md b/docs/杜康好客-v4-PRD.md
index 2b21aff..a030072 100644
--- a/docs/杜康好客-v4-PRD.md
+++ b/docs/杜康好客-v4-PRD.md
@@ -1,7 +1,7 @@
# 杜康好客 · V4 PRD
-> **v4.0**(2026-08-29)· 关联码与分佣事实源;**v4.0.6** 酒厂对账;**v4.0.7** HQ 活动图快链与勾选导出;**v4.0.9** 合伙人 H5 周结算与用户管理;**v4.0.14** HQ 概览粒度;**v4.0.15** HQ 概览折线图;**v4.0.18** 门店多收款账户与子账号继承码
-> 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、合伙人账单明细、活动图、酒厂对账、合伙人周结算、HQ 概览)。
+> **v4.0**(2026-08-29)· 关联码与分佣事实源;**v4.0.6** 酒厂对账;**v4.0.7** HQ 活动图快链与勾选导出;**v4.0.9** 合伙人 H5 周结算与用户管理;**v4.0.14** HQ 概览粒度;**v4.0.15** HQ 概览折线图;**v4.0.18** 门店多收款账户、子账号继承码、财务银行账户总目录
+> 未改规则仍见 [`杜康好客-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.0.18 开发文档`](./杜康好客-v4.0.18-开发文档.md) · 审计:[`v4-现状对照`](./杜康好客-v4-现状对照.md)
## 0. 版本
@@ -15,7 +15,7 @@
| 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) |
-| 4.0.18 | 09-07 | C 端门店列表省市区县地址;门店多收款账户(默认账户打款,切换无需重启);子账号独立继承二维码 + 子账号维度统计 | [`v4.0.18`](./杜康好客-v4.0.18-开发文档.md) |
+| 4.0.18 | 09-07 / 09-08 | C 端门店列表省+市+区+详细地址(原样拼接、不去重);门店多收款账户(默认账户打款,切换无需重启);子账号独立继承二维码 + 子账号维度统计;HQ 财务银行账户总目录 | [`v4.0.18`](./杜康好客-v4.0.18-开发文档.md) |
## 1. 锚点(沿用 V3,佣金归属改写)
@@ -106,6 +106,23 @@ HQ 财务详情与合伙人确认页均展示两段列表。不再「无快照
- **切换/新增/删除账户无需重启服务器**:银行字段均实时查库,无进程内缓存。
- 权限:门店主账号维护本店账户(子账号只读);总部可维护任意门店账户。
-## 9. 不做
+## 9. 财务银行账户总目录(v4.0.18)
+
+HQ「财务 → 银行账户」聚合**有效**收款账户,供财务查阅、筛选、导出;**不替代**各业务源的维护入口,也不作为打款主数据。
+
+| 类型 | 来源 | 有效 | 本页可写 |
+|------|------|------|----------|
+| 门店 | `store_bank_account` | ACTIVE 且户名+账号非空;含非默认 | 仅备注 |
+| 酒厂 | `WINERY_BANK_*` | 户名+账号已填 | 仅备注 |
+| 合伙人 | 主账号 `bank_account_*` | ACTIVE 且户名+账号非空 | 仅备注 |
+| 物流 | 承运商 `common_fulfillment_provider` | ACTIVE 且户名+账号非空 | 仅备注 |
+| 其他 | `finance_bank_account` | ACTIVE | 增删改(不挂门店,不进入打款) |
+
+- 列表字段:类型、归属、城市、户名、银行账号、开户行、备注;门店显示是否默认。
+- 筛选:类型、城市、关键字(户名/账号/开户行/归属/备注)。可导出 Excel 与 PDF。
+- 「其他」账户仅登记备查,不进入门店提现、账单打款、酒厂/物流对账。
+- 权限:`finance`。
+
+## 10. 不做
改推广码体系;改核销归属;回刷已打款账单;区县佣金双轨;AI 出图/出文案;C 端/门店端活动图;预生成每人缓存图。
diff --git a/docs/杜康好客-v4-现状对照.md b/docs/杜康好客-v4-现状对照.md
index eaff6e8..f12d46a 100644
--- a/docs/杜康好客-v4-现状对照.md
+++ b/docs/杜康好客-v4-现状对照.md
@@ -7,7 +7,7 @@
| 维度 | 结论 |
|------|------|
-| 版本线 | **v4.0.18** 门店多收款账户 + 子账号继承二维码(含 v4.0.15 HQ 概览折线图) |
+| 版本线 | **v4.0.18** 门店多收款账户 + 子账号继承二维码 + HQ 财务银行账户总目录(含 v4.0.15 HQ 概览折线图) |
| 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 |
| 账单 | 酒订单 / 核销订单分列;合伙人改为周账(周一 08:00);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) |
| 活动图 | HQ 上传底图/码栏/文案;**v4.0.15 上传超限自动压缩并提示尺寸**;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 |
@@ -24,7 +24,7 @@
| 4.0.13 | [`收货地址把关与拒单可感知`](./杜康好客-v4.0.13-开发文档.md) | ✅ 已实现 |
| 4.0.14 | [`HQ 概览粒度与环比`](./杜康好客-v4.0.14-开发文档.md) | ✅ 已实现 |
| 4.0.15 | [`HQ 概览折线图`](./杜康好客-v4.0.15-开发文档.md) | ✅ 已实现 |
-| 4.0.18 | [`门店多收款账户 + 子账号继承二维码`](./杜康好客-v4.0.18-开发文档.md) | ✅ 已实现 |
+| 4.0.18 | [`门店多收款账户 + 子账号继承二维码 + 财务银行账户总目录`](./杜康好客-v4.0.18-开发文档.md) | ✅ 已实现 |
| 日期 | 说明 |
|------|------|
@@ -39,3 +39,5 @@
| 2026-09-02 | v4.0.14:HQ 概览日/周/月/季/年、环比;全局城市/时间 + 五板块筛;订单/核销笔数与金额 |
| 2026-09-02 | v4.0.15:HQ 概览改为全宽折线图;去掉板块筛;查看快链只带全局城市与日期;活动图上传超限自动压缩并提示尺寸 |
| 2026-09-07 | v4.0.18:C 端门店列表省市区县地址;门店多收款账户(默认账户打款、切换无需重启);子账号独立继承码 + 子账号维度统计 |
+| 2026-09-08 | v4.0.18 修订:C 端门店地址「省+市+区+详细地址」原样拼接,详细地址已含省市区时不去重 |
+| 2026-09-08 | v4.0.18 追加:HQ 财务银行账户总目录(聚合门店/酒厂/合伙人/物流有效账户;可新增不挂门店账户;筛选与 Excel/PDF 导出) |
diff --git a/docs/杜康好客-v4.0.18-开发文档.md b/docs/杜康好客-v4.0.18-开发文档.md
index 9138c5f..ffb4c8c 100644
--- a/docs/杜康好客-v4.0.18-开发文档.md
+++ b/docs/杜康好客-v4.0.18-开发文档.md
@@ -1,7 +1,9 @@
# 杜康好客 · v4.0.18 开发文档
-> **2026-09-07** · mini-user / store / settlement / iam / h5-shop / admin-web / h5-partner / shared-types
-> **主题**:C 端门店列表省市区县地址;门店多收款账户;子账号独立继承二维码
+> **2026-09-07** · mini-user / store / settlement / iam / h5-shop / admin-web / h5-partner / shared-types / domain
+> **主题**:C 端门店列表省市区县地址;门店多收款账户;子账号独立继承二维码;HQ 财务银行账户总目录
+> **2026-09-08 修订**:门店列表地址按「省+市+区+详细地址」原样拼接,详细地址已含省市区时**不去重**
+> **2026-09-08 追加**:HQ 财务「银行账户」总目录(聚合门店/酒厂/合伙人/物流 + 不挂门店的「其他」账户)
---
@@ -9,11 +11,12 @@
| # | 任务 | 类型 | 交付 |
|---|------|------|------|
-| 1 | C 端门店列表地址 | 需求 | 「省市区县 + 详细地址」拼接展示,搜索同步匹配完整地址 |
+| 1 | C 端门店列表地址 | 需求 | 「省 + 市 + 区 + 详细地址」原样拼接展示(不去重);搜索匹配完整地址 |
| 2 | 门店多银行账号 | 需求 | 门店级收款账户列表;设「默认」账户用于打款;**切换无需重启** |
| 3 | 子账号二维码 | 需求 | 子账号独立继承码 `sa_{subId}`;新增一级子账号维度统计 |
+| 4 | HQ 财务银行账户总目录 | 需求 | 财务菜单下列出有效账户;可新增不挂门店的「其他」账户;类型/备注/筛选/Excel+PDF 导出 |
-**不做**:银行账号历史打款回刷;子账号佣金独立归属(佣金仍归主账号);按承运商维度的收款账户。
+**不做**:银行账号历史打款回刷;子账号佣金独立归属(佣金仍归主账号);按承运商维度的收款账户;「其他」账户接入打款;本页改写门店/合伙人/酒厂/物流源字段。
---
@@ -21,7 +24,23 @@
### 2.1 门店列表地址
-接口已返回 `province` / `cityName` / `district` / `address`,纯前端拼接。复用助手 `fullStoreAddress(store)`(`province + (cityName ?? city) + district + address` trim,空回退「地址待完善」),与门店详情页逻辑一致。
+C 端门店列表卡片「地址」一栏(门店详情页同规则)= **下拉省 + 下拉市 + 下拉区 + 详细地址 input 原文**,四段按字段原样拼接,中间不加分隔符。
+
+| 字段 | 来源 | 存库 |
+|------|------|------|
+| 省 | 录店/改店省市区下拉 | `store.province` |
+| 市 | 同上 | `store.cityName` |
+| 区 | 同上 | `store.district` |
+| 详细地址 | 用户自由输入(地图选点若回填也写入此字段) | `store.address` |
+
+**禁止去重**:不得因详细地址已包含省/市/区而省略前缀。助手 `formatStoreDisplayAddress`(`packages/domain`)/ `fullStoreAddress`(mini-user)只做 trim 后拼接;空则回退「地址待完善」。
+
+| 下拉省市区 | 详细地址 | 列表展示 |
+|------------|----------|----------|
+| 河南省 / 郑州市 / 金水区 | 金水东路333号 | `河南省郑州市金水区金水东路333号` |
+| 河南省 / 郑州市 / 金水区 | 河南省郑州市金水区金水东路333号 | `河南省郑州市金水区河南省郑州市金水区金水东路333号` |
+
+搜索关键字同时匹配 `address` / `district` / 完整拼接结果。规则实现:`formatStoreDisplayAddress`(domain,后端地理编码同用)与 mini-user `fullStoreAddress`(列表/详情展示)。
### 2.2 门店多银行账号
@@ -49,6 +68,26 @@ flowchart LR
- `getSummary`/`getStats`/`listUsers`/`listAssocOrders`:子账号调用时返回**自己维度**数据(`assocSubAccountId=子账号`);`activityPosterId` 恒为空;主账号保持聚合行为不变。
- 佣金仍归主账号,不因扫码来源为子账号而改变归属。
+### 2.4 HQ 财务银行账户总目录
+
+财务查阅与手工登记目录,**不改打款主数据**。打款仍读各业务源表(门店默认账户、合伙人主账号字段、承运商字段、酒厂 `WINERY_BANK_*`)。
+
+**有效账户**(同时满足才入列):户名、银行账号均非空;来源状态为有效。
+
+| 类型 | 数据源 | 有效条件 | 本页可写 |
+|------|--------|----------|----------|
+| 门店 | `store_bank_account` | `status=ACTIVE`;所有门店的全部有效账户(含非默认) | 仅备注 |
+| 酒厂 | `system_config` `WINERY_BANK_*` | 户名+账号已填 | 仅备注 |
+| 合伙人 | `partner_account` | `status=ACTIVE`、主账号、户名+账号已填 | 仅备注 |
+| 物流 | `common_fulfillment_provider` | `status=ACTIVE`、户名+账号已填(仓无银行字段) | 仅备注 |
+| 其他 | `finance_bank_account` | `status=ACTIVE` | 增删改 |
+
+- 列表:类型、归属、城市、户名、银行账号、开户行、备注;门店另显示是否默认。
+- **新增不关联门店** = 类型「其他」,不进入提现/账单打款/酒厂/物流对账。
+- 来源账户户名/账号/开户行仍在原处改;本页只读这些字段。备注:其他写本表;来源写 overlay 表 `finance_bank_account_note`。
+- 筛选:类型、城市、关键字(户名/账号/开户行/归属名/备注)。导出按当前筛选全量 Excel / PDF。
+- 权限:HQ `finance`。
+
---
## 3. API
@@ -75,32 +114,47 @@ flowchart LR
- `GET /partner/assoc/stats`、`GET /partner/assoc/users`、`GET /partner/assoc/orders`:子账号返回自己维度。
- `GET /partner/assoc/qrcode`:子账号下载自己的码。
+### 3.3 财务银行账户总目录(settlement 模块)
+
+| 方法 | 路径 | Guard | 说明 |
+|------|------|-------|------|
+| GET | `/admin/finance/bank-accounts` | HQ `finance` | 聚合列表;`type` / `cityId` / `keyword` / `page` / `pageSize` |
+| GET | `/admin/finance/bank-accounts/export` | HQ `finance` | `format=xlsx\|pdf`,同筛选全量 |
+| POST | `/admin/finance/bank-accounts` | HQ `finance` | 新增「其他」 |
+| PUT | `/admin/finance/bank-accounts/other/:id` | HQ `finance` | 编辑「其他」 |
+| DELETE | `/admin/finance/bank-accounts/other/:id` | HQ `finance` | 删除「其他」 |
+| PUT | `/admin/finance/bank-accounts/:id/remark` | HQ `finance` | 任意类型写备注;`id` 为复合键 |
+
+行 `id`:`STORE:{storeBankAccountId}` / `PARTNER:{partnerId}` / `LOGISTICS:{providerId}` / `WINERY:winery` / `OTHER:{id}`。`bankAccountNo` 校验 `^\d{8,32}$`。
+
---
## 4. 变更面
| 层 | 路径 |
|----|------|
-| Prisma | `schema.prisma`(新增 `StoreBankAccount`;`User.assocSubAccountId`) |
-| 迁移 | `migrate-store-bank-account-v4018.sql`、`migrate-user-assoc-sub-account-v4018.sql` |
-| shared-types | `settlement.ts`(`StoreBankAccountDto`);`partner-assoc.ts`(summary/touch/bind 增 `subAccountId`/`isSubAccount`) |
-| API store | `store-bank.service.ts`、`store-bank.controller.ts`(新增);`store.module.ts`;`store-bank.util.ts`(`loadStoreBankAccounts`/`loadStoreDefaultBank`);`partner-assoc.service.ts`(`sa_` 解析/生成/touch/bind/统计) |
-| API settlement | `settlement.service.ts`(summary/withdraw/admin-withdrawal/export 改读默认账户) |
+| Prisma | `schema.prisma`(新增 `StoreBankAccount`、`FinanceBankAccount`、`FinanceBankAccountNote`;`User.assocSubAccountId`) |
+| 迁移 | `migrate-store-bank-account-v4018.sql`、`migrate-user-assoc-sub-account-v4018.sql`、`migrate-finance-bank-account-v4018.sql` |
+| domain | `store-address.ts`(`formatStoreDisplayAddress`:省+市+区+详细地址原样拼接,不去重) |
+| shared-types | `settlement.ts`(`StoreBankAccountDto`、`FinanceBankAccountDto`);`hq-list-columns.ts`(`finance-bank-accounts`);`partner-assoc.ts`(summary/touch/bind 增 `subAccountId`/`isSubAccount`) |
+| API store | `store-bank.service.ts`、`store-bank.controller.ts`(新增);`store.module.ts`;`store-bank.util.ts`(`loadStoreBankAccounts`/`loadStoreDefaultBank`);`partner-assoc.service.ts`(`sa_` 解析/生成/touch/bind/统计);`store.service.ts`(地理编码地址与 C 端同规则拼接) |
+| API settlement | `settlement.service.ts`(summary/withdraw/admin-withdrawal/export 改读默认账户);`finance-bank-account.service.ts`、`finance-bank-export.util.ts`(财务银行账户总目录) |
| API ops | `admin-redeem.service.ts`(默认账户) |
-| mini-user | `stores/index.tsx`、`lib/store-display.ts`(`fullStoreAddress`)、`lib/promo.ts`(放行 `sa_`) |
+| mini-user | `stores/index.tsx`、`store-detail/index.tsx`、`lib/store-display.ts`(`fullStoreAddress`)、`lib/promo.ts`(放行 `sa_`) |
| h5-shop | `BankAccountsPage.tsx`(新增)、`App.tsx`、`MinePage.tsx`、`styles.css` |
-| admin-web | `StoreBankAccountsPanel.tsx`(新增)、`StoresPage.tsx`(收款账户页签) |
+| admin-web | `StoreBankAccountsPanel.tsx`(新增)、`StoresPage.tsx`(收款账户页签);`BankAccountsPage.tsx`(财务银行账户总目录) |
| h5-partner | `AssocQrcodePage.tsx`、`UsersManagePage.tsx`(子账号提示) |
---
## 5. 验收
-- [ ] C 端门店列表地址显示「省市区县 + 详细地址」;空值回退「地址待完善」;搜索「省/市/区县」关键词可命中
+- [ ] C 端门店列表地址 = 省+市+区+详细地址原文;详细地址已含省市区时仍重复拼接(例:金水东路333号 → `河南省郑州市金水区金水东路333号`;详细地址写全称 → `河南省郑州市金水区河南省郑州市金水区金水东路333号`);空值回退「地址待完善」;搜索「省/市/区县」关键词可命中
- [ ] 门店可新增/编辑/删除/设默认收款账户;默认账户用于提现打款与账单导出
- [ ] 切换默认账户后(无需重启)提现 summary 与打款信息即时生效
- [ ] 子账号可生成并下载自己的二维码(scene `sa_{subId}`),与主账号 `pa_{id}` 不冲突
- [ ] 扫子账号码:用户仍锁定主账号(佣金归主账号),并写入 `assoc_sub_account_id`
- [ ] 子账号用户管理/关联码页展示自己维度的已扫码/已关联/订单统计;主账号聚合 own+children 不变
- [ ] 主账号码扫码:`assoc_sub_account_id` 为空,行为与 v4.0.9 一致
+- [ ] HQ 财务菜单「银行账户」:列出全部有效门店账户(含非默认)+ 有效酒厂/主合伙人/承运商账户;可新增不挂门店账户;类型/城市/关键字筛选;Excel 与 PDF 导出与筛选一致;备注可写;来源账户改后刷新即更新
- [ ] shared-types 构建通过;相关 lint/单测过
diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts
index 49ea89a..cdaf0fd 100644
--- a/packages/domain/src/index.ts
+++ b/packages/domain/src/index.ts
@@ -415,3 +415,4 @@ export * from './dashboard-series';
export * from './wecom-report';
export * from './wecom-plugin';
export * from './shipping-address';
+export * from './store-address';
diff --git a/packages/domain/src/store-address.test.ts b/packages/domain/src/store-address.test.ts
new file mode 100644
index 0000000..84e7cf5
--- /dev/null
+++ b/packages/domain/src/store-address.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest';
+import { formatStoreDisplayAddress } from './store-address';
+
+describe('formatStoreDisplayAddress', () => {
+ it('concatenates dropdown region with street-only detail', () => {
+ expect(
+ formatStoreDisplayAddress({
+ province: '河南省',
+ cityName: '郑州市',
+ district: '金水区',
+ address: '金水东路333号',
+ }),
+ ).toBe('河南省郑州市金水区金水东路333号');
+ });
+
+ it('does not strip region prefix already present in detail', () => {
+ expect(
+ formatStoreDisplayAddress({
+ province: '河南省',
+ cityName: '郑州市',
+ district: '金水区',
+ address: '河南省郑州市金水区金水东路333号',
+ }),
+ ).toBe('河南省郑州市金水区河南省郑州市金水区金水东路333号');
+ });
+
+ it('returns fallback when all parts are empty', () => {
+ expect(formatStoreDisplayAddress({})).toBe('地址待完善');
+ expect(formatStoreDisplayAddress({}, '')).toBe('');
+ });
+});
diff --git a/packages/domain/src/store-address.ts b/packages/domain/src/store-address.ts
new file mode 100644
index 0000000..3309259
--- /dev/null
+++ b/packages/domain/src/store-address.ts
@@ -0,0 +1,29 @@
+export const STORE_ADDRESS_FALLBACK = '地址待完善';
+
+export type StoreAddressParts = {
+ province?: string | null;
+ cityName?: string | null;
+ city?: string | null;
+ district?: string | null;
+ address?: string | null;
+};
+
+function textPart(value: unknown): string {
+ return typeof value === 'string' ? value.trim() : '';
+}
+
+/**
+ * 门店展示地址:省 + 市 + 区 + 详细地址,按字段原样拼接。
+ * 省/市/区来自下拉选择,详细地址来自自由输入;即使详细地址已含省市区也不得去重。
+ */
+export function formatStoreDisplayAddress(
+ store: StoreAddressParts,
+ fallback = STORE_ADDRESS_FALLBACK,
+): string {
+ const province = textPart(store.province);
+ const city = textPart(store.cityName) || textPart(store.city);
+ const district = textPart(store.district);
+ const detail = textPart(store.address);
+ const text = `${province}${city}${district}${detail}`;
+ return text || fallback;
+}
diff --git a/packages/domain/src/wecom-report.test.ts b/packages/domain/src/wecom-report.test.ts
index 708a342..f91ff33 100644
--- a/packages/domain/src/wecom-report.test.ts
+++ b/packages/domain/src/wecom-report.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest';
import {
formatWecomReportMarkdown,
+ WECOM_REPORT_EXCLUDED_ORDER_STATUSES,
+ wecomReportCountedOrderWhere,
wecomReportCutoff,
wecomReportDueAt,
wecomReportPeriod,
@@ -24,6 +26,19 @@ const emptyStats = {
redeemAmountIncrement: 40,
};
+describe('wecomReportCountedOrderWhere', () => {
+ it('excludes pending pay, cancelled and refunding', () => {
+ expect([...WECOM_REPORT_EXCLUDED_ORDER_STATUSES]).toEqual([
+ 'PENDING_PAY',
+ 'CANCELLED',
+ 'REFUNDING',
+ ]);
+ expect(wecomReportCountedOrderWhere()).toEqual({
+ status: { notIn: ['PENDING_PAY', 'CANCELLED', 'REFUNDING'] },
+ });
+ });
+});
+
describe('wecomReportPeriod', () => {
it('daily is the previous Shanghai calendar day (closed at send-day 00:00)', () => {
const p = wecomReportPeriod('daily', new Date('2026-09-02T20:00:00+08:00'));
diff --git a/packages/domain/src/wecom-report.ts b/packages/domain/src/wecom-report.ts
index 7931741..c3bb71f 100644
--- a/packages/domain/src/wecom-report.ts
+++ b/packages/domain/src/wecom-report.ts
@@ -17,6 +17,20 @@ export function isWecomReportKind(v: string): v is WecomReportKind {
return (WECOM_REPORT_KINDS as readonly string[]).includes(v);
}
+/** 经营报告/插件指标:订单笔数与金额均不计入待支付、已取消、退款中 */
+export const WECOM_REPORT_EXCLUDED_ORDER_STATUSES = [
+ 'PENDING_PAY',
+ 'CANCELLED',
+ 'REFUNDING',
+] as const;
+
+export type WecomReportExcludedOrderStatus =
+ (typeof WECOM_REPORT_EXCLUDED_ORDER_STATUSES)[number];
+
+export function wecomReportCountedOrderWhere() {
+ return { status: { notIn: [...WECOM_REPORT_EXCLUDED_ORDER_STATUSES] } };
+}
+
export type WecomReportStats = {
usersTotal: number;
usersIncrement: number;
diff --git a/packages/shared-types/src/hq-list-columns.ts b/packages/shared-types/src/hq-list-columns.ts
index a44d7cc..5b1867e 100644
--- a/packages/shared-types/src/hq-list-columns.ts
+++ b/packages/shared-types/src/hq-list-columns.ts
@@ -29,6 +29,7 @@ export const HQ_LIST_COLUMN_KEYS = [
'finance-partner-bills',
'finance-winery-bills',
'finance-logistics-bills',
+ 'finance-bank-accounts',
'finance-store-withdrawals',
'benefit-coupons',
'benefit-ledgers',
diff --git a/packages/shared-types/src/settlement.ts b/packages/shared-types/src/settlement.ts
index 7abab15..59bf2c9 100644
--- a/packages/shared-types/src/settlement.ts
+++ b/packages/shared-types/src/settlement.ts
@@ -74,6 +74,45 @@ export interface StoreBankAccountInputDto {
bankBranch?: string;
}
+/** HQ 财务银行账户总目录类型(v4.0.18) */
+export const FINANCE_BANK_ACCOUNT_TYPES = ['STORE', 'WINERY', 'PARTNER', 'LOGISTICS', 'OTHER'] as const;
+export type FinanceBankAccountType = (typeof FINANCE_BANK_ACCOUNT_TYPES)[number];
+
+export const FINANCE_BANK_ACCOUNT_TYPE_LABELS: Record = {
+ STORE: '门店',
+ WINERY: '酒厂',
+ PARTNER: '合伙人',
+ LOGISTICS: '物流',
+ OTHER: '其他',
+};
+
+export interface FinanceBankAccountDto {
+ id: string;
+ type: FinanceBankAccountType;
+ ownerName: string;
+ ownerId?: string | null;
+ cityId?: string | null;
+ cityName?: string | null;
+ bankAccountName: string;
+ bankAccountNo: string;
+ bankBranch?: string | null;
+ remark?: string | null;
+ isDefault?: boolean;
+ editable: boolean;
+}
+
+export interface FinanceBankAccountInputDto {
+ name?: string;
+ bankAccountName: string;
+ bankAccountNo: string;
+ bankBranch?: string;
+ remark?: string;
+}
+
+export interface FinanceBankAccountRemarkDto {
+ remark?: string;
+}
+
export interface StoreWithdrawSummaryDto {
availableAmount: number;
pendingReviewAmount: number;
diff --git a/packages/shared-types/src/shop.ts b/packages/shared-types/src/shop.ts
index e10ffe4..c84495b 100644
--- a/packages/shared-types/src/shop.ts
+++ b/packages/shared-types/src/shop.ts
@@ -75,6 +75,26 @@ export interface UpdateShopStaffRequest {
storeIds?: string[];
}
+/** HQ 管理门店子账号:创建体与门店端一致,更新可改登录手机。 */
+export type CreateAdminStoreStaffRequest = CreateShopStaffRequest;
+
+export interface UpdateAdminStoreStaffRequest extends UpdateShopStaffRequest {
+ phone?: string;
+}
+
+export interface AdminStoreStaffItem {
+ id: string;
+ name: string;
+ phone: string;
+ staffRole: StoreStaffRole;
+ permissions: string[];
+ status: AccountStatus;
+ storeIds: string[];
+ stores: Array<{ id: string; name: string; status: string }>;
+ lastLoginAt?: string | null;
+ createdAt?: string;
+}
+
export const STORE_STAFF_ROLE_LABELS: Record = {
MANAGER: '店长',
CASHIER: '收银员',
@@ -82,3 +102,8 @@ export const STORE_STAFF_ROLE_LABELS: Record = {
/** Default permissions for store sub-accounts (首版). */
export const STORE_STAFF_DEFAULT_PERMISSIONS = ['redeem', 'records'] as const;
+
+export const STORE_STAFF_PERMISSION_LABELS: Record<(typeof STORE_STAFF_DEFAULT_PERMISSIONS)[number], string> = {
+ redeem: '核销',
+ records: '核销记录',
+};
diff --git a/packages/shared-types/src/wecom-message-push.ts b/packages/shared-types/src/wecom-message-push.ts
index ad49265..4b0ed93 100644
--- a/packages/shared-types/src/wecom-message-push.ts
+++ b/packages/shared-types/src/wecom-message-push.ts
@@ -40,7 +40,7 @@ export const WECOM_PUSH_CONDITION_LABELS: Record = {
'redeem.success': '门店核销成功',
'invoice.pending': '发票申请待开票',
'finance.store_bill': '门店日账单已生成',
- 'finance.partner_bill': '合伙人月账单已生成',
+ 'finance.partner_bill': '合伙人账单',
'finance.winery_bill': '酒厂日账单已生成',
'finance.logistics_bill': '物流月对账已生成',
};
@@ -183,7 +183,7 @@ export const WECOM_TEMPLATE_EVENT_LABELS: Record
'store.withdraw_approved': '门店手动提现已通过',
'invoice.pending': '发票申请待开票',
'finance.store_bill': '门店日账单已生成',
- 'finance.partner_bill': '合伙人月账单已生成',
+ 'finance.partner_bill': '合伙人账单',
'finance.winery_bill': '酒厂日账单已生成',
'finance.logistics_bill': '物流月对账已生成',
};
diff --git a/server/dukang-api/prisma/migrate-finance-bank-account-v4018.sql b/server/dukang-api/prisma/migrate-finance-bank-account-v4018.sql
new file mode 100644
index 0000000..aeb4e0a
--- /dev/null
+++ b/server/dukang-api/prisma/migrate-finance-bank-account-v4018.sql
@@ -0,0 +1,26 @@
+-- v4.0.18:HQ 财务银行账户总目录(其他账户 + 来源账户备注 overlay)
+
+CREATE TABLE IF NOT EXISTS `finance_bank_account` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `name` VARCHAR(128) NULL,
+ `bank_account_name` VARCHAR(64) NOT NULL,
+ `bank_account_no` VARCHAR(32) NOT NULL,
+ `bank_branch` VARCHAR(128) NULL,
+ `remark` VARCHAR(256) NULL,
+ `status` ENUM('ACTIVE','DISABLED') NOT NULL DEFAULT 'ACTIVE',
+ `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),
+ PRIMARY KEY (`id`),
+ KEY `idx_finance_bank_account_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='财务手工银行账户(不挂门店)';
+
+CREATE TABLE IF NOT EXISTS `finance_bank_account_note` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `owner_type` ENUM('STORE','WINERY','PARTNER','LOGISTICS') NOT NULL,
+ `source_id` VARCHAR(64) NOT NULL,
+ `remark` VARCHAR(256) NOT 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),
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_finance_bank_account_note_owner_source` (`owner_type`, `source_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='财务银行账户备注 overlay';
diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma
index 1236a4e..d7a156c 100644
--- a/server/dukang-api/prisma/schema.prisma
+++ b/server/dukang-api/prisma/schema.prisma
@@ -262,6 +262,13 @@ enum AccountStatus {
DISABLED
}
+enum FinanceBankAccountOwnerType {
+ STORE
+ WINERY
+ PARTNER
+ LOGISTICS
+}
+
enum HqAdminRole {
SUPER_ADMIN
OPS
@@ -2176,6 +2183,35 @@ model WineryBillItem {
@@map("winery_bill_item")
}
+/// HQ 财务手工登记的银行账户(不挂门店,不进入打款)
+model FinanceBankAccount {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ name String? @db.VarChar(128)
+ bankAccountName String @map("bank_account_name") @db.VarChar(64)
+ bankAccountNo String @map("bank_account_no") @db.VarChar(32)
+ bankBranch String? @map("bank_branch") @db.VarChar(128)
+ remark String? @db.VarChar(256)
+ status AccountStatus @default(ACTIVE)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ @@index([status])
+ @@map("finance_bank_account")
+}
+
+/// HQ 财务对来源账户的备注 overlay(不改写门店/合伙人/酒厂/物流源字段)
+model FinanceBankAccountNote {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ ownerType FinanceBankAccountOwnerType @map("owner_type")
+ sourceId String @map("source_id") @db.VarChar(64)
+ remark String @db.VarChar(256)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ @@unique([ownerType, sourceId])
+ @@map("finance_bank_account_note")
+}
+
// ─── LOG ──────────────────────────────────────────────
model LogThirdParty {
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 050e4f5..09a58e5 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
@@ -35,6 +35,8 @@ export const HqOperationAction = {
STORE_AUDIT: 'STORE_AUDIT',
STORE_ACCOUNT_CREATE: 'STORE_ACCOUNT_CREATE',
STORE_ACCOUNT_UPDATE: 'STORE_ACCOUNT_UPDATE',
+ STORE_ACCOUNT_STAFF_CREATE: 'STORE_ACCOUNT_STAFF_CREATE',
+ STORE_ACCOUNT_STAFF_UPDATE: 'STORE_ACCOUNT_STAFF_UPDATE',
STORE_ACCOUNT_STAFF_DELETE: 'STORE_ACCOUNT_STAFF_DELETE',
STORE_MEDIA_CREATE: 'STORE_MEDIA_CREATE',
STORE_MEDIA_UPDATE: 'STORE_MEDIA_UPDATE',
@@ -176,6 +178,8 @@ export const HQ_OPERATION_ACTION_LABELS: Record = {
[HqOperationAction.STORE_AUDIT]: '门店审核',
[HqOperationAction.STORE_ACCOUNT_CREATE]: '新增门店账户',
[HqOperationAction.STORE_ACCOUNT_UPDATE]: '编辑门店账户',
+ [HqOperationAction.STORE_ACCOUNT_STAFF_CREATE]: '新增门店子账号',
+ [HqOperationAction.STORE_ACCOUNT_STAFF_UPDATE]: '编辑门店子账号',
[HqOperationAction.STORE_ACCOUNT_STAFF_DELETE]: '删除门店子账号',
[HqOperationAction.STORE_MEDIA_CREATE]: '新增门店资源',
[HqOperationAction.STORE_MEDIA_UPDATE]: '编辑门店资源',
diff --git a/server/dukang-api/src/integrations/wecom/wecom-message-push.service.ts b/server/dukang-api/src/integrations/wecom/wecom-message-push.service.ts
index 1ec7180..03ee964 100644
--- a/server/dukang-api/src/integrations/wecom/wecom-message-push.service.ts
+++ b/server/dukang-api/src/integrations/wecom/wecom-message-push.service.ts
@@ -30,6 +30,11 @@ import {
renderWecomTemplate,
} from './wecom-push-template.defaults';
+/** 含 markdown 表格时改走 markdown_v2,群聊才能渲染表格 */
+function wecomMarkdownUsesTable(content: string): boolean {
+ return /\|[^\n]*\|\s*\n\s*\|?\s*:?-{3,}/.test(content);
+}
+
type PushRow = {
id: bigint;
name: string;
@@ -327,14 +332,16 @@ export class WecomMessagePushService implements OnModuleInit {
async sendMarkdownToWebhook(webhookUrl: string, content: string): Promise {
const url = (webhookUrl || '').trim();
if (!url) return false;
+ const useTable = wecomMarkdownUsesTable(content);
+ const text = content.slice(0, useTable ? 4096 : 4000);
+ const payload = useTable
+ ? { msgtype: 'markdown_v2', markdown_v2: { content: text } }
+ : { msgtype: 'markdown', markdown: { content: text } };
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- msgtype: 'markdown',
- markdown: { content: content.slice(0, 4000) },
- }),
+ body: JSON.stringify(payload),
});
const data = (await res.json().catch(() => ({}))) as {
errcode?: number;
@@ -580,20 +587,23 @@ export class WecomMessagePushService implements OnModuleInit {
},
'finance.partner_bill': {
vars: {
- period: '2026-07',
+ period: '2026-07-27 ~ 2026-08-02',
billCount: '1',
totalAmount: '120.00',
billSummary: [
- '城市:郑州',
- '合伙人:郑州某某商贸-张三',
- '订单笔数:8',
- '核销笔数:12',
- '订单佣金:¥100.00',
- '核销佣金:¥20.00',
- '账单:¥120.00',
- '收款人:张三',
- '收款账户:6222000011112222',
- '收款开户行:郑州支行',
+ '| 郑州某某商贸-张三 | |',
+ '| :--- | ---: |',
+ '| 账期 | 2026-07-27 ~ 2026-08-02 |',
+ '| 订单数量 | 8 |',
+ '| 订单金额 | ¥1000.00 |',
+ '| 订单佣金 | ¥100.00 |',
+ '| 核销单数量 | 12 |',
+ '| 核销金额 | ¥200.00 |',
+ '| 核销佣金 | ¥20.00 |',
+ '| 累计金额 | ¥120.00 |',
+ '| 收款人 | 张三 |',
+ '| 收款银行账号 | 6222000011112222 |',
+ '| 开户行 | 郑州支行 |',
].join('\n'),
},
handlePath: '/finance/partner-bills',
diff --git a/server/dukang-api/src/integrations/wecom/wecom-plugin-query.service.ts b/server/dukang-api/src/integrations/wecom/wecom-plugin-query.service.ts
index 0bf2259..24d1436 100644
--- a/server/dukang-api/src/integrations/wecom/wecom-plugin-query.service.ts
+++ b/server/dukang-api/src/integrations/wecom/wecom-plugin-query.service.ts
@@ -9,7 +9,8 @@ import {
toWecomPluginMetricsView,
toWecomPluginUserView,
wecomPluginMetricsPeriod,
- type WecomReportStats,
+ wecomReportCountedOrderWhere,
+ type WecomReportStats,
} from '@dukang/domain';
import {
WECOM_PLUGIN_TOOL_PATHS,
@@ -428,10 +429,11 @@ export class WecomPluginQueryService {
);
}
- /** 日报口径:用户=有效未合并;订单金额=已付 payAmount;核销=RedeemRecord。today 期末为当前时刻。 */
+ /** 日报口径:用户=有效未合并;订单排除待支付/已取消/退款中;金额=已付 payAmount;核销=RedeemRecord。today 期末为当前时刻。 */
private async loadStats(start: Date, cutoff: Date): Promise {
const userBase = { status: 1, mergedIntoUserId: null } as const;
const partnerBase = { isPrimary: 1 } as const;
+ const countedOrder = wecomReportCountedOrderWhere();
const paid = { payStatus: 'PAID' as const };
const [
@@ -462,15 +464,15 @@ export class WecomPluginQueryService {
}),
this.prisma.store.count({ where: { createdAt: { lt: cutoff } } }),
this.prisma.store.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
- this.prisma.order.count({ where: { createdAt: { lt: cutoff } } }),
- this.prisma.order.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
+ this.prisma.order.count({ where: { ...countedOrder, createdAt: { lt: cutoff } } }),
+ this.prisma.order.count({ where: { ...countedOrder, createdAt: { gte: start, lt: cutoff } } }),
this.prisma.order.aggregate({
_sum: { payAmount: true },
- where: { ...paid, paidAt: { lt: cutoff } },
+ where: { ...countedOrder, ...paid, paidAt: { lt: cutoff } },
}),
this.prisma.order.aggregate({
_sum: { payAmount: true },
- where: { ...paid, paidAt: { gte: start, lt: cutoff } },
+ where: { ...countedOrder, ...paid, paidAt: { gte: start, lt: cutoff } },
}),
this.prisma.redeemRecord.count({ where: { createdAt: { lt: cutoff } } }),
this.prisma.redeemRecord.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
diff --git a/server/dukang-api/src/integrations/wecom/wecom-plugin.openapi.ts b/server/dukang-api/src/integrations/wecom/wecom-plugin.openapi.ts
index a9ccaa6..791bbb1 100644
--- a/server/dukang-api/src/integrations/wecom/wecom-plugin.openapi.ts
+++ b/server/dukang-api/src/integrations/wecom/wecom-plugin.openapi.ts
@@ -305,7 +305,7 @@ export const WECOM_PLUGIN_OPENAPI = {
get: {
summary: '经营指标',
description:
- 'today=今日截至当前;daily/weekly/monthly 与企微经营报告同一口径。stats 含 users/partners/stores/orders 存量与增量;storesIncrement 与 newStores 均为新增门店数。',
+ 'today=今日截至当前;daily/weekly/monthly 与企微经营报告同一口径。订单排除待支付/已取消/退款中。stats 含 users/partners/stores/orders 存量与增量;storesIncrement 与 newStores 均为新增门店数。',
operationId: '查询经营指标',
parameters: [
{
diff --git a/server/dukang-api/src/integrations/wecom/wecom-push-template.defaults.ts b/server/dukang-api/src/integrations/wecom/wecom-push-template.defaults.ts
index c375c17..613967d 100644
--- a/server/dukang-api/src/integrations/wecom/wecom-push-template.defaults.ts
+++ b/server/dukang-api/src/integrations/wecom/wecom-push-template.defaults.ts
@@ -150,13 +150,9 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
},
{
eventKey: 'finance.partner_bill',
- title: '合伙人月账单已生成',
+ title: '合伙人账单',
body: [
- '**合伙人月账单已生成**',
- '账期:{{period}}',
- '账单笔数:{{billCount}}',
- '累计金额:¥{{totalAmount}}',
- '时间:{{time}}',
+ '**合伙人账单**',
'',
'{{billSummary}}',
'',
@@ -315,6 +311,17 @@ export const WECOM_PUSH_TEMPLATE_LEGACY_BODIES: Partial b.store),
store: account.bindings[0]?.store ?? null,
- staff: account.childAccounts,
+ staff: account.childAccounts.map((row) => this.mapStoreStaff(row)),
});
}
@@ -1120,17 +1126,102 @@ export class AdminStoresService {
return serializeBigInt(account);
}
+ async createStoreStaff(parentAccountId: bigint, dto: CreateStoreAccountStaffDto, actorId: bigint) {
+ const parent = await this.assertPrimaryStoreAccount(parentAccountId, actorId);
+ const phone = dto.phone.trim();
+ if (!isMobilePhone(phone)) {
+ throw new BadRequestException('请输入正确的手机号码');
+ }
+ const existing = await this.prisma.storeAccount.findUnique({ where: { phone } });
+ if (existing) throw new BadRequestException('该手机号已被使用');
+
+ const name = dto.name.trim();
+ if (!name) throw new BadRequestException('请填写真实姓名');
+
+ const storeIds = await this.resolveStaffStoreIds(parent.id, dto.storeIds);
+ const staffRole = (dto.staffRole as StoreStaffRole | undefined) ?? StoreStaffRole.CASHIER;
+ const permissions = dto.permissions?.length
+ ? dto.permissions
+ : [...STORE_STAFF_DEFAULT_PERMISSIONS];
+
+ const account = await this.prisma.storeAccount.create({
+ data: {
+ phone,
+ name,
+ isPrimary: 0,
+ parentAccountId: parent.id,
+ staffRole,
+ permissions,
+ status: 'ACTIVE',
+ isTest: parent.isTest,
+ bindings: {
+ create: storeIds.map((storeId) => ({ storeId })),
+ },
+ },
+ include: this.staffBindingsInclude,
+ });
+
+ return serializeBigInt(this.mapStoreStaff(account));
+ }
+
+ async updateStoreStaff(
+ parentAccountId: bigint,
+ staffId: bigint,
+ dto: UpdateStoreAccountStaffDto,
+ actorId: bigint,
+ ) {
+ const parent = await this.assertPrimaryStoreAccount(parentAccountId, actorId);
+ const staff = await this.assertStaffOwned(parent.id, staffId);
+
+ const data: Prisma.StoreAccountUpdateInput = {};
+ if (dto.name !== undefined) {
+ const name = dto.name.trim();
+ if (!name) throw new BadRequestException('请填写真实姓名');
+ data.name = name;
+ }
+ if (dto.staffRole !== undefined) data.staffRole = dto.staffRole as StoreStaffRole;
+ if (dto.permissions !== undefined) data.permissions = dto.permissions;
+ if (dto.status !== undefined) data.status = dto.status as 'ACTIVE' | 'DISABLED';
+ if (dto.phone !== undefined) {
+ const phone = dto.phone.trim();
+ if (!isMobilePhone(phone)) {
+ throw new BadRequestException('请输入正确的手机号码');
+ }
+ const phoneTaken = await this.prisma.storeAccount.findUnique({ where: { phone } });
+ if (phoneTaken && phoneTaken.id !== staff.id) {
+ throw new BadRequestException('该手机号已被使用');
+ }
+ data.phone = phone;
+ if (phone !== staff.phone) {
+ data.wxOpenId = null;
+ data.wxUnionId = null;
+ }
+ }
+
+ if (dto.storeIds !== undefined) {
+ const storeIds = await this.resolveStaffStoreIds(parent.id, dto.storeIds);
+ await this.prisma.$transaction([
+ this.prisma.storeAccountStore.deleteMany({ where: { storeAccountId: staff.id } }),
+ this.prisma.storeAccountStore.createMany({
+ data: storeIds.map((storeId) => ({ storeAccountId: staff.id, storeId })),
+ }),
+ this.prisma.storeAccount.update({ where: { id: staff.id }, data }),
+ ]);
+ } else if (Object.keys(data).length) {
+ await this.prisma.storeAccount.update({ where: { id: staff.id }, data });
+ }
+
+ const updated = await this.prisma.storeAccount.findUniqueOrThrow({
+ where: { id: staff.id },
+ include: this.staffBindingsInclude,
+ });
+ return serializeBigInt(this.mapStoreStaff(updated));
+ }
+
/** HQ 删除门店子账号(非主账号) */
async deleteStoreStaff(parentAccountId: bigint, staffId: bigint, actorId: bigint) {
- await this.assertStoreAccountInScope(actorId, parentAccountId);
- const parent = await this.prisma.storeAccount.findUnique({ where: { id: parentAccountId } });
- if (!parent || parent.isPrimary !== 1) {
- throw new BadRequestException('主账号不存在');
- }
- const staff = await this.prisma.storeAccount.findFirst({
- where: { id: staffId, parentAccountId, isPrimary: 0 },
- });
- if (!staff) throw new NotFoundException('子账号不存在');
+ const parent = await this.assertPrimaryStoreAccount(parentAccountId, actorId);
+ const staff = await this.assertStaffOwned(parent.id, staffId);
const pending = await this.prisma.redeemPendingRecord.count({
where: { storeAccountId: staffId },
@@ -1139,7 +1230,76 @@ export class AdminStoresService {
throw new BadRequestException('该子账号仍有待处理核销单,无法删除');
}
- await this.prisma.storeAccount.delete({ where: { id: staffId } });
+ await this.prisma.storeAccount.delete({ where: { id: staff.id } });
return { ok: true };
}
+
+ private readonly staffBindingsInclude = {
+ bindings: {
+ include: {
+ store: { select: { id: true, name: true, status: true } },
+ },
+ },
+ } as const;
+
+ private async assertPrimaryStoreAccount(parentAccountId: bigint, actorId: bigint) {
+ await this.assertStoreAccountInScope(actorId, parentAccountId);
+ const parent = await this.prisma.storeAccount.findUnique({ where: { id: parentAccountId } });
+ if (!parent || parent.isPrimary !== 1) {
+ throw new BadRequestException('主账号不存在');
+ }
+ return parent;
+ }
+
+ private async assertStaffOwned(parentAccountId: bigint, staffId: bigint) {
+ const staff = await this.prisma.storeAccount.findFirst({
+ where: { id: staffId, parentAccountId, isPrimary: 0 },
+ });
+ if (!staff) throw new NotFoundException('子账号不存在');
+ return staff;
+ }
+
+ /** 子账号只能绑定主账号已管理的门店 */
+ private async resolveStaffStoreIds(primaryAccountId: bigint, storeIds: string[]) {
+ const unique = [...new Set(storeIds.map((id) => id.trim()).filter(Boolean))];
+ if (!unique.length) throw new BadRequestException('请至少绑定一家门店');
+ const ids = unique.map((id) => BigInt(id));
+ const owned = await this.prisma.storeAccountStore.findMany({
+ where: { storeAccountId: primaryAccountId, storeId: { in: ids } },
+ select: { storeId: true },
+ });
+ if (owned.length !== ids.length) {
+ throw new BadRequestException('只能绑定主账号已管理的门店');
+ }
+ return ids;
+ }
+
+ private mapStoreStaff(row: {
+ id: bigint;
+ name: string;
+ phone: string;
+ staffRole: string | null;
+ permissions: Prisma.JsonValue;
+ status: string;
+ lastLoginAt: Date | null;
+ createdAt: Date;
+ bindings: Array<{ store: { id: bigint; name: string; status: string } }>;
+ }) {
+ return {
+ id: row.id.toString(),
+ name: row.name,
+ phone: row.phone,
+ staffRole: row.staffRole ?? StoreStaffRole.CASHIER,
+ permissions: Array.isArray(row.permissions) ? row.permissions : [],
+ status: row.status,
+ storeIds: row.bindings.map((b) => b.store.id.toString()),
+ stores: row.bindings.map((b) => ({
+ id: b.store.id.toString(),
+ name: b.store.name,
+ status: b.store.status,
+ })),
+ lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
+ createdAt: row.createdAt.toISOString(),
+ };
+ }
}
diff --git a/server/dukang-api/src/modules/ops/admin-wecom-reports.service.ts b/server/dukang-api/src/modules/ops/admin-wecom-reports.service.ts
index 0c19161..5f49272 100644
--- a/server/dukang-api/src/modules/ops/admin-wecom-reports.service.ts
+++ b/server/dukang-api/src/modules/ops/admin-wecom-reports.service.ts
@@ -10,6 +10,7 @@ import { Prisma, type WecomReportPush } from '@prisma/client';
import {
formatWecomReportMarkdown,
isWecomReportKind,
+ wecomReportCountedOrderWhere,
wecomReportCutoff,
wecomReportPeriod,
wecomReportShouldFire,
@@ -274,6 +275,7 @@ export class AdminWecomReportsService implements OnModuleInit {
private async loadStats(start: Date, cutoff: Date): Promise {
const userBase = { status: 1, mergedIntoUserId: null } as const;
const partnerBase = { isPrimary: 1 } as const;
+ const countedOrder = wecomReportCountedOrderWhere();
const paid = { payStatus: 'PAID' as const };
const [
@@ -304,15 +306,15 @@ export class AdminWecomReportsService implements OnModuleInit {
}),
this.prisma.store.count({ where: { createdAt: { lt: cutoff } } }),
this.prisma.store.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
- this.prisma.order.count({ where: { createdAt: { lt: cutoff } } }),
- this.prisma.order.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
+ this.prisma.order.count({ where: { ...countedOrder, createdAt: { lt: cutoff } } }),
+ this.prisma.order.count({ where: { ...countedOrder, createdAt: { gte: start, lt: cutoff } } }),
this.prisma.order.aggregate({
_sum: { payAmount: true },
- where: { ...paid, paidAt: { lt: cutoff } },
+ where: { ...countedOrder, ...paid, paidAt: { lt: cutoff } },
}),
this.prisma.order.aggregate({
_sum: { payAmount: true },
- where: { ...paid, paidAt: { gte: start, lt: cutoff } },
+ where: { ...countedOrder, ...paid, paidAt: { gte: start, lt: cutoff } },
}),
this.prisma.redeemRecord.count({ where: { createdAt: { lt: cutoff } } }),
this.prisma.redeemRecord.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts
index 2d2b4e5..7bc31a6 100644
--- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts
+++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts
@@ -1,6 +1,7 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
+ ArrayMinSize,
IsArray,
IsBoolean,
IsIn,
@@ -16,6 +17,7 @@ import {
ValidateIf,
ValidateNested,
} from 'class-validator';
+import { AccountStatus, StoreStaffRole } from '@dukang/shared-types';
export class UpdateStoreStatusDto {
@IsString()
@@ -341,6 +343,61 @@ export class UpdateStoreAccountDto {
status?: string;
}
+export class CreateStoreAccountStaffDto {
+ @IsString()
+ @IsNotEmpty()
+ phone: string;
+
+ @IsString()
+ @IsNotEmpty()
+ name: string;
+
+ @IsArray()
+ @ArrayMinSize(1)
+ @IsString({ each: true })
+ storeIds: string[];
+
+ @IsOptional()
+ @IsString()
+ @IsIn(Object.values(StoreStaffRole))
+ staffRole?: string;
+
+ @IsOptional()
+ @IsArray()
+ @IsString({ each: true })
+ permissions?: string[];
+}
+
+export class UpdateStoreAccountStaffDto {
+ @IsOptional()
+ @IsString()
+ name?: string;
+
+ @IsOptional()
+ @IsString()
+ phone?: string;
+
+ @IsOptional()
+ @IsString()
+ @IsIn(Object.values(StoreStaffRole))
+ staffRole?: string;
+
+ @IsOptional()
+ @IsArray()
+ @IsString({ each: true })
+ permissions?: string[];
+
+ @IsOptional()
+ @IsString()
+ @IsIn(Object.values(AccountStatus))
+ status?: string;
+
+ @IsOptional()
+ @IsArray()
+ @IsString({ each: true })
+ storeIds?: string[];
+}
+
export class CreatePartnerDto {
@IsString()
@IsNotEmpty()
diff --git a/server/dukang-api/src/modules/settlement/finance-bank-account.service.ts b/server/dukang-api/src/modules/settlement/finance-bank-account.service.ts
new file mode 100644
index 0000000..bffa0b5
--- /dev/null
+++ b/server/dukang-api/src/modules/settlement/finance-bank-account.service.ts
@@ -0,0 +1,354 @@
+import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
+import {
+ FINANCE_BANK_ACCOUNT_TYPES,
+ type FinanceBankAccountDto,
+ type FinanceBankAccountInputDto,
+ type FinanceBankAccountType,
+} from '@dukang/shared-types';
+import { PrismaService } from '../../common/prisma/prisma.module';
+import { loadWineryBankConfig } from '../../common/store/store-bank.util';
+import {
+ buildFinanceBankAccountsPdf,
+ buildFinanceBankAccountsXlsx,
+ buildFinanceBankExportFilename,
+} from './finance-bank-export.util';
+
+const BANK_NO_RE = /^\d{8,32}$/;
+const TYPE_ORDER: Record = {
+ STORE: 0,
+ WINERY: 1,
+ PARTNER: 2,
+ LOGISTICS: 3,
+ OTHER: 4,
+};
+
+const SOURCE_NOTE_TYPES = ['STORE', 'WINERY', 'PARTNER', 'LOGISTICS'] as const;
+
+export type FinanceBankAccountListQuery = {
+ type?: string;
+ cityId?: string;
+ keyword?: string;
+ page?: number;
+ pageSize?: number;
+};
+
+@Injectable()
+export class FinanceBankAccountService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ async list(query: FinanceBankAccountListQuery) {
+ const page = Math.max(1, query.page ?? 1);
+ const pageSize = Math.min(100, Math.max(1, query.pageSize ?? 20));
+ const all = await this.collectFiltered(query);
+ const start = (page - 1) * pageSize;
+ return {
+ items: all.slice(start, start + pageSize),
+ total: all.length,
+ page,
+ pageSize,
+ };
+ }
+
+ async export(query: FinanceBankAccountListQuery & { format?: string }) {
+ const format = query.format === 'pdf' ? 'pdf' : 'xlsx';
+ const rows = await this.collectFiltered(query);
+ if (!rows.length) throw new BadRequestException('没有可导出的银行账户');
+ const buffer =
+ format === 'pdf' ? await buildFinanceBankAccountsPdf(rows) : await buildFinanceBankAccountsXlsx(rows);
+ return {
+ filename: buildFinanceBankExportFilename(format, rows.length),
+ mimeType:
+ format === 'pdf'
+ ? 'application/pdf'
+ : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ contentBase64: buffer.toString('base64'),
+ count: rows.length,
+ };
+ }
+
+ async createOther(dto: FinanceBankAccountInputDto) {
+ const data = this.normalizeInput(dto);
+ const created = await this.prisma.financeBankAccount.create({
+ data: {
+ name: data.name,
+ bankAccountName: data.bankAccountName,
+ bankAccountNo: data.bankAccountNo,
+ bankBranch: data.bankBranch,
+ remark: data.remark,
+ status: 'ACTIVE',
+ },
+ });
+ return this.serializeOther(created, created.remark);
+ }
+
+ async updateOther(id: bigint, dto: FinanceBankAccountInputDto) {
+ const existing = await this.prisma.financeBankAccount.findUnique({ where: { id } });
+ if (!existing) throw new NotFoundException('账户不存在');
+ const data = this.normalizeInput(dto);
+ const updated = await this.prisma.financeBankAccount.update({
+ where: { id },
+ data: {
+ name: data.name,
+ bankAccountName: data.bankAccountName,
+ bankAccountNo: data.bankAccountNo,
+ bankBranch: data.bankBranch,
+ remark: data.remark,
+ },
+ });
+ return this.serializeOther(updated, updated.remark);
+ }
+
+ async removeOther(id: bigint) {
+ const existing = await this.prisma.financeBankAccount.findUnique({ where: { id } });
+ if (!existing) throw new NotFoundException('账户不存在');
+ await this.prisma.financeBankAccount.delete({ where: { id } });
+ return { ok: true };
+ }
+
+ async updateRemark(compositeId: string, remarkRaw?: string) {
+ const parsed = this.parseCompositeId(compositeId);
+ const remark = remarkRaw?.trim() || null;
+ if (remark && remark.length > 256) throw new BadRequestException('备注最多 256 字');
+
+ if (parsed.type === 'OTHER') {
+ const id = this.parseBigIntId(parsed.sourceId, '账户不存在');
+ const existing = await this.prisma.financeBankAccount.findUnique({ where: { id } });
+ if (!existing) throw new NotFoundException('账户不存在');
+ const updated = await this.prisma.financeBankAccount.update({
+ where: { id },
+ data: { remark },
+ });
+ return this.serializeOther(updated, updated.remark);
+ }
+
+ const ownerType = parsed.type;
+ if (remark) {
+ await this.prisma.financeBankAccountNote.upsert({
+ where: { ownerType_sourceId: { ownerType, sourceId: parsed.sourceId } },
+ create: { ownerType, sourceId: parsed.sourceId, remark },
+ update: { remark },
+ });
+ } else {
+ await this.prisma.financeBankAccountNote.deleteMany({
+ where: { ownerType, sourceId: parsed.sourceId },
+ });
+ }
+ return { ok: true, id: compositeId, remark };
+ }
+
+ private async collectFiltered(query: FinanceBankAccountListQuery): Promise {
+ const typeFilter = this.parseType(query.type);
+ const cityId = query.cityId?.trim() || '';
+ const keyword = query.keyword?.trim().toLowerCase() || '';
+ const rows = await this.collectAll();
+ return rows
+ .filter((row) => {
+ if (typeFilter && row.type !== typeFilter) return false;
+ if (cityId && row.cityId !== cityId) return false;
+ if (!keyword) return true;
+ const hay = [row.ownerName, row.bankAccountName, row.bankAccountNo, row.bankBranch ?? '', row.remark ?? '']
+ .join(' ')
+ .toLowerCase();
+ return hay.includes(keyword);
+ })
+ .sort((a, b) => {
+ const t = TYPE_ORDER[a.type] - TYPE_ORDER[b.type];
+ if (t !== 0) return t;
+ const city = (a.cityName ?? '').localeCompare(b.cityName ?? '', 'zh');
+ if (city !== 0) return city;
+ return a.ownerName.localeCompare(b.ownerName, 'zh');
+ });
+ }
+
+ private async collectAll(): Promise {
+ const [storeRows, partnerRows, logisticsRows, otherRows, notes, winery] = await Promise.all([
+ this.prisma.storeBankAccount.findMany({
+ where: { status: 'ACTIVE' },
+ include: { store: { select: { id: true, name: true, cityId: true, cityName: true } } },
+ orderBy: { id: 'asc' },
+ }),
+ this.prisma.partnerAccount.findMany({
+ where: { status: 'ACTIVE', parentAccountId: null, isPrimary: 1 },
+ select: {
+ id: true,
+ name: true,
+ companyName: true,
+ cityId: true,
+ bankAccountName: true,
+ bankAccountNo: true,
+ bankBranch: true,
+ city: { select: { name: true } },
+ },
+ }),
+ this.prisma.fulfillmentProvider.findMany({
+ where: { status: 'ACTIVE' },
+ select: {
+ id: true,
+ name: true,
+ bankAccountName: true,
+ bankAccountNo: true,
+ bankBranch: true,
+ },
+ }),
+ this.prisma.financeBankAccount.findMany({
+ where: { status: 'ACTIVE' },
+ orderBy: { id: 'asc' },
+ }),
+ this.prisma.financeBankAccountNote.findMany(),
+ loadWineryBankConfig(this.prisma),
+ ]);
+
+ const noteMap = new Map(notes.map((n) => [`${n.ownerType}:${n.sourceId}`, n.remark]));
+
+ const items: FinanceBankAccountDto[] = [];
+
+ for (const row of storeRows) {
+ const name = row.bankAccountName.trim();
+ const no = row.bankAccountNo.replace(/\s+/g, '');
+ if (!name || !no) continue;
+ const sourceId = row.id.toString();
+ items.push({
+ id: `STORE:${sourceId}`,
+ type: 'STORE',
+ ownerName: row.store.name,
+ ownerId: row.store.id.toString(),
+ cityId: row.store.cityId.toString(),
+ cityName: row.store.cityName,
+ bankAccountName: name,
+ bankAccountNo: no,
+ bankBranch: row.bankBranch,
+ remark: noteMap.get(`STORE:${sourceId}`) ?? null,
+ isDefault: row.isDefault === 1,
+ editable: false,
+ });
+ }
+
+ const wineryName = winery.bankAccountName?.trim() ?? '';
+ const wineryNo = winery.bankAccountNo?.replace(/\s+/g, '') ?? '';
+ if (wineryName && wineryNo) {
+ items.push({
+ id: 'WINERY:winery',
+ type: 'WINERY',
+ ownerName: '酒厂',
+ ownerId: null,
+ cityId: null,
+ cityName: null,
+ bankAccountName: wineryName,
+ bankAccountNo: wineryNo,
+ bankBranch: winery.bankBranch?.trim() || winery.bankName?.trim() || null,
+ remark: noteMap.get('WINERY:winery') ?? null,
+ editable: false,
+ });
+ }
+
+ for (const row of partnerRows) {
+ const name = row.bankAccountName?.trim() ?? '';
+ const no = row.bankAccountNo?.replace(/\s+/g, '') ?? '';
+ if (!name || !no) continue;
+ const sourceId = row.id.toString();
+ items.push({
+ id: `PARTNER:${sourceId}`,
+ type: 'PARTNER',
+ ownerName: row.companyName?.trim() || row.name,
+ ownerId: sourceId,
+ cityId: row.cityId?.toString() ?? null,
+ cityName: row.city?.name ?? null,
+ bankAccountName: name,
+ bankAccountNo: no,
+ bankBranch: row.bankBranch,
+ remark: noteMap.get(`PARTNER:${sourceId}`) ?? null,
+ editable: false,
+ });
+ }
+
+ for (const row of logisticsRows) {
+ const name = row.bankAccountName?.trim() ?? '';
+ const no = row.bankAccountNo?.replace(/\s+/g, '') ?? '';
+ if (!name || !no) continue;
+ const sourceId = row.id.toString();
+ items.push({
+ id: `LOGISTICS:${sourceId}`,
+ type: 'LOGISTICS',
+ ownerName: row.name,
+ ownerId: sourceId,
+ cityId: null,
+ cityName: null,
+ bankAccountName: name,
+ bankAccountNo: no,
+ bankBranch: row.bankBranch,
+ remark: noteMap.get(`LOGISTICS:${sourceId}`) ?? null,
+ editable: false,
+ });
+ }
+
+ for (const row of otherRows) {
+ items.push(this.serializeOther(row, row.remark));
+ }
+
+ return items;
+ }
+
+ private serializeOther(
+ row: {
+ id: bigint;
+ name: string | null;
+ bankAccountName: string;
+ bankAccountNo: string;
+ bankBranch: string | null;
+ },
+ remark: string | null,
+ ): FinanceBankAccountDto {
+ return {
+ id: `OTHER:${row.id.toString()}`,
+ type: 'OTHER',
+ ownerName: row.name?.trim() || row.bankAccountName,
+ ownerId: row.id.toString(),
+ cityId: null,
+ cityName: null,
+ bankAccountName: row.bankAccountName,
+ bankAccountNo: row.bankAccountNo,
+ bankBranch: row.bankBranch,
+ remark,
+ editable: true,
+ };
+ }
+
+ private normalizeInput(dto: FinanceBankAccountInputDto) {
+ const bankAccountName = dto.bankAccountName?.trim() ?? '';
+ const bankAccountNo = dto.bankAccountNo?.replace(/\s+/g, '') ?? '';
+ const bankBranch = dto.bankBranch?.trim() || null;
+ const name = dto.name?.trim() || null;
+ const remark = dto.remark?.trim() || null;
+ if (!bankAccountName) throw new BadRequestException('请填写户名');
+ if (!BANK_NO_RE.test(bankAccountNo)) throw new BadRequestException('请填写正确的银行账号');
+ if (remark && remark.length > 256) throw new BadRequestException('备注最多 256 字');
+ return { name, bankAccountName, bankAccountNo, bankBranch, remark };
+ }
+
+ private parseType(raw?: string): FinanceBankAccountType | null {
+ if (!raw?.trim()) return null;
+ const value = raw.trim().toUpperCase();
+ if ((FINANCE_BANK_ACCOUNT_TYPES as readonly string[]).includes(value)) {
+ return value as FinanceBankAccountType;
+ }
+ throw new BadRequestException('无效的账户类型');
+ }
+
+ private parseCompositeId(raw: string): { type: FinanceBankAccountType; sourceId: string } {
+ const idx = raw.indexOf(':');
+ if (idx <= 0) throw new BadRequestException('无效的账户编号');
+ const type = this.parseType(raw.slice(0, idx));
+ const sourceId = raw.slice(idx + 1).trim();
+ if (!type || !sourceId) throw new BadRequestException('无效的账户编号');
+ if (type === 'WINERY' && sourceId !== 'winery') throw new BadRequestException('无效的账户编号');
+ if (type !== 'OTHER' && !(SOURCE_NOTE_TYPES as readonly string[]).includes(type)) {
+ throw new BadRequestException('无效的账户编号');
+ }
+ return { type, sourceId };
+ }
+
+ private parseBigIntId(raw: string, message: string): bigint {
+ if (!/^\d+$/.test(raw)) throw new NotFoundException(message);
+ return BigInt(raw);
+ }
+}
diff --git a/server/dukang-api/src/modules/settlement/finance-bank-export.util.ts b/server/dukang-api/src/modules/settlement/finance-bank-export.util.ts
new file mode 100644
index 0000000..47456eb
--- /dev/null
+++ b/server/dukang-api/src/modules/settlement/finance-bank-export.util.ts
@@ -0,0 +1,83 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import ExcelJS from 'exceljs';
+import PDFDocument from 'pdfkit';
+import {
+ FINANCE_BANK_ACCOUNT_TYPE_LABELS,
+ type FinanceBankAccountDto,
+} from '@dukang/shared-types';
+import { pickExportColumns, type ExportColumnDef } from '../../common/export/column-export.util';
+
+function resolvePdfFontPath(): string {
+ const candidates = [
+ process.env.EXPORT_PDF_FONT_PATH,
+ path.join(process.cwd(), 'assets', 'fonts', 'NotoSansSC-Regular.otf'),
+ path.join(process.cwd(), 'assets', 'fonts', 'simhei.ttf'),
+ path.join(process.cwd(), 'assets', 'fonts', 'msyh.ttc'),
+ path.join(process.cwd(), 'dist', 'assets', 'fonts', 'simhei.ttf'),
+ '/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
+ '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
+ 'C:\\Windows\\Fonts\\simhei.ttf',
+ 'C:\\Windows\\Fonts\\msyh.ttc',
+ ].filter(Boolean) as string[];
+
+ for (const candidate of candidates) {
+ if (fs.existsSync(candidate)) return candidate;
+ }
+ throw new Error('未找到可用于 PDF 的中文字体,请将字体文件放到 server/dukang-api/assets/fonts/');
+}
+
+function columnDefs(): ExportColumnDef[] {
+ return [
+ { key: 'type', header: '类型', value: (r) => FINANCE_BANK_ACCOUNT_TYPE_LABELS[r.type] },
+ { key: 'ownerName', header: '归属', value: (r) => r.ownerName },
+ { key: 'cityName', header: '城市', value: (r) => r.cityName ?? '' },
+ { key: 'bankAccountName', header: '户名', value: (r) => r.bankAccountName },
+ { key: 'bankAccountNo', header: '银行账号', value: (r) => r.bankAccountNo },
+ { key: 'bankBranch', header: '开户行', value: (r) => r.bankBranch ?? '' },
+ { key: 'isDefault', header: '默认', value: (r) => (r.type === 'STORE' ? (r.isDefault ? '是' : '否') : '') },
+ { key: 'remark', header: '备注', value: (r) => r.remark ?? '' },
+ ];
+}
+
+export async function buildFinanceBankAccountsXlsx(rows: FinanceBankAccountDto[]): Promise {
+ const cols = pickExportColumns(columnDefs());
+ const workbook = new ExcelJS.Workbook();
+ const sheet = workbook.addWorksheet('银行账户');
+ sheet.addRow(cols.map((c) => c.header));
+ for (const row of rows) {
+ sheet.addRow(cols.map((c) => c.value(row)));
+ }
+ sheet.columns.forEach((col) => {
+ col.width = 18;
+ });
+ const buffer = await workbook.xlsx.writeBuffer();
+ return Buffer.from(buffer);
+}
+
+export async function buildFinanceBankAccountsPdf(rows: FinanceBankAccountDto[]): Promise {
+ const cols = pickExportColumns(columnDefs());
+ const fontPath = resolvePdfFontPath();
+ const chunks: Buffer[] = [];
+
+ return new Promise((resolve, reject) => {
+ const doc = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 24, bufferPages: true });
+ doc.on('data', (c) => chunks.push(c as Buffer));
+ doc.on('end', () => resolve(Buffer.concat(chunks)));
+ doc.on('error', reject);
+ doc.font(fontPath);
+ doc.fontSize(12).text('银行账户');
+ doc.moveDown(0.4);
+ doc.fontSize(9).text(cols.map((c) => c.header).join(' | '));
+ doc.moveDown(0.3);
+ for (const row of rows) {
+ doc.text(cols.map((c) => String(c.value(row))).join(' | '));
+ }
+ doc.end();
+ });
+}
+
+export function buildFinanceBankExportFilename(format: 'xlsx' | 'pdf', count: number): string {
+ const stamp = new Date().toISOString().slice(0, 10);
+ return `银行账户_${stamp}_${count}.${format}`;
+}
diff --git a/server/dukang-api/src/modules/settlement/settlement.controller.ts b/server/dukang-api/src/modules/settlement/settlement.controller.ts
index 7add4f4..88efd24 100644
--- a/server/dukang-api/src/modules/settlement/settlement.controller.ts
+++ b/server/dukang-api/src/modules/settlement/settlement.controller.ts
@@ -1,5 +1,7 @@
-import { BadRequestException, Body, Controller, ForbiddenException, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
+import { BadRequestException, Body, Controller, Delete, ForbiddenException, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
+import { FinanceBankAccountService } from './finance-bank-account.service';
+import { HqPermissionGuard, RequireHqPermissions } from '../../common/guards/hq-permission.guard';
import { parseShanghaiYmd } from '@dukang/domain';
import { DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
import { SettlementService } from './settlement.service';
@@ -37,6 +39,40 @@ class UpdatePartnerBankDto {
bankBranch!: string;
}
+class FinanceBankAccountBodyDto {
+ @IsOptional()
+ @IsString()
+ @MaxLength(128)
+ name?: string;
+
+ @IsString()
+ @IsNotEmpty({ message: '请填写户名' })
+ @MaxLength(64)
+ bankAccountName!: string;
+
+ @IsString()
+ @IsNotEmpty({ message: '请填写银行账号' })
+ @MaxLength(32)
+ bankAccountNo!: string;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(128)
+ bankBranch?: string;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(256)
+ remark?: string;
+}
+
+class FinanceBankAccountRemarkBodyDto {
+ @IsOptional()
+ @IsString()
+ @MaxLength(256)
+ remark?: string;
+}
+
@Controller('partner/settlement')
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
export class SettlementController {
@@ -627,6 +663,56 @@ export class AdminLogisticsBillController {
}
}
+@Controller('admin/finance/bank-accounts')
+@UseGuards(HqAuthGuard, HqPermissionGuard)
+@RequireHqPermissions('finance')
+export class AdminFinanceBankAccountController {
+ constructor(private readonly financeBankAccounts: FinanceBankAccountService) {}
+
+ @Get()
+ list(@Query() query: Record) {
+ return this.financeBankAccounts.list({
+ type: query.type,
+ cityId: query.cityId,
+ keyword: query.keyword,
+ page: query.page ? Number(query.page) : 1,
+ pageSize: query.pageSize ? Number(query.pageSize) : 20,
+ });
+ }
+
+ @Get('export')
+ export(@Query() query: Record) {
+ return this.financeBankAccounts.export({
+ type: query.type,
+ cityId: query.cityId,
+ keyword: query.keyword,
+ format: query.format,
+ });
+ }
+
+ @Post()
+ create(@Body() dto: FinanceBankAccountBodyDto) {
+ return this.financeBankAccounts.createOther(dto);
+ }
+
+ @Put('other/:id')
+ updateOther(@Param('id') id: string, @Body() dto: FinanceBankAccountBodyDto) {
+ if (!/^\d+$/.test(id)) throw new BadRequestException('无效的账户编号');
+ return this.financeBankAccounts.updateOther(BigInt(id), dto);
+ }
+
+ @Delete('other/:id')
+ removeOther(@Param('id') id: string) {
+ if (!/^\d+$/.test(id)) throw new BadRequestException('无效的账户编号');
+ return this.financeBankAccounts.removeOther(BigInt(id));
+ }
+
+ @Put(':id/remark')
+ updateRemark(@Param('id') id: string, @Body() dto: FinanceBankAccountRemarkBodyDto) {
+ return this.financeBankAccounts.updateRemark(id, dto.remark);
+ }
+}
+
@Controller('partner/me')
@UseGuards(JwtAuthGuard)
export class PartnerMeController {
diff --git a/server/dukang-api/src/modules/settlement/settlement.module.ts b/server/dukang-api/src/modules/settlement/settlement.module.ts
index bfff4ae..559f6ad 100644
--- a/server/dukang-api/src/modules/settlement/settlement.module.ts
+++ b/server/dukang-api/src/modules/settlement/settlement.module.ts
@@ -4,7 +4,9 @@ import { AnalyticsModule } from '../analytics/analytics.module';
import { CityScopeModule } from '../city-scope/city-scope.module';
import { FulfillmentModule } from '../fulfillment/fulfillment.module';
import { SettlementService } from './settlement.service';
+import { FinanceBankAccountService } from './finance-bank-account.service';
import {
+ AdminFinanceBankAccountController,
AdminLogisticsBillController,
AdminPartnerBillController,
AdminStoreBillController,
@@ -32,8 +34,9 @@ import {
AdminPartnerBillController,
AdminWineryBillController,
AdminLogisticsBillController,
+ AdminFinanceBankAccountController,
],
- providers: [SettlementService],
+ providers: [SettlementService, FinanceBankAccountService],
exports: [SettlementService],
})
export class SettlementModule {}
diff --git a/server/dukang-api/src/modules/settlement/settlement.service.ts b/server/dukang-api/src/modules/settlement/settlement.service.ts
index e2c5fe2..bc566f1 100644
--- a/server/dukang-api/src/modules/settlement/settlement.service.ts
+++ b/server/dukang-api/src/modules/settlement/settlement.service.ts
@@ -54,6 +54,7 @@ import {
} from '../../common/store/store-bank.util';
import {
capBillBlocks,
+ filterNonZeroWecomBills,
formatLogisticsBillWecomBlock,
formatPartnerBillWecomBlock,
formatPartnerDashLabel,
@@ -199,6 +200,15 @@ function round2(n: number) {
return Math.round(n * 100) / 100;
}
+function formatPartnerBillPeriodLabel(weekStartYmd: string): string {
+ try {
+ const { periodStart, periodEnd } = resolvePartnerWeekPeriod(weekStartYmd);
+ return `${shanghaiYmd(periodStart)} ~ ${shanghaiYmd(periodEnd)}`;
+ } catch {
+ return weekStartYmd;
+ }
+}
+
function sumAmounts(rows: Array<{ amount: number }>): string {
return rows.reduce((s, r) => s + Number(r.amount || 0), 0).toFixed(2);
}
@@ -345,21 +355,25 @@ export class SettlementService implements OnModuleInit {
cityName: string;
partnerLabel: string;
orderCount?: number;
+ orderAmount?: number;
redeemCount?: number;
+ redeemAmount?: number;
orderCommission: number;
redeemCommission: number;
amount: number;
bank?: WecomBankLike | null;
}>,
) {
- if (!rows.length) return;
- const blocks = rows.map((r) => formatPartnerBillWecomBlock(r));
+ const billed = filterNonZeroWecomBills(rows);
+ if (!billed.length) return;
+ const periodLabel = formatPartnerBillPeriodLabel(period);
+ const blocks = billed.map((r) => formatPartnerBillWecomBlock({ ...r, period: periodLabel }));
void this.wecomPush.dispatchEvent(
'finance.partner_bill',
{
- period,
- billCount: String(rows.length),
- totalAmount: sumAmounts(rows),
+ period: periodLabel,
+ billCount: String(billed.length),
+ totalAmount: sumAmounts(billed),
billSummary: capBillBlocks(blocks),
},
{ handlePath: '/finance/partner-bills' },
@@ -1895,7 +1909,9 @@ export class SettlementService implements OnModuleInit {
cityName: string;
partnerLabel: string;
orderCount?: number;
+ orderAmount?: number;
redeemCount?: number;
+ redeemAmount?: number;
orderCommission: number;
redeemCommission: number;
amount: number;
@@ -1915,7 +1931,9 @@ export class SettlementService implements OnModuleInit {
cityName: p.city?.name?.trim() || '—',
partnerLabel: formatPartnerDashLabel(p.companyName, p.name),
orderCount: Number(bill.orderCount ?? 0),
+ orderAmount: Number(bill.orderAmount ?? 0),
redeemCount: Number(bill.redeemCount ?? 0),
+ redeemAmount: Number(bill.redeemAmount ?? 0),
orderCommission: Number(bill.orderCommission),
redeemCommission: Number(bill.redeemCommission),
amount: Number(bill.totalAmount),
@@ -2016,6 +2034,7 @@ export class SettlementService implements OnModuleInit {
};
});
const orderCommission = orderRows.reduce((sum, r) => sum + r.commission, 0);
+ const orderAmount = round2(orderRows.reduce((sum, r) => sum + r.baseAmount, 0));
const stores = await this.prisma.store.findMany({
where: { partnerAccountId: primary.id },
@@ -2047,6 +2066,7 @@ export class SettlementService implements OnModuleInit {
};
});
const redeemCommission = redeemRows.reduce((sum, r) => sum + r.commission, 0);
+ const redeemAmount = round2(redeemRows.reduce((sum, r) => sum + r.baseAmount, 0));
const totalAmount = round2(orderCommission + redeemCommission);
const itemRows = [...orderRows, ...redeemRows];
@@ -2111,7 +2131,9 @@ export class SettlementService implements OnModuleInit {
cityName,
partnerLabel: formatPartnerDashLabel(primary.companyName, primary.name),
orderCount: orders.length,
+ orderAmount,
redeemCount: redeems.length,
+ redeemAmount,
orderCommission: round2(orderCommission),
redeemCommission: round2(redeemCommission),
amount: totalAmount,
@@ -2128,6 +2150,8 @@ export class SettlementService implements OnModuleInit {
...bill,
orderCount: orders.length,
redeemCount: redeems.length,
+ orderAmount,
+ redeemAmount,
});
}
diff --git a/server/dukang-api/src/modules/settlement/wecom-bill-digest.test.ts b/server/dukang-api/src/modules/settlement/wecom-bill-digest.test.ts
new file mode 100644
index 0000000..d67380f
--- /dev/null
+++ b/server/dukang-api/src/modules/settlement/wecom-bill-digest.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest';
+import {
+ filterNonZeroWecomBills,
+ formatPartnerBillWecomBlock,
+ formatWecomKvTable,
+} from './wecom-bill-digest';
+
+describe('filterNonZeroWecomBills', () => {
+ it('drops zero-amount bills', () => {
+ expect(
+ filterNonZeroWecomBills([
+ { amount: 0, name: 'zero' },
+ { amount: 12.5, name: 'keep' },
+ { amount: 0.0, name: 'also-zero' },
+ ]).map((r) => r.name),
+ ).toEqual(['keep']);
+ });
+});
+
+describe('formatWecomKvTable', () => {
+ it('uses the title as table header and fields as rows', () => {
+ const md = formatWecomKvTable('郑州某某商贸-张三', [
+ ['账期', '2026-07-27 ~ 2026-08-02'],
+ ['累计金额', '¥120.00'],
+ ]);
+ expect(md).toBe(
+ [
+ '| 郑州某某商贸-张三 | |',
+ '| :--- | ---: |',
+ '| 账期 | 2026-07-27 ~ 2026-08-02 |',
+ '| 累计金额 | ¥120.00 |',
+ ].join('\n'),
+ );
+ });
+});
+
+describe('formatPartnerBillWecomBlock', () => {
+ it('renders partner company-name header and settlement fields', () => {
+ const md = formatPartnerBillWecomBlock({
+ partnerLabel: '郑州某某商贸-张三',
+ period: '2026-07-27 ~ 2026-08-02',
+ orderCount: 8,
+ orderAmount: 1000,
+ orderCommission: 100,
+ redeemCount: 12,
+ redeemAmount: 200,
+ redeemCommission: 20,
+ amount: 120,
+ bank: {
+ bankAccountName: '张三',
+ bankAccountNo: '6222000011112222',
+ bankBranch: '郑州支行',
+ },
+ });
+ expect(md).toContain('| 郑州某某商贸-张三 | |');
+ expect(md).toContain('| 账期 | 2026-07-27 ~ 2026-08-02 |');
+ expect(md).toContain('| 订单数量 | 8 |');
+ expect(md).toContain('| 订单金额 | ¥1000.00 |');
+ expect(md).toContain('| 订单佣金 | ¥100.00 |');
+ expect(md).toContain('| 核销单数量 | 12 |');
+ expect(md).toContain('| 核销金额 | ¥200.00 |');
+ expect(md).toContain('| 核销佣金 | ¥20.00 |');
+ expect(md).toContain('| 累计金额 | ¥120.00 |');
+ expect(md).toContain('| 收款人 | 张三 |');
+ expect(md).toContain('| 收款银行账号 | 6222000011112222 |');
+ expect(md).toContain('| 开户行 | 郑州支行 |');
+ });
+});
diff --git a/server/dukang-api/src/modules/settlement/wecom-bill-digest.ts b/server/dukang-api/src/modules/settlement/wecom-bill-digest.ts
index 4665c54..f993561 100644
--- a/server/dukang-api/src/modules/settlement/wecom-bill-digest.ts
+++ b/server/dukang-api/src/modules/settlement/wecom-bill-digest.ts
@@ -1,4 +1,4 @@
-/** 企微结算账单正文:分字段块 + 字节上限(机器人 markdown 约 4096 字节) */
+/** 企微结算账单正文:字段块 / markdown_v2 表格 + 字节上限(机器人约 4096 字节) */
export type WecomBankLike = {
bankAccountName?: string | null;
@@ -51,6 +51,27 @@ export function formatWecomFieldBlock(rows: Array<[string, string]>): string {
return rows.map(([k, v]) => `${k}:${v}`).join('\n');
}
+function escapeWecomTableCell(v: string): string {
+ const text = String(v ?? '')
+ .replace(/\|/g, '|')
+ .replace(/\r?\n/g, ' ')
+ .trim();
+ return text || '—';
+}
+
+/** 企微 markdown_v2 键值表:表头为标题,下列为字段/值 */
+export function formatWecomKvTable(header: string, rows: Array<[string, string]>): string {
+ const lines = [`| ${escapeWecomTableCell(header)} | |`, '| :--- | ---: |'];
+ for (const [k, v] of rows) {
+ lines.push(`| ${escapeWecomTableCell(k)} | ${escapeWecomTableCell(v)} |`);
+ }
+ return lines.join('\n');
+}
+
+export function filterNonZeroWecomBills(rows: T[]): T[] {
+ return rows.filter((r) => Number(r.amount) > 0);
+}
+
export function joinLimited(names: string[], unit: string, max = 8): string {
const uniq = [...new Set(names.map((n) => n.trim()).filter(Boolean))];
if (!uniq.length) return '—';
@@ -104,31 +125,31 @@ export function formatStoreBillWecomBlock(row: {
}
export function formatPartnerBillWecomBlock(row: {
- cityName: string;
partnerLabel: string;
+ period: string;
orderCount?: number;
- redeemCount?: number;
+ orderAmount?: number;
orderCommission: number;
+ redeemCount?: number;
+ redeemAmount?: number;
redeemCommission: number;
amount: number;
bank?: WecomBankLike | null;
}): string {
const bank = wecomBankParts(row.bank);
- const rows: Array<[string, string]> = [
- ['城市', row.cityName || '—'],
- ['合伙人', row.partnerLabel || '—'],
- ];
- if (row.orderCount != null) rows.push(['订单笔数', String(row.orderCount)]);
- if (row.redeemCount != null) rows.push(['核销笔数', String(row.redeemCount)]);
- rows.push(
+ return formatWecomKvTable(row.partnerLabel || '—', [
+ ['账期', row.period || '—'],
+ ['订单数量', String(row.orderCount ?? 0)],
+ ['订单金额', formatYuan(row.orderAmount ?? 0)],
['订单佣金', formatYuan(row.orderCommission)],
+ ['核销单数量', String(row.redeemCount ?? 0)],
+ ['核销金额', formatYuan(row.redeemAmount ?? 0)],
['核销佣金', formatYuan(row.redeemCommission)],
- ['账单', formatYuan(row.amount)],
+ ['累计金额', formatYuan(row.amount)],
['收款人', bank.payee],
- ['收款账户', bank.accountNo],
- ['收款开户行', bank.bankBranch],
- );
- return formatWecomFieldBlock(rows);
+ ['收款银行账号', bank.accountNo],
+ ['开户行', bank.bankBranch],
+ ]);
}
export function formatLogisticsBillWecomBlock(row: {
diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts
index 96264cc..aad112b 100644
--- a/server/dukang-api/src/modules/store/store.service.ts
+++ b/server/dukang-api/src/modules/store/store.service.ts
@@ -8,7 +8,12 @@ import {
import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types';
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types';
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
-import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT, validateBusinessHours } from '@dukang/domain';
+import {
+ formatStoreDisplayAddress,
+ isStoreContactPhone,
+ STORE_CONTACT_PHONE_HINT,
+ validateBusinessHours,
+} from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { mapStoreCompat } from '../../common/compat/v31-compat';
@@ -123,7 +128,7 @@ export class StoreService {
district?: string | null;
address?: string | null;
}) {
- return `${store.province ?? ''}${store.cityName ?? ''}${store.district ?? ''}${store.address ?? ''}`.trim();
+ return formatStoreDisplayAddress(store, '');
}
/** 缺坐标时用地址正向地理编码并回写 */
From 253430b2910ee3f8b1e6ba5d7229cc1cb74f1ede Mon Sep 17 00:00:00 2001
From: jacy <18049821889@163.com>
Date: Tue, 8 Sep 2026 13:47:42 +0800
Subject: [PATCH 2/2] =?UTF-8?q?v4.0.18=E7=89=88=E6=9C=AC=E6=9B=B4=E6=96=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/components/PartnerAssocPanel.tsx | 42 +++++++++++++++----
apps/mini-user/src/lib/store-display.ts | 9 ++++
.../src/pages/store-detail/index.tsx | 11 ++++-
apps/mini-user/src/pages/stores/index.tsx | 37 +++++++++++++---
docs/杜康好客-v3-PRD.md | 1 +
docs/杜康好客-v3编码手册.md | 4 +-
docs/杜康好客-v4-PRD.md | 4 +-
docs/杜康好客-v4-现状对照.md | 1 +
packages/shared-types/src/config.ts | 7 ++++
packages/shared-types/src/wechat.ts | 2 +
.../system-config/system-config.registry.ts | 9 ++++
.../common/client-config.controller.ts | 2 +
.../modules/ops/admin-partners.controller.ts | 14 ++++++-
.../src/modules/store/store.service.ts | 18 +++++---
14 files changed, 136 insertions(+), 25 deletions(-)
diff --git a/apps/admin-web/src/components/PartnerAssocPanel.tsx b/apps/admin-web/src/components/PartnerAssocPanel.tsx
index 8045ad1..8c2136e 100644
--- a/apps/admin-web/src/components/PartnerAssocPanel.tsx
+++ b/apps/admin-web/src/components/PartnerAssocPanel.tsx
@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
import { Button, Popconfirm, Space, Table, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { PartnerAssocSummary, PartnerAssocUserItem } from '@dukang/shared-types';
-import { request, type HqProfile, type Paginated } from '../lib/api';
+import { request, requestDownload, type HqProfile, type Paginated } from '../lib/api';
import { fmtTime } from '../lib/constants';
import ActivityPosterDownloadModal from './ActivityPosterDownloadModal';
@@ -27,6 +27,7 @@ export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps)
const [canEditAssoc, setCanEditAssoc] = useState(false);
const [canDownloadPosters, setCanDownloadPosters] = useState(false);
const [posterDownloadOpen, setPosterDownloadOpen] = useState(false);
+ const [downloadingQr, setDownloadingQr] = useState(false);
const loadSummary = useCallback(async () => {
const data = await request(`/admin/partners/${partnerId}/assoc`);
@@ -78,6 +79,26 @@ export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps)
}
}
+ async function downloadBareQrcode() {
+ if (!summary?.qrcodeUrl) {
+ message.warning('尚未生成关联码');
+ return;
+ }
+ setDownloadingQr(true);
+ try {
+ await requestDownload(
+ `/admin/partners/${partnerId}/assoc/qrcode`,
+ {},
+ `partner-assoc-${partnerId}.png`,
+ );
+ message.success('已开始下载二维码');
+ } catch (e) {
+ message.error(e instanceof Error ? e.message : '下载二维码失败');
+ } finally {
+ setDownloadingQr(false);
+ }
+ }
+
async function unbind(userId: string) {
try {
await request(`/admin/partners/${partnerId}/assoc/users/${userId}/unbind`, { method: 'POST' });
@@ -137,12 +158,19 @@ export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps)
关联用户 {summary?.userCount ?? 0} 人
-
- {canDownloadPosters ? (
-
- ) : null}
+
+
+ {summary?.qrcodeUrl ? (
+
+ ) : null}
+ {canDownloadPosters ? (
+
+ ) : null}
+
0;
+}
diff --git a/apps/mini-user/src/pages/store-detail/index.tsx b/apps/mini-user/src/pages/store-detail/index.tsx
index ba3f393..9a8f1f2 100644
--- a/apps/mini-user/src/pages/store-detail/index.tsx
+++ b/apps/mini-user/src/pages/store-detail/index.tsx
@@ -17,11 +17,13 @@ import StoreRedeemMarquee, { type StoreRedeemMarqueeItem } from '../../component
import BenefitIntroCard from '../../components/BenefitIntroCard';
import WechatShareReady from '../../components/WechatShareReady';
import { request, toast, isLoggedIn } from '../../lib/api';
+import { fetchClientConfig } from '../../lib/pay-wechat';
import { toMoneyNumber } from '../../lib/money';
import { maskPhone, toDialablePhone } from '../../lib/phone';
import { track } from '../../lib/analytics';
import {
fullStoreAddress,
+ shouldShowStoreRedeemCount,
storeCategoryTags,
storeStarCount,
type StoreCategoryTreeNode,
@@ -190,6 +192,7 @@ export default function StoreDetailPage() {
const [loadError, setLoadError] = useState('');
const [headerSolid, setHeaderSolid] = useState(false);
const [pendingRatingId, setPendingRatingId] = useState(null);
+ const [showStoreRedeemCount, setShowStoreRedeemCount] = useState(true);
const storeRef = useRef(null);
storeRef.current = store;
@@ -197,6 +200,12 @@ export default function StoreDetailPage() {
setHeaderSolid(scrollTop > 100);
});
+ useEffect(() => {
+ void fetchClientConfig()
+ .then((cfg) => setShowStoreRedeemCount(cfg.showStoreRedeemCount !== false))
+ .catch(() => undefined);
+ }, []);
+
const loadStore = useCallback(async (id: string) => {
if (!id) {
setLoading(false);
@@ -440,7 +449,7 @@ export default function StoreDetailPage() {
))}
- {Number(store.redeemCount) > 0 ? (
+ {shouldShowStoreRedeemCount(showStoreRedeemCount, store.redeemCount) ? (
核销{store.redeemCount}次
) : null}
diff --git a/apps/mini-user/src/pages/stores/index.tsx b/apps/mini-user/src/pages/stores/index.tsx
index 2a5b162..73ec286 100644
--- a/apps/mini-user/src/pages/stores/index.tsx
+++ b/apps/mini-user/src/pages/stores/index.tsx
@@ -28,6 +28,7 @@ import {
import { FALLBACK_CITY_CODE } from '../../lib/product-images';
import { formatDistanceMeters } from '../../lib/geo';
import { getToken, request, toast } from '../../lib/api';
+import { fetchClientConfig } from '../../lib/pay-wechat';
import {
getStoresListCache,
isStoresSessionBootstrapped,
@@ -42,7 +43,7 @@ import {
toWeappShareTimeline,
} from '../../lib/wechat-share';
import BenefitSloganBar from '../../components/BenefitSloganBar';
-import { fullStoreAddress, storeCategoryTags, storeLeafCategoryIds, storeStarCount } from '../../lib/store-display';
+import { fullStoreAddress, shouldShowStoreRedeemCount, storeCategoryTags, storeLeafCategoryIds, storeStarCount } from '../../lib/store-display';
import openBadgeImg from '../../assets/icons/store-open-badge.png';
type Store = {
@@ -123,13 +124,21 @@ export default function StoresPage() {
const [categoryTree, setCategoryTree] = useState([]);
const [sort, setSort] = useState(() => cached?.sort ?? 'nearby');
const [sortOpen, setSortOpen] = useState(false);
+ const [showStoreRedeemCount, setShowStoreRedeemCount] = useState(true);
const fetchCityKeyRef = useRef(cached?.cityKey ?? null);
const fetchSeqRef = useRef(0);
const regionRef = useRef(region);
regionRef.current = region;
const regionLabel = formatRegionLabel(region);
const categoryLabel = formatCategoryLabel(category);
- const sortLabel = STORE_SORT_OPTIONS.find((o) => o.key === sort)?.label ?? '附近优先';
+ const sortOptions = useMemo(
+ () =>
+ showStoreRedeemCount
+ ? STORE_SORT_OPTIONS
+ : STORE_SORT_OPTIONS.filter((o) => o.key !== 'redeem'),
+ [showStoreRedeemCount],
+ );
+ const sortLabel = sortOptions.find((o) => o.key === sort)?.label ?? '附近优先';
const showBootLoading = loading && stores.length === 0;
const childIdsByParent = useMemo(() => {
@@ -143,6 +152,22 @@ export default function StoresPage() {
return map;
}, [categoryTree]);
+ useEffect(() => {
+ void fetchClientConfig()
+ .then((cfg) => {
+ const enabled = cfg.showStoreRedeemCount !== false;
+ setShowStoreRedeemCount(enabled);
+ if (!enabled) {
+ setSort((prev) => {
+ if (prev !== 'redeem') return prev;
+ patchStoresFilterCache({ sort: 'nearby' });
+ return 'nearby';
+ });
+ }
+ })
+ .catch(() => undefined);
+ }, []);
+
async function fetchStores(
nextCode: string,
coords: UserCoords | null,
@@ -329,7 +354,7 @@ export default function StoresPage() {
if (sort === 'rating') {
const diff = storeStarCount(b.rating) - storeStarCount(a.rating);
if (diff !== 0) return diff;
- } else if (sort === 'redeem') {
+ } else if (sort === 'redeem' && showStoreRedeemCount) {
const diff = (b.redeemCount ?? 0) - (a.redeemCount ?? 0);
if (diff !== 0) return diff;
}
@@ -338,7 +363,7 @@ export default function StoresPage() {
return da - db;
});
return next;
- }, [stores, region, category, keyword, sort, childIdsByParent]);
+ }, [stores, region, category, keyword, sort, childIdsByParent, showStoreRedeemCount]);
function applySearch() {
const next = keywordInput.trim();
@@ -497,7 +522,7 @@ export default function StoresPage() {
))}
- {Number(s.redeemCount) > 0 ? (
+ {shouldShowStoreRedeemCount(showStoreRedeemCount, s.redeemCount) ? (
核销{s.redeemCount}次
) : null}
@@ -551,7 +576,7 @@ export default function StoresPage() {
- {STORE_SORT_OPTIONS.map((opt) => (
+ {sortOptions.map((opt) => (
return code || MOCK_SMS_FIXED_CODE;
}
+/** C 端门店是否展示核销次数(系统设置 SHOW_STORE_REDEEM_COUNT;未配置默认开) */
+export function isShowStoreRedeemCountEnabled(env?: Record): boolean {
+ const raw = (readEnv(env).SHOW_STORE_REDEEM_COUNT ?? '').trim().toLowerCase();
+ if (!raw) return true;
+ return raw === 'true' || raw === '1';
+}
+
/** 小程序 / H5 默认分享文案与引导(系统设置「小程序分享配置」可覆盖) */
export const DEFAULT_SHARE_TITLE = '你吃饭,我买单';
export const DEFAULT_SHARE_DESC = '杜康好客 · 买美酒,享好礼,全城门店可用';
diff --git a/packages/shared-types/src/wechat.ts b/packages/shared-types/src/wechat.ts
index 7f8796e..6f5827d 100644
--- a/packages/shared-types/src/wechat.ts
+++ b/packages/shared-types/src/wechat.ts
@@ -74,6 +74,8 @@ export type ClientRuntimeConfig = {
partnerOnboardCsHint?: string | null;
/** 小程序各场景分享文案/图 */
share?: MiniShareRuntime;
+ /** C 端门店列表/详情是否展示核销次数;未下发时按开启处理 */
+ showStoreRedeemCount?: boolean;
};
/** 是否展示微信授权入口 */
diff --git a/server/dukang-api/src/common/system-config/system-config.registry.ts b/server/dukang-api/src/common/system-config/system-config.registry.ts
index 900b9fb..5a18e59 100644
--- a/server/dukang-api/src/common/system-config/system-config.registry.ts
+++ b/server/dukang-api/src/common/system-config/system-config.registry.ts
@@ -83,6 +83,14 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
requiresRestart: false,
description: '总开关。开启后连接 HQ「企微机器人 → 智能机器人」中已启用且配置完整的 Bot(每 Bot 同时仅 1 条长连接)',
},
+ {
+ key: 'SHOW_STORE_REDEEM_COUNT',
+ label: 'C 端展示门店核销次数',
+ group: G.feature,
+ type: 'boolean',
+ requiresRestart: false,
+ description: '关闭后小程序门店列表/详情不再展示「核销N次」,接口也不再返回核销次数',
+ },
{ key: 'ALIYUN_SMS_SIGN_NAME', label: '短信签名', group: G.sms, type: 'string', requiresRestart: false },
{ key: 'ALIYUN_SMS_TEMPLATE_CODE', label: '默认短信模板', group: G.sms, type: 'string', requiresRestart: false },
@@ -612,6 +620,7 @@ export const SYSTEM_CONFIG_KEY_SET = new Set(SYSTEM_CONFIG_FIELDS.map((f) => f.k
/** 表单空值时展示 / 启动补种的默认值(与 shared-types 常量对齐) */
export const SYSTEM_CONFIG_DEFAULTS: Record = {
+ SHOW_STORE_REDEEM_COUNT: 'true',
USER_H5_URL: DEFAULT_USER_H5_URL.replace(/\/$/, ''),
SHOP_H5_URL: DEFAULT_SHOP_H5_URL.replace(/\/$/, ''),
BRAND_LOGO_OSS_BASE: BRAND_LOGO_OSS_BASE,
diff --git a/server/dukang-api/src/modules/common/client-config.controller.ts b/server/dukang-api/src/modules/common/client-config.controller.ts
index 8dbe02d..2dd51f1 100644
--- a/server/dukang-api/src/modules/common/client-config.controller.ts
+++ b/server/dukang-api/src/modules/common/client-config.controller.ts
@@ -1,5 +1,6 @@
import { Controller, Get } from '@nestjs/common';
import {
+ isShowStoreRedeemCountEnabled,
parseMiniHomeBanners,
resolveClientBrandRuntime,
resolveMiniShareRuntime,
@@ -44,6 +45,7 @@ export class ClientConfigController {
(env.PARTNER_ONBOARD_CS_HINT ?? '').trim() ||
'使用问题、提现问题等随时可联系【杜康好客】客服',
share,
+ showStoreRedeemCount: isShowStoreRedeemCountEnabled(env),
};
}
}
diff --git a/server/dukang-api/src/modules/ops/admin-partners.controller.ts b/server/dukang-api/src/modules/ops/admin-partners.controller.ts
index a49798b..ace49de 100644
--- a/server/dukang-api/src/modules/ops/admin-partners.controller.ts
+++ b/server/dukang-api/src/modules/ops/admin-partners.controller.ts
@@ -1,4 +1,5 @@
-import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
+import { Body, Controller, Delete, Get, Param, Post, Put, Query, Res, UseGuards } from '@nestjs/common';
+import type { Response } from 'express';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
@@ -80,6 +81,17 @@ export class AdminPartnersController {
return this.assoc.listUsers(BigInt(id), Number(page) || 1, Number(pageSize) || 20);
}
+ @Get(':id/assoc/qrcode')
+ async assocQrcode(@Param('id') id: string, @Res() res: Response) {
+ const { buffer, fileName } = await this.assoc.getQrcodeBuffer(BigInt(id));
+ res.setHeader('Content-Type', 'image/png');
+ res.setHeader(
+ 'Content-Disposition',
+ `attachment; filename="${fileName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
+ );
+ res.send(buffer);
+ }
+
@Post(':id/assoc/qrcode')
regenQrcode(@Param('id') id: string) {
return this.assoc.ensureQrcode(BigInt(id), true);
diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts
index aad112b..114a7cd 100644
--- a/server/dukang-api/src/modules/store/store.service.ts
+++ b/server/dukang-api/src/modules/store/store.service.ts
@@ -5,7 +5,7 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
-import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types';
+import { loadAppConfig, ClientApp, SmsScene, isShowStoreRedeemCountEnabled } from '@dukang/shared-types';
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types';
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
import {
@@ -36,6 +36,7 @@ import {
} from '../../common/test-whitelist/test-whitelist.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
import { formatPartnerWecomLabel } from './wecom-submitter-label';
+import { SystemConfigService } from '../../common/system-config/system-config.service';
function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
@@ -86,6 +87,7 @@ export class StoreService {
private readonly tencentLbs: TencentLbsProvider,
private readonly testWhitelist: TestWhitelistService,
private readonly wecomPush: WecomMessagePushService,
+ private readonly systemConfig: SystemConfigService,
) {}
/** 门店进入 PENDING 或 HQ 新建时通知企微(失败不挡业务) */
@@ -218,11 +220,12 @@ export class StoreService {
latitude?: unknown;
longitude?: unknown;
sortOrder?: number;
- redeemCount: number;
+ redeemCount?: number;
};
+ const showRedeemCount = isShowStoreRedeemCountEnabled(this.systemConfig.getMergedEnv());
const redeemGroups =
- visible.length === 0
+ !showRedeemCount || visible.length === 0
? []
: await this.prisma.redeemRecord.groupBy({
by: ['storeId'],
@@ -252,7 +255,9 @@ export class StoreService {
items.push({
...mapped,
distanceMeters,
- redeemCount: redeemCountByStore.get(store.id.toString()) ?? 0,
+ ...(showRedeemCount
+ ? { redeemCount: redeemCountByStore.get(store.id.toString()) ?? 0 }
+ : {}),
});
}
@@ -286,6 +291,7 @@ export class StoreService {
throw new NotFoundException('门店不存在');
}
const coords = await this.ensureStoreCoordinates(store);
+ const showRedeemCount = isShowStoreRedeemCountEnabled(this.systemConfig.getMergedEnv());
const [media, packageRows, redeemCount] = await Promise.all([
this.prisma.commonResource.findMany({
where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' },
@@ -295,7 +301,7 @@ export class StoreService {
where: { storeId: id },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
}),
- this.prisma.redeemRecord.count({ where: { storeId: id } }),
+ showRedeemCount ? this.prisma.redeemRecord.count({ where: { storeId: id } }) : Promise.resolve(null),
]);
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
return serializeBigInt(
@@ -304,7 +310,7 @@ export class StoreService {
...rest,
latitude: coords?.latitude ?? store.latitude,
longitude: coords?.longitude ?? store.longitude,
- redeemCount,
+ ...(showRedeemCount && redeemCount != null ? { redeemCount } : {}),
media,
packages: packageRows.map((p) => {
const imageUrls = normalizeStorePackageImageUrls({