diff --git a/docs/杜康好客-v3-PRD.md b/docs/杜康好客-v3-PRD.md index 877b160..fd607d8 100644 --- a/docs/杜康好客-v3-PRD.md +++ b/docs/杜康好客-v3-PRD.md @@ -30,7 +30,7 @@ | 3.5.8 | 08-24 | HQ 单账号追加/撤销权限;运营改客服修复;城市门店服务+城市范围 | [`v3.5.8`](./杜康好客-v3.5.8-开发文档.md) | | 3.5.9 | 08-25 | 门店累计核销好客权益;HQ 表格去省略号;序号列 + 列设置 + 拖表头改列宽;用户备注 / 手机号不脱敏 | [`v3.5.9`](./杜康好客-v3.5.9-开发文档.md) | | 3.5.10 | 08-25 | C 端门店详情去掉分享按钮;同城送提示改承运商 HTML;小程序客服接通企微微信客服 | [`v3.5.10`](./杜康好客-v3.5.10-开发文档.md) | -| 3.5.11 | 08-26 | 城市三种履约起购;企微通知补提交人/规格物流/核销用户;结算通知(提现通过+四账单) | [`v3.5.11`](./杜康好客-v3.5.11-开发文档.md) | +| 3.5.11 | 08-26 | 城市三种履约起购;企微通知补提交人/规格物流/核销用户;结算任务失败通知 + 提现通过/四账单 | [`v3.5.11`](./杜康好客-v3.5.11-开发文档.md) | --- diff --git a/docs/杜康好客-v3-现状对照.md b/docs/杜康好客-v3-现状对照.md index 2434ec1..05255c0 100644 --- a/docs/杜康好客-v3-现状对照.md +++ b/docs/杜康好客-v3-现状对照.md @@ -65,11 +65,11 @@ | 3.5.8 | [`HQ 追加/撤销权限 + 运营改客服 + 城市门店服务`](./杜康好客-v3.5.8-开发文档.md) | 🔶 开发完成 | | 3.5.9 | [`门店累计核销 + HQ 去截断 + 列设置`](./杜康好客-v3.5.9-开发文档.md) | 🔶 开发完成 | | 3.5.10 | [`门店详情去分享按钮 + 同城提示改承运商 HTML + 小程序客服接企微`](./杜康好客-v3.5.10-开发文档.md) | 🔶 开发完成 | -| 3.5.11 | [`城市履约起购 + 企微通知字段与结算通知`](./杜康好客-v3.5.11-开发文档.md) | 🔶 开发中 | +| 3.5.11 | [`城市履约起购 + 企微通知字段与结算通知`](./杜康好客-v3.5.11-开发文档.md) | 🔶 开发完成 | | 日期 | 说明 | |------|------| -| 2026-08-26 | v3.5.11:城市三种履约起购;企微补提交人/订单规格物流收货/核销用户;`alert.settlement` 改名「结算通知」并补提现通过与四账单 | +| 2026-08-26 | v3.5.11:城市三种履约起购;企微补提交人/订单规格物流收货/核销用户;`alert.settlement` 改名「结算任务失败通知」归入系统监控;补提现通过与四账单 | | 2026-08-25 | v3.5.10:门店详情去掉分享按钮;同城送提示改承运商 HTML(`GET /catalog/local-deliveries`);小程序客服接通企微(需填 CorpID) | | 2026-08-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置/列宽存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏;订单列表状态可多选 | | 2026-08-24 | v3.5.8:HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 | diff --git a/docs/杜康好客-v3.5.11-开发文档.md b/docs/杜康好客-v3.5.11-开发文档.md index 6b322ac..028e2f6 100644 --- a/docs/杜康好客-v3.5.11-开发文档.md +++ b/docs/杜康好客-v3.5.11-开发文档.md @@ -14,7 +14,7 @@ | 3 | 承运商 | 文案 | 「起送瓶数」标明不影响用户起购 | | 4 | 企微提交人 | 优化 | 门店新建/信息变更/套餐待审显示提交人名称 | | 5 | 成交播报 | 优化 | 支付成功补规格/物流/收货;核销成功补用户 | -| 6 | 结算通知 | 需求 | `alert.settlement` 改名;提现通过 + 财务四账单(含收款账户) | +| 6 | 结算通知 | 需求 | `alert.settlement` 改名为「结算任务失败通知」并归入系统监控;提现通过 + 财务四账单(含收款账户) | **不做**:起购迁到承运商;多城按地址匹配起购(仍读当前 ACTIVE 城市);HQ 直改已营业门店不发待审通知。 @@ -73,11 +73,11 @@ ## 5. 结算通知 -条件 key 仍为 `alert.settlement`,HQ 展示 **「结算通知」**(原「结算任务告警」)。分组从「系统与结算」拆为「系统监控」+「结算通知」。 +条件 key 仍为 `alert.settlement`,HQ 展示 **「结算任务失败通知」**(原「结算任务告警」),放在 **系统监控** 组。提现通过 / 四账单为独立条件,仍在「结算通知」组。 | key | 触发 | 收款账户 | |-----|------|----------| -| `alert.settlement` | 账单定时任务失败、提现超时未审 | — | +| `alert.settlement` | 结算任务失败通知(系统监控组):账单定时任务失败、提现超时未审 | — | | `store.withdraw_approved` | 门店手动提现审核通过 | 门店主账号卡 | | `finance.store_bill` | 门店日账单 `created>0` | 各店主账号卡 | | `finance.partner_bill` | 合伙人月账单生成成功 | 合伙人卡 | @@ -97,6 +97,6 @@ - [ ] HQ 新建门店:企微可见 HQ 名称 - [ ] 支付成功:规格单位、物流方式、收货信息按提货/配送分支正确 - [ ] 核销成功:有用户昵称与脱敏手机 -- [ ] HQ 推送条件展示「结算通知」,不再出现「结算任务告警」 -- [ ] 账单任务失败 / 提现超时出现在勾了「结算通知」的群 +- [ ] HQ 推送条件展示「结算任务失败通知」,在系统监控组 +- [ ] 账单任务失败 / 提现超时出现在勾了「结算任务失败通知」的群 - [ ] 提现通过、四类账单生成含收款账户;无账单不刷屏 diff --git a/packages/shared-types/src/wecom-message-push.test.ts b/packages/shared-types/src/wecom-message-push.test.ts new file mode 100644 index 0000000..cedee0d --- /dev/null +++ b/packages/shared-types/src/wecom-message-push.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { + WECOM_PUSH_CONDITION_GROUPS, + WECOM_PUSH_CONDITION_LABELS, + WECOM_PUSH_DEFAULT_SETTLEMENT_CONDITIONS, + WECOM_SETTLEMENT_PUSH_NAME, + parseWecomPushConditions, +} from './wecom-message-push'; + +describe('wecom-message-push', () => { + it('alert.settlement 展示为结算任务失败通知且在系统监控组', () => { + expect(WECOM_PUSH_CONDITION_LABELS['alert.settlement']).toBe('结算任务失败通知'); + const system = WECOM_PUSH_CONDITION_GROUPS.find((g) => g.key === 'system'); + expect(system?.conditions).toEqual(['alert.system', 'alert.settlement']); + }); + + it('默认结算通知群条件含任务失败与四账单', () => { + expect(WECOM_SETTLEMENT_PUSH_NAME).toBe('结算通知群'); + expect(WECOM_PUSH_DEFAULT_SETTLEMENT_CONDITIONS).toEqual( + expect.arrayContaining([ + 'alert.settlement', + 'store.withdraw_approved', + 'finance.store_bill', + 'finance.partner_bill', + 'finance.winery_bill', + 'finance.logistics_bill', + ]), + ); + }); + + it('解析新财务条件', () => { + expect(parseWecomPushConditions(['finance.store_bill', 'bogus'])).toEqual([ + 'finance.store_bill', + ]); + }); +}); diff --git a/packages/shared-types/src/wecom-message-push.ts b/packages/shared-types/src/wecom-message-push.ts index 76f36c0..e2bde04 100644 --- a/packages/shared-types/src/wecom-message-push.ts +++ b/packages/shared-types/src/wecom-message-push.ts @@ -1,4 +1,4 @@ -/** 企微群机器人 Webhook · 推送条件(v3.4.11 + v3.5.3 业务通知) */ +/** 企微群机器人 Webhook · 推送条件(v3.4.11 + v3.5.3 业务通知 + v3.5.11 结算通知) */ export const WECOM_PUSH_CONDITIONS = [ 'alert.ops', 'support_ticket.created', @@ -11,9 +11,14 @@ export const WECOM_PUSH_CONDITIONS = [ 'store.package_audit_pending', 'store.info_change_pending', 'store.withdraw_pending', + 'store.withdraw_approved', 'order.paid', 'redeem.success', 'invoice.pending', + 'finance.store_bill', + 'finance.partner_bill', + 'finance.winery_bill', + 'finance.logistics_bill', ] as const; export type WecomPushCondition = (typeof WECOM_PUSH_CONDITIONS)[number]; @@ -24,15 +29,20 @@ export const WECOM_PUSH_CONDITION_LABELS: Record = { 'alert.pay': '支付异常', 'alert.redeem': '核销异常', 'alert.system': '系统监控(5xx、回调、定时任务)', - 'alert.settlement': '结算任务告警', + 'alert.settlement': '结算任务失败通知', 'dev_plan.task_dispatch': '开发任务评审派发', 'store.audit_pending': '门店提交/重提待审', 'store.package_audit_pending': '套餐变更待审', 'store.info_change_pending': '门店信息变更待审', 'store.withdraw_pending': '门店手动提现待审', + 'store.withdraw_approved': '门店手动提现已通过', 'order.paid': '订单支付成功', 'redeem.success': '门店核销成功', 'invoice.pending': '发票申请待开票', + 'finance.store_bill': '门店日账单已生成', + 'finance.partner_bill': '合伙人月账单已生成', + 'finance.winery_bill': '酒厂日账单已生成', + 'finance.logistics_bill': '物流月对账已生成', }; export const WECOM_PUSH_CONDITION_GROUPS: Array<{ @@ -68,9 +78,20 @@ export const WECOM_PUSH_CONDITION_GROUPS: Array<{ }, { key: 'system', - label: '系统与结算', + label: '系统监控', conditions: ['alert.system', 'alert.settlement'], }, + { + key: 'settlement', + label: '结算通知', + conditions: [ + 'store.withdraw_approved', + 'finance.store_bill', + 'finance.partner_bill', + 'finance.winery_bill', + 'finance.logistics_bill', + ], + }, { key: 'dev_plan', label: '开发计划', @@ -115,9 +136,20 @@ export const WECOM_PUSH_DEFAULT_DEAL_BROADCAST_CONDITIONS: WecomPushCondition[] 'redeem.success', ]; +/** 默认「结算通知群」 */ +export const WECOM_PUSH_DEFAULT_SETTLEMENT_CONDITIONS: WecomPushCondition[] = [ + 'alert.settlement', + 'store.withdraw_approved', + 'finance.store_bill', + 'finance.partner_bill', + 'finance.winery_bill', + 'finance.logistics_bill', +]; + export const WECOM_STORE_AUDIT_PUSH_NAME = '门店审核通知群'; export const WECOM_BIZ_TODO_PUSH_NAME = '业务待办通知群'; export const WECOM_DEAL_BROADCAST_PUSH_NAME = '成交播报群'; +export const WECOM_SETTLEMENT_PUSH_NAME = '结算通知群'; /** 占位 webhook(未配置真实 key 时禁用,避免误推) */ export const WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK = @@ -131,7 +163,12 @@ export const WECOM_TEMPLATE_EVENT_KEYS = [ 'store.package_audit_pending', 'store.info_change_pending', 'store.withdraw_pending', + 'store.withdraw_approved', 'invoice.pending', + 'finance.store_bill', + 'finance.partner_bill', + 'finance.winery_bill', + 'finance.logistics_bill', ] as const; export type WecomTemplateEventKey = (typeof WECOM_TEMPLATE_EVENT_KEYS)[number]; @@ -143,14 +180,50 @@ export const WECOM_TEMPLATE_EVENT_LABELS: Record 'store.package_audit_pending': '套餐变更待审', 'store.info_change_pending': '门店信息变更待审', 'store.withdraw_pending': '门店手动提现待审', + 'store.withdraw_approved': '门店手动提现已通过', 'invoice.pending': '发票申请待开票', + 'finance.store_bill': '门店日账单已生成', + 'finance.partner_bill': '合伙人月账单已生成', + 'finance.winery_bill': '酒厂日账单已生成', + 'finance.logistics_bill': '物流月对账已生成', }; /** 各事件可用占位符说明(HQ 模板编辑) */ export const WECOM_TEMPLATE_PLACEHOLDERS: Record = { - 'order.paid': ['orderNo', 'payAmount', 'cityName', 'skuSummary', 'phoneMasked', 'time', 'handleUrl', 'handleLabel'], - 'redeem.success': ['redeemNo', 'amount', 'storeName', 'channel', 'time', 'handleUrl', 'handleLabel'], - 'store.audit_pending': ['storeName', 'cityName', 'partnerLabel', 'action', 'time', 'handleUrl', 'handleLabel', 'storeId'], + 'order.paid': [ + 'orderNo', + 'payAmount', + 'cityName', + 'skuSummary', + 'deliveryLabel', + 'receiverInfo', + 'phoneMasked', + 'time', + 'handleUrl', + 'handleLabel', + ], + 'redeem.success': [ + 'redeemNo', + 'amount', + 'storeName', + 'channel', + 'userName', + 'userPhone', + 'time', + 'handleUrl', + 'handleLabel', + ], + 'store.audit_pending': [ + 'storeName', + 'cityName', + 'submitter', + 'partnerLabel', + 'action', + 'time', + 'handleUrl', + 'handleLabel', + 'storeId', + ], 'store.package_audit_pending': [ 'storeName', 'cityName', @@ -181,6 +254,16 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record { 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 9f58286..44e1052 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 @@ -7,7 +7,9 @@ import { WECOM_PUSH_DEFAULT_BIZ_TODO_CONDITIONS, WECOM_PUSH_DEFAULT_DEAL_BROADCAST_CONDITIONS, WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS, + WECOM_PUSH_DEFAULT_SETTLEMENT_CONDITIONS, WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS, + WECOM_SETTLEMENT_PUSH_NAME, WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK, WECOM_STORE_AUDIT_PUSH_NAME, WECOM_TEMPLATE_EVENT_KEYS, @@ -22,6 +24,7 @@ import { PrismaService } from '../../common/prisma/prisma.module'; import { applyWecomAtMentionInContent } from '../../modules/dev-plan/dev-plan-wecom-mention.util'; import { WECOM_PUSH_TEMPLATE_DEFAULTS, + WECOM_PUSH_TEMPLATE_LEGACY_BODIES, buildHqHandleUrl, getDefaultTemplate, renderWecomTemplate, @@ -66,7 +69,7 @@ export class WecomMessagePushService implements OnModuleInit { } } - /** 表空时从 .env / 旧设置迁移;并确保门店审核 / 业务待办 / 成交播报 / 模板缺行 */ + /** 表空时从 .env / 旧设置迁移;并确保门店审核 / 业务待办 / 成交播报 / 结算通知 / 模板缺行 */ async ensureDefaults(): Promise { const count = await this.prisma.wecomMessagePush.count(); if (count === 0) { @@ -141,6 +144,12 @@ export class WecomMessagePushService implements OnModuleInit { 30, process.env.WECOM_DEAL_BROADCAST_WEBHOOK_URL, ); + await this.ensureNamedPush( + WECOM_SETTLEMENT_PUSH_NAME, + WECOM_PUSH_DEFAULT_SETTLEMENT_CONDITIONS, + 35, + process.env.WECOM_SETTLEMENT_WEBHOOK_URL, + ); await this.ensureTemplates(); } @@ -181,22 +190,36 @@ export class WecomMessagePushService implements OnModuleInit { this.logger.log(`seeded wecom message push: ${name} (placeholder, disabled)`); } - /** 仅插入缺失 eventKey,不覆盖已有文案 */ + /** 插入缺失 eventKey;仅当 body 仍为上一版默认文案时升级,不覆盖 HQ 手改 */ async ensureTemplates(): Promise { for (const def of WECOM_PUSH_TEMPLATE_DEFAULTS) { const existing = await this.prisma.wecomPushTemplate.findUnique({ where: { eventKey: def.eventKey }, }); - if (existing) continue; - await this.prisma.wecomPushTemplate.create({ - data: { - eventKey: def.eventKey, - title: def.title, - body: def.body, - handleLabel: def.handleLabel, - }, - }); - this.logger.log(`seeded wecom push template: ${def.eventKey}`); + if (!existing) { + await this.prisma.wecomPushTemplate.create({ + data: { + eventKey: def.eventKey, + title: def.title, + body: def.body, + handleLabel: def.handleLabel, + }, + }); + this.logger.log(`seeded wecom push template: ${def.eventKey}`); + continue; + } + const legacy = WECOM_PUSH_TEMPLATE_LEGACY_BODIES[def.eventKey]; + if (legacy && existing.body === legacy) { + await this.prisma.wecomPushTemplate.update({ + where: { eventKey: def.eventKey }, + data: { + title: def.title, + body: def.body, + handleLabel: def.handleLabel, + }, + }); + this.logger.log(`upgraded wecom push template from legacy default: ${def.eventKey}`); + } } } @@ -447,7 +470,9 @@ export class WecomMessagePushService implements OnModuleInit { orderNo: 'DK202608200001', payAmount: '199.00', cityName: '郑州', - skuSummary: '杜康原浆 ×2', + skuSummary: '杜康原浆 52度 瓶×2', + deliveryLabel: '同城', + receiverInfo: '张三 13800008000 河南省郑州市金水区示例路1号', phoneMasked: '138****8000', }, handlePath: '/orders?orderNo=DK202608200001', @@ -458,6 +483,8 @@ export class WecomMessagePushService implements OnModuleInit { amount: '88.00', storeName: '示例门店', channel: '扫码', + userName: '好客用户', + userPhone: '138****8000', }, handlePath: '/redeem-records?redeemNo=RD202608200001', }, @@ -465,7 +492,8 @@ export class WecomMessagePushService implements OnModuleInit { vars: { storeName: '示例门店', cityName: '郑州', - partnerLabel: '示例合伙人', + submitter: '郑州某某商贸 · 张三', + partnerLabel: '郑州某某商贸 · 张三', action: '新建', storeId: '1', }, @@ -475,7 +503,7 @@ export class WecomMessagePushService implements OnModuleInit { vars: { storeName: '示例门店', cityName: '郑州', - submitter: '合伙人', + submitter: '郑州某某商贸 · 张三', packageCount: '3', requestId: '1', }, @@ -485,7 +513,7 @@ export class WecomMessagePushService implements OnModuleInit { vars: { storeName: '示例门店', cityName: '郑州', - submitter: '门店', + submitter: '门店 · 李四', changedFields: '门店名称、详细地址', requestId: '1', }, @@ -501,6 +529,16 @@ export class WecomMessagePushService implements OnModuleInit { }, handlePath: '/finance/store-bills?kind=WITHDRAW&status=PENDING_REVIEW&storeId=1', }, + 'store.withdraw_approved': { + vars: { + storeName: '示例门店', + withdrawNo: 'SW202608200001', + amount: '500.00', + bankAccount: '张三 / 郑州支行 / 6222000011112222', + storeId: '1', + }, + handlePath: '/finance/store-bills?kind=WITHDRAW&status=PAID&storeId=1', + }, 'invoice.pending': { vars: { invoiceNo: 'IV202608200001', @@ -511,6 +549,39 @@ export class WecomMessagePushService implements OnModuleInit { }, handlePath: '/invoices?status=PENDING&invoiceNo=IV202608200001', }, + 'finance.store_bill': { + vars: { + period: '2026-08-25', + billCount: '2', + billSummary: '示例门店 ¥300.00 张三 / 郑州支行 / 6222000011112222', + }, + handlePath: '/finance/store-bills?kind=T1_BILL', + }, + 'finance.partner_bill': { + vars: { + period: '2026-07', + billCount: '1', + billSummary: '郑州某某商贸 ¥120.00 张三 / 郑州支行 / 6222000011112222', + }, + handlePath: '/finance/partner-bills', + }, + 'finance.winery_bill': { + vars: { + period: '2026-08-23', + amount: '1990.00', + orderCount: '8', + bankAccount: '杜康酒厂 / 洛阳支行 / 6222000099998888', + }, + handlePath: '/finance/winery-bills', + }, + 'finance.logistics_bill': { + vars: { + period: '2026-07', + billCount: '1', + billSummary: '小飞侠 ¥86.00 承运商 / 郑州支行 / 6222000033334444', + }, + handlePath: '/finance/logistics-bills', + }, }; return samples[eventKey]; } 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 230fb88..d0f77d3 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 @@ -18,6 +18,8 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [ '实付:¥{{payAmount}}', '城市:{{cityName}}', '商品:{{skuSummary}}', + '物流:{{deliveryLabel}}', + '收货:{{receiverInfo}}', '用户:{{phoneMasked}}', '时间:{{time}}', '[{{handleLabel}}]({{handleUrl}})', @@ -33,6 +35,7 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [ '金额:¥{{amount}}', '门店:{{storeName}}', '渠道:{{channel}}', + '用户:{{userName}} {{userPhone}}', '时间:{{time}}', '[{{handleLabel}}]({{handleUrl}})', ].join('\n'), @@ -45,6 +48,7 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [ '**门店审核待处理 · {{action}}**', '门店:{{storeName}}', '城市:{{cityName}}', + '提交人:{{submitter}}', '合伙人:{{partnerLabel}}', '时间:{{time}}', '[{{handleLabel}}]({{handleUrl}})', @@ -58,7 +62,7 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [ '**套餐变更待审核**', '门店:{{storeName}}', '城市:{{cityName}}', - '提交端:{{submitter}}', + '提交人:{{submitter}}', '套餐条数:{{packageCount}}', '时间:{{time}}', '[{{handleLabel}}]({{handleUrl}})', @@ -72,7 +76,7 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [ '**门店信息变更待审**', '门店:{{storeName}}', '城市:{{cityName}}', - '提交端:{{submitter}}', + '提交人:{{submitter}}', '变更字段:{{changedFields}}', '时间:{{time}}', '[{{handleLabel}}]({{handleUrl}})', @@ -93,6 +97,20 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [ ].join('\n'), handleLabel: '去处理', }, + { + eventKey: 'store.withdraw_approved', + title: '门店提现已通过', + body: [ + '**门店提现已通过**', + '门店:{{storeName}}', + '提现单号:{{withdrawNo}}', + '金额:¥{{amount}}', + '收款账户:{{bankAccount}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + handleLabel: '去处理', + }, { eventKey: 'invoice.pending', title: '发票申请待开票', @@ -108,8 +126,110 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [ ].join('\n'), handleLabel: '去处理', }, + { + eventKey: 'finance.store_bill', + title: '门店日账单已生成', + body: [ + '**门店日账单已生成**', + '账期:{{period}}', + '笔数:{{billCount}}', + '{{billSummary}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + handleLabel: '去处理', + }, + { + eventKey: 'finance.partner_bill', + title: '合伙人月账单已生成', + body: [ + '**合伙人月账单已生成**', + '账期:{{period}}', + '笔数:{{billCount}}', + '{{billSummary}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + handleLabel: '去处理', + }, + { + eventKey: 'finance.winery_bill', + title: '酒厂日账单已生成', + body: [ + '**酒厂日账单已生成**', + '账期:{{period}}', + '订单数:{{orderCount}}', + '金额:¥{{amount}}', + '收款账户:{{bankAccount}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + handleLabel: '去处理', + }, + { + eventKey: 'finance.logistics_bill', + title: '物流月对账已生成', + body: [ + '**物流月对账已生成**', + '账期:{{period}}', + '笔数:{{billCount}}', + '{{billSummary}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + handleLabel: '去处理', + }, ]; +/** v3.5.3 默认正文;ensureTemplates 仅在库中仍为此文案时升级,不覆盖 HQ 手改 */ +export const WECOM_PUSH_TEMPLATE_LEGACY_BODIES: Partial> = { + 'order.paid': [ + '**订单支付成功**', + '订单号:{{orderNo}}', + '实付:¥{{payAmount}}', + '城市:{{cityName}}', + '商品:{{skuSummary}}', + '用户:{{phoneMasked}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + 'redeem.success': [ + '**门店核销成功**', + '核销单号:{{redeemNo}}', + '金额:¥{{amount}}', + '门店:{{storeName}}', + '渠道:{{channel}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + 'store.audit_pending': [ + '**门店审核待处理 · {{action}}**', + '门店:{{storeName}}', + '城市:{{cityName}}', + '合伙人:{{partnerLabel}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + 'store.package_audit_pending': [ + '**套餐变更待审核**', + '门店:{{storeName}}', + '城市:{{cityName}}', + '提交端:{{submitter}}', + '套餐条数:{{packageCount}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + 'store.info_change_pending': [ + '**门店信息变更待审**', + '门店:{{storeName}}', + '城市:{{cityName}}', + '提交端:{{submitter}}', + '变更字段:{{changedFields}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), +}; + export function getDefaultTemplate(eventKey: string): WecomTemplateDefault | undefined { return WECOM_PUSH_TEMPLATE_DEFAULTS.find((t) => t.eventKey === eventKey); } diff --git a/server/dukang-api/src/jobs/settlement.scheduler.ts b/server/dukang-api/src/jobs/settlement.scheduler.ts index 727f678..cfc37cb 100644 --- a/server/dukang-api/src/jobs/settlement.scheduler.ts +++ b/server/dukang-api/src/jobs/settlement.scheduler.ts @@ -28,7 +28,7 @@ export class SettlementScheduler { this.logger.error('Winery bill job failed', e instanceof Error ? e.stack : e); this.alert.notify({ level: 'P0', - category: 'job', + category: 'settlement', title: '酒厂日账单任务失败', detail: e instanceof Error ? e.message : String(e), dedupeKey: `job_winery_bill|${new Date().toISOString().slice(0, 10)}`, @@ -41,7 +41,7 @@ export class SettlementScheduler { this.logger.error('Store bill job failed', e instanceof Error ? e.stack : e); this.alert.notify({ level: 'P0', - category: 'job', + category: 'settlement', title: '门店日账单任务失败', detail: e instanceof Error ? e.message : String(e), dedupeKey: `job_store_bill|${new Date().toISOString().slice(0, 10)}`, @@ -60,7 +60,7 @@ export class SettlementScheduler { this.logger.error('Store withdraw overdue scan failed', e instanceof Error ? e.stack : e); this.alert.notify({ level: 'P1', - category: 'job', + category: 'settlement', title: '门店提现超时扫描失败', detail: e instanceof Error ? e.message : String(e), dedupeKey: `job_store_withdraw_overdue_fail|${new Date().toISOString().slice(0, 10)}`, @@ -79,7 +79,7 @@ export class SettlementScheduler { if (result.failed > 0) { this.alert.notify({ level: 'P0', - category: 'job', + category: 'settlement', title: '合伙人月账单部分失败', detail: `total=${result.total} success=${result.success} failed=${result.failed}`, dedupeKey: `job_partner_bill|${new Date().toISOString().slice(0, 7)}`, @@ -89,7 +89,7 @@ export class SettlementScheduler { this.logger.error('Partner bill job failed', e instanceof Error ? e.stack : e); this.alert.notify({ level: 'P0', - category: 'job', + category: 'settlement', title: '合伙人月账单任务失败', detail: e instanceof Error ? e.message : String(e), dedupeKey: `job_partner_bill|${new Date().toISOString().slice(0, 7)}`, @@ -103,7 +103,7 @@ export class SettlementScheduler { if (logistics.failed > 0) { this.alert.notify({ level: 'P0', - category: 'job', + category: 'settlement', title: '物流月对账部分失败', detail: `total=${logistics.total} success=${logistics.success} failed=${logistics.failed}`, dedupeKey: `job_logistics_bill|${new Date().toISOString().slice(0, 7)}`, @@ -113,7 +113,7 @@ export class SettlementScheduler { this.logger.error('Logistics bill job failed', e instanceof Error ? e.stack : e); this.alert.notify({ level: 'P0', - category: 'job', + category: 'settlement', title: '物流月对账任务失败', detail: e instanceof Error ? e.message : String(e), dedupeKey: `job_logistics_bill|${new Date().toISOString().slice(0, 7)}`, diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts index c2e59fc..f336a89 100644 --- a/server/dukang-api/src/modules/ops/admin-stores.service.ts +++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts @@ -20,6 +20,8 @@ import type { } from './dto/admin-mutate.dto'; import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service'; import { HqPermissionsResolver, hqStoreCityWhere, mergeHqStoreCityWhere, type HqCityScope } from '../../common/guards/hq-permission.guard'; +import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; +import { formatHqWecomLabel, formatPartnerWecomLabel } from '../store/wecom-submitter-label'; /** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */ function normalizeStoreOptionalText(value: unknown): string | null { @@ -56,6 +58,7 @@ export class AdminStoresService { private readonly analyticsService: AnalyticsService, private readonly testWhitelist: TestWhitelistService, private readonly hqPermissions: HqPermissionsResolver, + private readonly wecomPush: WecomMessagePushService, ) {} private async storeIdsInScope(scope: HqCityScope): Promise { @@ -776,6 +779,23 @@ export class AdminStoresService { }); } + const [submitter, partnerLabel] = await Promise.all([ + formatHqWecomLabel(this.prisma, actorId), + formatPartnerWecomLabel(this.prisma, partnerAccountId), + ]); + void this.wecomPush.dispatchEvent( + 'store.audit_pending', + { + storeName: store.name, + cityName: store.cityName, + submitter, + partnerLabel, + action: '新建', + storeId: store.id.toString(), + }, + { handlePath: `/stores?storeId=${store.id.toString()}` }, + ); + return this.detailStore(store.id, actorId); } diff --git a/server/dukang-api/src/modules/redeem/redeem.service.ts b/server/dukang-api/src/modules/redeem/redeem.service.ts index 1fbb23c..dd4dd30 100644 --- a/server/dukang-api/src/modules/redeem/redeem.service.ts +++ b/server/dukang-api/src/modules/redeem/redeem.service.ts @@ -8,6 +8,7 @@ import { calcRedeemSettleAmount, generateRedeemNo, generateRedeemPendingNo, + maskContactPhone, REDEEM_WEAKNET_FAIL_THRESHOLD, resolveSettlementRate, validateRedeemAmount, @@ -194,7 +195,10 @@ export class RedeemService { const redeemChannel = analyticsExtra?.channel === 'phone' ? 'PHONE' : 'SCAN'; const [userRow, storeRow] = await Promise.all([ - this.prisma.user.findUnique({ where: { id: userId }, select: { isTest: true } }), + this.prisma.user.findUnique({ + where: { id: userId }, + select: { isTest: true, nickname: true, phone: true }, + }), this.prisma.store.findUnique({ where: { id: account.storeId }, select: { isTest: true }, @@ -290,6 +294,8 @@ export class RedeemService { amount: amountNum.toFixed(2), storeName: account.store.name || String(account.storeId), channel: channelLabel, + userName: userRow?.nickname?.trim() || '—', + userPhone: maskContactPhone(userRow?.phone), }, { handlePath: `/redeem-records?redeemNo=${encodeURIComponent(record.redeemNo)}`, diff --git a/server/dukang-api/src/modules/settlement/settlement.service.ts b/server/dukang-api/src/modules/settlement/settlement.service.ts index a898034..4aa477e 100644 --- a/server/dukang-api/src/modules/settlement/settlement.service.ts +++ b/server/dukang-api/src/modules/settlement/settlement.service.ts @@ -26,6 +26,7 @@ import { loadStorePrimaryBank, loadStorePrimaryBanksMap, loadWineryBankConfig, + formatWecomBankAccount, } from '../../common/store/store-bank.util'; function generateBillNo(prefix: string) { @@ -62,6 +63,14 @@ function round2(n: number) { return Math.round(n * 100) / 100; } +const BILL_DIGEST_MAX_LINES = 15; + +function capBillSummary(lines: string[]): string { + if (!lines.length) return '无'; + if (lines.length <= BILL_DIGEST_MAX_LINES) return lines.join('\n'); + return `${lines.slice(0, BILL_DIGEST_MAX_LINES).join('\n')}\n…等共 ${lines.length} 笔`; +} + function getStoreWithdrawDailyLimit(): number { const raw = process.env.STORE_WITHDRAW_DAILY_LIMIT; const n = raw != null && raw !== '' ? Number(raw) : DEFAULT_STORE_WITHDRAW_DAILY_LIMIT; @@ -95,6 +104,59 @@ export class SettlementService { private readonly wecomPush: WecomMessagePushService, ) {} + private notifyPartnerBillDigest( + period: string, + rows: Array<{ + label: string; + amount: number; + bankAccountName?: string | null; + bankBranch?: string | null; + bankAccountNo?: string | null; + }>, + ) { + if (!rows.length) return; + const lines = rows.map( + (r) => + `${r.label} ¥${Number(r.amount).toFixed(2)} ${formatWecomBankAccount(r)}`, + ); + void this.wecomPush.dispatchEvent( + 'finance.partner_bill', + { + period, + billCount: String(rows.length), + billSummary: capBillSummary(lines), + }, + { handlePath: '/finance/partner-bills' }, + ); + } + + private notifyLogisticsBillDigest( + period: string, + rows: Array<{ + label: string; + amount: number; + bankAccountName?: string | null; + bankName?: string | null; + bankBranch?: string | null; + bankAccountNo?: string | null; + }>, + ) { + if (!rows.length) return; + const lines = rows.map( + (r) => + `${r.label} ¥${Number(r.amount).toFixed(2)} ${formatWecomBankAccount(r)}`, + ); + void this.wecomPush.dispatchEvent( + 'finance.logistics_bill', + { + period, + billCount: String(rows.length), + billSummary: capBillSummary(lines), + }, + { handlePath: '/finance/logistics-bills' }, + ); + } + // ─── Store payout (line) ───────────────────────────── async createStorePayout( @@ -584,6 +646,27 @@ export class SettlementService { }, }); + const [store, bank] = await Promise.all([ + this.prisma.store.findUnique({ + where: { id: row.storeId }, + select: { name: true }, + }), + loadStorePrimaryBank(this.prisma, row.storeId), + ]); + void this.wecomPush.dispatchEvent( + 'store.withdraw_approved', + { + storeName: store?.name || String(row.storeId), + withdrawNo: row.withdrawNo, + amount: Number(row.amount).toFixed(2), + bankAccount: formatWecomBankAccount(bank), + storeId: row.storeId.toString(), + }, + { + handlePath: `/finance/store-bills?kind=WITHDRAW&status=PAID&storeId=${row.storeId.toString()}`, + }, + ); + return serializeBigInt(updated); } @@ -651,7 +734,7 @@ export class SettlementService { ); this.alert.notify({ level: 'P1', - category: 'finance', + category: 'settlement', title: '门店提现超时未审', detail: [ `待审 ${summary.pendingCount} 笔,超时 ${summary.overdueCount} 笔,超时金额 ¥${summary.overdueAmount.toFixed(2)}`, @@ -847,7 +930,7 @@ export class SettlementService { withdrawItem: { is: null }, status: 'PENDING', }, - include: { store: { select: { id: true, settlementRate: true } } }, + include: { store: { select: { id: true, name: true, settlementRate: true } } }, }); const byStore = new Map(); @@ -860,8 +943,10 @@ export class SettlementService { let created = 0; let skipped = 0; + const createdBills: Array<{ storeId: bigint; storeName: string; amount: number }> = []; for (const [storeIdStr, list] of byStore) { const storeId = BigInt(storeIdStr); + const storeName = list[0]?.store?.name || storeIdStr; const existing = await this.prisma.storeBill.findUnique({ where: { storeId_billDate: { storeId, billDate } }, }); @@ -873,13 +958,14 @@ export class SettlementService { const redeemAmount = round2(list.reduce((s, p) => s + Number(p.redeemAmount), 0)); const payoutAmount = round2(list.reduce((s, p) => s + Number(p.payoutAmount), 0)); const rate = Number(list[0].settlementRate); + const updatedTotal = round2(Number(existing.payoutAmount) + payoutAmount); await this.prisma.$transaction(async (tx) => { await tx.storeBill.update({ where: { id: existing.id }, data: { redeemCount: existing.redeemCount + list.length, redeemAmount: round2(Number(existing.redeemAmount) + redeemAmount), - payoutAmount: round2(Number(existing.payoutAmount) + payoutAmount), + payoutAmount: updatedTotal, settlementRate: rate, }, }); @@ -889,6 +975,7 @@ export class SettlementService { }); }); created += 1; + createdBills.push({ storeId, storeName, amount: updatedTotal }); continue; } @@ -914,9 +1001,31 @@ export class SettlementService { }); }); created += 1; + createdBills.push({ storeId, storeName, amount: payoutAmount }); } - return { billDate: billDate.toISOString().slice(0, 10), created, skipped, payoutLinked: payouts.length }; + const period = billDate.toISOString().slice(0, 10); + if (createdBills.length) { + const banks = await loadStorePrimaryBanksMap( + this.prisma, + createdBills.map((b) => b.storeId), + ); + const lines = createdBills.map( + (b) => + `${b.storeName} ¥${b.amount.toFixed(2)} ${formatWecomBankAccount(banks.get(b.storeId.toString()) ?? null)}`, + ); + void this.wecomPush.dispatchEvent( + 'finance.store_bill', + { + period, + billCount: String(createdBills.length), + billSummary: capBillSummary(lines), + }, + { handlePath: '/finance/store-bills?kind=T1_BILL' }, + ); + } + + return { billDate: period, created, skipped, payoutLinked: payouts.length }; } /** @@ -1360,17 +1469,43 @@ export class SettlementService { async generateAllPartnerBills(body: { year: number; month: number }) { const partners = await this.prisma.partnerAccount.findMany({ where: { isPrimary: 1, status: 'ACTIVE' }, - select: { id: true, companyName: true }, + select: { + id: true, + companyName: true, + name: true, + bankAccountName: true, + bankBranch: true, + bankAccountNo: true, + }, }); const results: Array<{ partnerId: string; companyName: string; ok: boolean; message?: string }> = []; + const created: Array<{ + label: string; + amount: number; + bankAccountName: string | null; + bankBranch: string | null; + bankAccountNo: string | null; + }> = []; for (const p of partners) { try { - await this.generatePartnerBill({ - partnerId: p.id.toString(), - year: body.year, - month: body.month, - }); + const bill = await this.generatePartnerBill( + { + partnerId: p.id.toString(), + year: body.year, + month: body.month, + }, + { notify: false }, + ); results.push({ partnerId: p.id.toString(), companyName: p.companyName ?? '', ok: true }); + const label = + [p.companyName?.trim(), p.name?.trim()].filter(Boolean).join(' · ') || p.id.toString(); + created.push({ + label, + amount: Number(bill.totalAmount), + bankAccountName: p.bankAccountName, + bankBranch: p.bankBranch, + bankAccountNo: p.bankAccountNo, + }); } catch (e) { results.push({ partnerId: p.id.toString(), @@ -1380,6 +1515,10 @@ export class SettlementService { }); } } + this.notifyPartnerBillDigest( + `${body.year}-${String(body.month).padStart(2, '0')}`, + created, + ); return { total: partners.length, success: results.filter((r) => r.ok).length, @@ -1403,7 +1542,10 @@ export class SettlementService { return serializeBigInt(bill); } - async generatePartnerBill(body: { partnerId: string; year: number; month: number }) { + async generatePartnerBill( + body: { partnerId: string; year: number; month: number }, + opts?: { notify?: boolean }, + ) { const partnerAccountId = BigInt(body.partnerId); const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const periodStart = new Date(body.year, body.month - 1, 1); @@ -1485,6 +1627,24 @@ export class SettlementService { }, }); + if (opts?.notify !== false) { + const label = + [primary.companyName?.trim(), primary.name?.trim()].filter(Boolean).join(' · ') || + primary.id.toString(); + this.notifyPartnerBillDigest( + `${body.year}-${String(body.month).padStart(2, '0')}`, + [ + { + label, + amount: totalAmount, + bankAccountName: primary.bankAccountName, + bankBranch: primary.bankBranch, + bankAccountNo: primary.bankAccountNo, + }, + ], + ); + } + return serializeBigInt(bill); } @@ -1775,8 +1935,21 @@ export class SettlementService { return header; }); + const period = billDate.toISOString().slice(0, 10); + const wineryBank = await loadWineryBankConfig(this.prisma); + void this.wecomPush.dispatchEvent( + 'finance.winery_bill', + { + period, + amount: wineryAmount.toFixed(2), + orderCount: String(orders.length), + bankAccount: formatWecomBankAccount(wineryBank), + }, + { handlePath: '/finance/winery-bills' }, + ); + return serializeBigInt({ - billDate: billDate.toISOString().slice(0, 10), + billDate: period, skipped: false, bill, }); @@ -1997,14 +2170,25 @@ export class SettlementService { message?: string; billId?: string; }> = []; + const created: Array<{ + label: string; + amount: number; + bankAccountName: string | null; + bankName: string | null; + bankBranch: string | null; + bankAccountNo: string | null; + }> = []; for (const p of providers) { try { - const bill = await this.generateLogisticsBill({ - providerId: p.id.toString(), - year: body.year, - month: body.month, - }); + const bill = await this.generateLogisticsBill( + { + providerId: p.id.toString(), + year: body.year, + month: body.month, + }, + { notify: false }, + ); results.push({ providerId: p.id.toString(), providerCode: p.code, @@ -2019,6 +2203,16 @@ export class SettlementService { : String(bill.bill.id) : undefined, }); + if (!bill.skipped && bill.bill) { + created.push({ + label: p.name, + amount: Number(bill.bill.logisticsAmount ?? 0), + bankAccountName: p.bankAccountName, + bankName: p.bankName, + bankBranch: p.bankBranch, + bankAccountNo: p.bankAccountNo, + }); + } } catch (e) { results.push({ providerId: p.id.toString(), @@ -2030,6 +2224,11 @@ export class SettlementService { } } + this.notifyLogisticsBillDigest( + `${body.year}-${String(body.month).padStart(2, '0')}`, + created, + ); + return { total: providers.length, success: results.filter((r) => r.ok).length, @@ -2038,7 +2237,10 @@ export class SettlementService { }; } - async generateLogisticsBill(body: { providerId: string; year: number; month: number }) { + async generateLogisticsBill( + body: { providerId: string; year: number; month: number }, + opts?: { notify?: boolean }, + ) { const fulfillmentProviderId = BigInt(body.providerId); const provider = await this.prisma.fulfillmentProvider.findUnique({ where: { id: fulfillmentProviderId }, @@ -2190,6 +2392,22 @@ export class SettlementService { return header; }); + if (opts?.notify !== false) { + this.notifyLogisticsBillDigest( + `${body.year}-${String(body.month).padStart(2, '0')}`, + [ + { + label: provider.name, + amount: Number(bill.logisticsAmount), + bankAccountName: provider.bankAccountName, + bankName: provider.bankName, + bankBranch: provider.bankBranch, + bankAccountNo: provider.bankAccountNo, + }, + ], + ); + } + return { skipped: false, bill: serializeBigInt(bill), diff --git a/server/dukang-api/src/modules/store/store-info-change.service.ts b/server/dukang-api/src/modules/store/store-info-change.service.ts index 519f033..e3b0d44 100644 --- a/server/dukang-api/src/modules/store/store-info-change.service.ts +++ b/server/dukang-api/src/modules/store/store-info-change.service.ts @@ -21,6 +21,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator' import { StoreService } from './store.service'; import { PartnerCityService } from '../city-scope/partner-city.service'; import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; +import { formatHqWecomLabel, formatPartnerWecomLabel, formatShopWecomLabel } from './wecom-submitter-label'; import { HqPermissionsResolver, hqStoreCityWhere } from '../../common/guards/hq-permission.guard'; type ChangeableField = (typeof STORE_INFO_CHANGEABLE_FIELDS)[number]; @@ -335,13 +336,18 @@ export class StoreInfoChangeService { `Store info change submitted storeId=${input.storeId} fields=${changedFields.join(',')}`, ); - const submitterLabel = input.submitterType === 'PARTNER' ? '合伙人' : '门店'; + const submitter = + input.submitterType === 'PARTNER' + ? await formatPartnerWecomLabel(this.prisma, input.submitterId) + : input.submitterType === 'HQ_DIRECT_ADMIN' + ? await formatHqWecomLabel(this.prisma, input.submitterId) + : await formatShopWecomLabel(this.prisma, input.submitterId); void this.wecomPush.dispatchEvent( 'store.info_change_pending', { storeName: String(store.name ?? input.storeId), cityName: String(store.cityName ?? '—'), - submitter: submitterLabel, + submitter, changedFields: formatStoreInfoChangeFieldLabels(changedFields), requestId: created.id.toString(), }, diff --git a/server/dukang-api/src/modules/store/store-package.service.ts b/server/dukang-api/src/modules/store/store-package.service.ts index d107ae6..1e74e3d 100644 --- a/server/dukang-api/src/modules/store/store-package.service.ts +++ b/server/dukang-api/src/modules/store/store-package.service.ts @@ -15,6 +15,7 @@ import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { StoreService } from './store.service'; import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; +import { formatPartnerWecomLabel, formatShopWecomLabel } from './wecom-submitter-label'; import { HqPermissionsResolver, hqStoreCityWhere } from '../../common/guards/hq-permission.guard'; type PackageInput = Record; @@ -193,13 +194,16 @@ export class StorePackageService { where: { id: storeId }, select: { name: true, cityName: true }, }); - const submitterLabel = submitterType === 'PARTNER' ? '合伙人' : '门店'; + const submitter = + submitterType === 'PARTNER' + ? await formatPartnerWecomLabel(this.prisma, submitterId) + : await formatShopWecomLabel(this.prisma, submitterId); void this.wecomPush.dispatchEvent( 'store.package_audit_pending', { storeName: store?.name ?? String(storeId), cityName: store?.cityName || '—', - submitter: submitterLabel, + submitter, packageCount: String(packages.length), requestId: req.id.toString(), }, diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts index f79d8a5..488c34d 100644 --- a/server/dukang-api/src/modules/store/store.service.ts +++ b/server/dukang-api/src/modules/store/store.service.ts @@ -24,6 +24,7 @@ import { normalizeTestPhone, } from '../../common/test-whitelist/test-whitelist.service'; import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; +import { formatPartnerWecomLabel } from './wecom-submitter-label'; function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number { const toRad = (d: number) => (d * Math.PI) / 180; @@ -76,25 +77,29 @@ export class StoreService { private readonly wecomPush: WecomMessagePushService, ) {} - /** 门店进入 PENDING 时通知企微(失败不挡业务) */ + /** 门店进入 PENDING 或 HQ 新建时通知企微(失败不挡业务) */ private notifyStoreAuditPending(opts: { storeId: bigint; storeName: string; cityName?: string | null; partnerLabel?: string | null; + submitter?: string | null; submitType: '新建' | '重提'; + handlePath?: string; }) { void this.wecomPush.dispatchEvent( 'store.audit_pending', { storeName: opts.storeName, cityName: opts.cityName || '—', + submitter: opts.submitter || opts.partnerLabel || '—', partnerLabel: opts.partnerLabel || '—', action: opts.submitType, storeId: opts.storeId.toString(), }, { - handlePath: `/stores?auditStatus=PENDING&storeId=${opts.storeId.toString()}`, + handlePath: + opts.handlePath || `/stores?auditStatus=PENDING&storeId=${opts.storeId.toString()}`, }, ); } @@ -587,15 +592,16 @@ export class StoreService { }); if (!this.config.autoApproveStore) { - const partner = await this.prisma.partnerAccount.findUnique({ - where: { id: primaryId }, - select: { name: true, phone: true }, - }); + const [submitter, partnerLabel] = await Promise.all([ + formatPartnerWecomLabel(this.prisma, partnerAccountId), + formatPartnerWecomLabel(this.prisma, primaryId), + ]); this.notifyStoreAuditPending({ storeId: store.id, storeName: store.name, cityName: store.cityName, - partnerLabel: partner?.name || partner?.phone || String(primaryId), + submitter, + partnerLabel, submitType: '新建', }); } @@ -757,15 +763,16 @@ export class StoreService { remark: '合伙人修改资料后重新提交审核', }, }); - const partner = await this.prisma.partnerAccount.findUnique({ - where: { id: primaryId }, - select: { name: true, phone: true }, - }); + const [submitter, partnerLabel] = await Promise.all([ + formatPartnerWecomLabel(this.prisma, partnerAccountId), + formatPartnerWecomLabel(this.prisma, primaryId), + ]); this.notifyStoreAuditPending({ storeId: updated.id, storeName: updated.name, cityName: updated.cityName, - partnerLabel: partner?.name || partner?.phone || String(primaryId), + submitter, + partnerLabel, submitType: '重提', }); } @@ -882,15 +889,16 @@ export class StoreService { remark: '合伙人重新上传资料后重新提交审核', }, }); - const partner = await this.prisma.partnerAccount.findUnique({ - where: { id: primaryId }, - select: { name: true, phone: true }, - }); + const [submitter, partnerLabel] = await Promise.all([ + formatPartnerWecomLabel(this.prisma, partnerAccountId), + formatPartnerWecomLabel(this.prisma, primaryId), + ]); this.notifyStoreAuditPending({ storeId: store.id, storeName: store.name, cityName: store.cityName, - partnerLabel: partner?.name || partner?.phone || String(primaryId), + submitter, + partnerLabel, submitType: '重提', }); } diff --git a/server/dukang-api/src/modules/store/wecom-submitter-label.ts b/server/dukang-api/src/modules/store/wecom-submitter-label.ts new file mode 100644 index 0000000..38cfea6 --- /dev/null +++ b/server/dukang-api/src/modules/store/wecom-submitter-label.ts @@ -0,0 +1,58 @@ +import type { PrismaClient } from '@prisma/client'; + +export function joinCompanyAndName( + company?: string | null, + name?: string | null, + fallback = '—', +): string { + const person = (name || '').trim() || fallback; + const org = (company || '').trim(); + return org ? `${org} · ${person}` : person; +} + +export async function formatPartnerWecomLabel( + prisma: PrismaClient, + accountId: bigint, +): Promise { + const acc = await prisma.partnerAccount.findUnique({ + where: { id: accountId }, + select: { + name: true, + phone: true, + companyName: true, + isPrimary: true, + parentAccountId: true, + }, + }); + if (!acc) return '—'; + let company = acc.companyName?.trim() || ''; + if (!company && acc.isPrimary !== 1 && acc.parentAccountId) { + const primary = await prisma.partnerAccount.findUnique({ + where: { id: acc.parentAccountId }, + select: { companyName: true }, + }); + company = primary?.companyName?.trim() || ''; + } + const name = acc.name?.trim() || acc.phone || String(accountId); + return joinCompanyAndName(company, name, String(accountId)); +} + +export async function formatHqWecomLabel(prisma: PrismaClient, hqAccountId: bigint): Promise { + const hq = await prisma.hqAccount.findUnique({ + where: { id: hqAccountId }, + select: { name: true }, + }); + return hq?.name?.trim() || 'HQ'; +} + +export async function formatShopWecomLabel( + prisma: PrismaClient, + storeAccountId: bigint, +): Promise { + const acc = await prisma.storeAccount.findUnique({ + where: { id: storeAccountId }, + select: { name: true, phone: true }, + }); + const name = acc?.name?.trim() || acc?.phone || String(storeAccountId); + return `门店 · ${name}`; +} diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index 0c94961..560bda6 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -10,6 +10,7 @@ import type { FreightPayType } from '@prisma/client'; import { calcBenefitAmount, generateOrderNo, + maskContactPhone, minBottleQtyForDelivery, orderTabToStatuses, toMinSaleQuantity, @@ -501,10 +502,22 @@ export class TradeService { if (!order) return; if (!order.isTest) { - const skuSummary = `${order.productName}×${order.quantity}`; - const phone = order.user?.phone || ''; - const phoneMasked = - phone.length >= 7 ? `${phone.slice(0, 3)}****${phone.slice(-4)}` : phone || '—'; + const unit = order.saleUnit === 'BOX' ? '箱' : '瓶'; + const spec = (order.productSpec || '').trim(); + const skuSummary = spec + ? `${order.productName} ${spec} ${unit}×${order.quantity}` + : `${order.productName} ${unit}×${order.quantity}`; + const deliveryLabel = + order.deliveryType === 'ON_SITE_PICKUP' + ? '现场提货' + : order.deliveryType === 'CROSS_CITY' + ? '跨城' + : '同城'; + const receiverInfo = + order.deliveryType === 'ON_SITE_PICKUP' + ? `${order.receiverName} ${order.receiverPhone}`.trim() + : `${order.receiverName} ${order.receiverPhone} ${order.receiverAddress}`.trim(); + const phoneMasked = maskContactPhone(order.user?.phone || order.receiverPhone); void this.wecomPush.dispatchEvent( 'order.paid', { @@ -512,6 +525,8 @@ export class TradeService { payAmount: Number(order.payAmount).toFixed(2), cityName: order.city?.name || '—', skuSummary, + deliveryLabel, + receiverInfo, phoneMasked, }, { handlePath: `/orders?orderNo=${encodeURIComponent(order.orderNo)}` },