diff --git a/docs/杜康好客-v3-PRD.md b/docs/杜康好客-v3-PRD.md index 5dfd823..2c27406 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) | | 3.5.12 | 08-26 | 发布会订单大屏循环 BGM;HQ 日志/订单状态流转/用户行为时间线展示中文;修复删除门店分类被默认树回种;HQ 侧栏顺序(业务前 11 项,系统设置置底) | [`v3.5.12`](./杜康好客-v3.5.12-开发文档.md) | | 3.5.14 | 08-26 | 修复 C 端提交订单/支付成功日志误标 H5;线上 `order_submit`/`pay_success` 回填为小程序 | [`v3.5.14`](./杜康好客-v3.5.14-开发文档.md) | diff --git a/docs/杜康好客-v3-现状对照.md b/docs/杜康好客-v3-现状对照.md index c1e4994..da07916 100644 --- a/docs/杜康好客-v3-现状对照.md +++ b/docs/杜康好客-v3-现状对照.md @@ -73,7 +73,7 @@ |------|------| | 2026-08-26 | v3.5.14:`order_submit`/`pay_success` 埋点改用真实 `clientApp`;线上这两类 `USER_H5` 回填为 `USER_MINI` | | 2026-08-26 | v3.5.12:订单大屏循环 BGM;HQ 日志/订单状态流转/用户行为时间线英文码改中文;修复删除门店分类后被 `ensureDefaults` 回种;HQ 侧栏按业务前 11 项重排、系统设置置底 | -| 2026-08-26 | v3.5.11:城市三种履约起购;企微补提交人/订单规格物流收货/核销用户;`alert.settlement` 改名「结算任务失败通知」归入系统监控;补提现通过与四账单 | +| 2026-08-26 | v3.5.11:城市三种履约起购;企微补提交人/订单规格物流收货/核销用户(昵称+明文手机+HQ备注);`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 028e2f6..13c995b 100644 --- a/docs/杜康好客-v3.5.11-开发文档.md +++ b/docs/杜康好客-v3.5.11-开发文档.md @@ -13,7 +13,7 @@ | 2 | 现场提货 | 体验 | 默认数量对齐起购;文案用销售单位 | | 3 | 承运商 | 文案 | 「起送瓶数」标明不影响用户起购 | | 4 | 企微提交人 | 优化 | 门店新建/信息变更/套餐待审显示提交人名称 | -| 5 | 成交播报 | 优化 | 支付成功补规格/物流/收货;核销成功补用户 | +| 5 | 成交播报 | 优化 | 支付成功补规格/物流/收货;支付与核销补用户昵称、明文手机、HQ 备注 | | 6 | 结算通知 | 需求 | `alert.settlement` 改名为「结算任务失败通知」并归入系统监控;提现通过 + 财务四账单(含收款账户) | **不做**:起购迁到承运商;多城按地址匹配起购(仍读当前 ACTIVE 城市);HQ 直改已营业门店不发待审通知。 @@ -63,11 +63,12 @@ - 商品:`{名称} {规格} {瓶\|箱}×{数量}` - 物流:现场提货 / 同城 / 跨城 - 收货:提货=姓名+手机;同城/跨城=姓名+手机+详细地址 +- 用户:昵称、手机号(明文)、HQ 备注 `hq_remark`(空则 —) - 测试单不推 ### 4.3 核销成功 `redeem.success` -补 `userName`、`userPhone`(脱敏,与支付播报一致)。 +补 `userName`、`userPhone`(明文)、`userRemark`(HQ 用户备注,空则 —)。 --- @@ -84,7 +85,7 @@ | `finance.winery_bill` | 酒厂日账单未 skip | 系统配置酒厂账户 | | `finance.logistics_bill` | 物流月账成功且未 skip | 承运商卡 | -账单一次摘要(多店多行,最多 15 行);缺卡写「未登记收款账户」;内部群账号不脱敏。`created=0` / `skipped` 不推。 +账单一次摘要:抬头含账期 / 账单笔数 / 累计金额;每笔分块写城市、门店或承运商、合伙人(公司-姓名)、核销/订单笔数、账单金额、收款人 / 账户 / 开户行。缺卡写「未登记」。内部群账号不脱敏。`created=0` / `skipped` 不推。正文过长时截断并提示到 HQ 查看。 启动 upsert「结算通知群」(占位 webhook + 禁用)。已有「运营告警」若勾了 `alert.settlement`,会收到任务失败/超时,**不会**自动勾上 5 个新业务事件。 @@ -95,8 +96,8 @@ - [ ] 城市三字段起购;提货默认数量对齐;承运商起送不影响下单 - [ ] 合伙人新建/信息变更/套餐:企微可见「企业 · 姓名」 - [ ] 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 index cedee0d..ea192f1 100644 --- a/packages/shared-types/src/wecom-message-push.test.ts +++ b/packages/shared-types/src/wecom-message-push.test.ts @@ -4,6 +4,7 @@ import { WECOM_PUSH_CONDITION_LABELS, WECOM_PUSH_DEFAULT_SETTLEMENT_CONDITIONS, WECOM_SETTLEMENT_PUSH_NAME, + WECOM_TEMPLATE_PLACEHOLDERS, parseWecomPushConditions, } from './wecom-message-push'; @@ -33,4 +34,22 @@ describe('wecom-message-push', () => { 'finance.store_bill', ]); }); + + it('支付与核销模板含昵称、明文手机、备注占位符', () => { + expect(WECOM_TEMPLATE_PLACEHOLDERS['order.paid']).toEqual( + expect.arrayContaining(['userName', 'userPhone', 'userRemark']), + ); + expect(WECOM_TEMPLATE_PLACEHOLDERS['redeem.success']).toEqual( + expect.arrayContaining(['userName', 'userPhone', 'userRemark']), + ); + }); + + it('结算账单模板含累计金额与明细摘要', () => { + expect(WECOM_TEMPLATE_PLACEHOLDERS['finance.store_bill']).toEqual( + expect.arrayContaining(['billCount', 'totalAmount', 'billSummary']), + ); + expect(WECOM_TEMPLATE_PLACEHOLDERS['store.withdraw_approved']).toEqual( + expect.arrayContaining(['cityName', 'partnerLabel', 'payee', 'accountNo', 'bankBranch']), + ); + }); }); diff --git a/packages/shared-types/src/wecom-message-push.ts b/packages/shared-types/src/wecom-message-push.ts index e2bde04..ad49265 100644 --- a/packages/shared-types/src/wecom-message-push.ts +++ b/packages/shared-types/src/wecom-message-push.ts @@ -197,6 +197,9 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record> = { +/** 上一版默认正文;ensureTemplates 仅在库中仍为此文案时升级,不覆盖 HQ 手改 */ +export const WECOM_PUSH_TEMPLATE_LEGACY_BODIES: Partial> = { 'order.paid': [ - '**订单支付成功**', - '订单号:{{orderNo}}', - '实付:¥{{payAmount}}', - '城市:{{cityName}}', - '商品:{{skuSummary}}', - '用户:{{phoneMasked}}', - '时间:{{time}}', - '[{{handleLabel}}]({{handleUrl}})', - ].join('\n'), + [ + '**订单支付成功**', + '订单号:{{orderNo}}', + '实付:¥{{payAmount}}', + '城市:{{cityName}}', + '商品:{{skuSummary}}', + '用户:{{phoneMasked}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + [ + '**订单支付成功**', + '订单号:{{orderNo}}', + '实付:¥{{payAmount}}', + '城市:{{cityName}}', + '商品:{{skuSummary}}', + '物流:{{deliveryLabel}}', + '收货:{{receiverInfo}}', + '用户:{{phoneMasked}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + ], 'redeem.success': [ - '**门店核销成功**', - '核销单号:{{redeemNo}}', - '金额:¥{{amount}}', - '门店:{{storeName}}', - '渠道:{{channel}}', - '时间:{{time}}', - '[{{handleLabel}}]({{handleUrl}})', - ].join('\n'), + [ + '**门店核销成功**', + '核销单号:{{redeemNo}}', + '金额:¥{{amount}}', + '门店:{{storeName}}', + '渠道:{{channel}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + [ + '**门店核销成功**', + '核销单号:{{redeemNo}}', + '金额:¥{{amount}}', + '门店:{{storeName}}', + '渠道:{{channel}}', + '用户:{{userName}} {{userPhone}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + ], 'store.audit_pending': [ - '**门店审核待处理 · {{action}}**', - '门店:{{storeName}}', - '城市:{{cityName}}', - '合伙人:{{partnerLabel}}', - '时间:{{time}}', - '[{{handleLabel}}]({{handleUrl}})', - ].join('\n'), + [ + '**门店审核待处理 · {{action}}**', + '门店:{{storeName}}', + '城市:{{cityName}}', + '合伙人:{{partnerLabel}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + ], 'store.package_audit_pending': [ - '**套餐变更待审核**', - '门店:{{storeName}}', - '城市:{{cityName}}', - '提交端:{{submitter}}', - '套餐条数:{{packageCount}}', - '时间:{{time}}', - '[{{handleLabel}}]({{handleUrl}})', - ].join('\n'), + [ + '**套餐变更待审核**', + '门店:{{storeName}}', + '城市:{{cityName}}', + '提交端:{{submitter}}', + '套餐条数:{{packageCount}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + ], 'store.info_change_pending': [ - '**门店信息变更待审**', - '门店:{{storeName}}', - '城市:{{cityName}}', - '提交端:{{submitter}}', - '变更字段:{{changedFields}}', - '时间:{{time}}', - '[{{handleLabel}}]({{handleUrl}})', - ].join('\n'), + [ + '**门店信息变更待审**', + '门店:{{storeName}}', + '城市:{{cityName}}', + '提交端:{{submitter}}', + '变更字段:{{changedFields}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + ], + 'store.withdraw_approved': [ + [ + '**门店提现已通过**', + '门店:{{storeName}}', + '提现单号:{{withdrawNo}}', + '金额:¥{{amount}}', + '收款账户:{{bankAccount}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + ], + 'finance.store_bill': [ + [ + '**门店日账单已生成**', + '账期:{{period}}', + '笔数:{{billCount}}', + '{{billSummary}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + ], + 'finance.partner_bill': [ + [ + '**合伙人月账单已生成**', + '账期:{{period}}', + '笔数:{{billCount}}', + '{{billSummary}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + ], + 'finance.winery_bill': [ + [ + '**酒厂日账单已生成**', + '账期:{{period}}', + '订单数:{{orderCount}}', + '金额:¥{{amount}}', + '收款账户:{{bankAccount}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + ], + 'finance.logistics_bill': [ + [ + '**物流月对账已生成**', + '账期:{{period}}', + '笔数:{{billCount}}', + '{{billSummary}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + ], }; export function getDefaultTemplate(eventKey: string): WecomTemplateDefault | undefined { diff --git a/server/dukang-api/src/modules/redeem/redeem.service.ts b/server/dukang-api/src/modules/redeem/redeem.service.ts index dd4dd30..33f92e9 100644 --- a/server/dukang-api/src/modules/redeem/redeem.service.ts +++ b/server/dukang-api/src/modules/redeem/redeem.service.ts @@ -8,7 +8,6 @@ import { calcRedeemSettleAmount, generateRedeemNo, generateRedeemPendingNo, - maskContactPhone, REDEEM_WEAKNET_FAIL_THRESHOLD, resolveSettlementRate, validateRedeemAmount, @@ -197,7 +196,7 @@ export class RedeemService { const [userRow, storeRow] = await Promise.all([ this.prisma.user.findUnique({ where: { id: userId }, - select: { isTest: true, nickname: true, phone: true }, + select: { isTest: true, nickname: true, phone: true, hqRemark: true }, }), this.prisma.store.findUnique({ where: { id: account.storeId }, @@ -295,7 +294,8 @@ export class RedeemService { storeName: account.store.name || String(account.storeId), channel: channelLabel, userName: userRow?.nickname?.trim() || '—', - userPhone: maskContactPhone(userRow?.phone), + userPhone: (userRow?.phone || '').trim() || '—', + userRemark: (userRow?.hqRemark || '').trim() || '—', }, { 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 4aa477e..263b5b7 100644 --- a/server/dukang-api/src/modules/settlement/settlement.service.ts +++ b/server/dukang-api/src/modules/settlement/settlement.service.ts @@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client'; import { DEFAULT_STORE_WITHDRAW_DAILY_LIMIT, DEFAULT_XFX_LOGISTICS_PRICING, + LOGISTICS_SETTLEMENT_METHOD_LABELS, WINERY_SETTLEMENT_LAG_DAYS, WINERY_SETTLEMENT_RATE, } from '@dukang/shared-types'; @@ -28,6 +29,17 @@ import { loadWineryBankConfig, formatWecomBankAccount, } from '../../common/store/store-bank.util'; +import { + capBillBlocks, + formatLogisticsBillWecomBlock, + formatPartnerBillWecomBlock, + formatPartnerDashLabel, + formatPercent, + formatStoreBillWecomBlock, + joinLimited, + wecomBankParts, + type WecomBankLike, +} from './wecom-bill-digest'; function generateBillNo(prefix: string) { return `${prefix}${Date.now()}${Math.floor(Math.random() * 900 + 100)}`; @@ -63,12 +75,37 @@ function round2(n: number) { return Math.round(n * 100) / 100; } -const BILL_DIGEST_MAX_LINES = 15; +function sumAmounts(rows: Array<{ amount: number }>): string { + return rows.reduce((s, r) => s + Number(r.amount || 0), 0).toFixed(2); +} -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 storeWecomMeta( + store: + | { + name?: string | null; + cityName?: string | null; + cityRef?: { name: string } | null; + partnerAccount?: { + name: string | null; + companyName: string | null; + parent?: { companyName: string | null } | null; + } | null; + } + | null + | undefined, + fallbackName: string, +) { + const cityName = store?.cityRef?.name?.trim() || store?.cityName?.trim() || '—'; + const acc = store?.partnerAccount; + const partnerLabel = formatPartnerDashLabel( + acc?.companyName || acc?.parent?.companyName, + acc?.name, + ); + return { + storeName: store?.name?.trim() || fallbackName, + cityName, + partnerLabel, + }; } function getStoreWithdrawDailyLimit(): number { @@ -107,24 +144,25 @@ export class SettlementService { private notifyPartnerBillDigest( period: string, rows: Array<{ - label: string; + cityName: string; + partnerLabel: string; + orderCount?: number; + redeemCount?: number; + orderCommission: number; + redeemCommission: number; amount: number; - bankAccountName?: string | null; - bankBranch?: string | null; - bankAccountNo?: string | null; + bank?: WecomBankLike | null; }>, ) { if (!rows.length) return; - const lines = rows.map( - (r) => - `${r.label} ¥${Number(r.amount).toFixed(2)} ${formatWecomBankAccount(r)}`, - ); + const blocks = rows.map((r) => formatPartnerBillWecomBlock(r)); void this.wecomPush.dispatchEvent( 'finance.partner_bill', { period, billCount: String(rows.length), - billSummary: capBillSummary(lines), + totalAmount: sumAmounts(rows), + billSummary: capBillBlocks(blocks), }, { handlePath: '/finance/partner-bills' }, ); @@ -133,25 +171,25 @@ export class SettlementService { private notifyLogisticsBillDigest( period: string, rows: Array<{ - label: string; + cityNames: string; + partnerNames: string; + providerName: string; + settlementMethod: string; + orderCount: number; + bottleCount: number; amount: number; - bankAccountName?: string | null; - bankName?: string | null; - bankBranch?: string | null; - bankAccountNo?: string | null; + bank?: WecomBankLike | null; }>, ) { if (!rows.length) return; - const lines = rows.map( - (r) => - `${r.label} ¥${Number(r.amount).toFixed(2)} ${formatWecomBankAccount(r)}`, - ); + const blocks = rows.map((r) => formatLogisticsBillWecomBlock(r)); void this.wecomPush.dispatchEvent( 'finance.logistics_bill', { period, billCount: String(rows.length), - billSummary: capBillSummary(lines), + totalAmount: sumAmounts(rows), + billSummary: capBillBlocks(blocks), }, { handlePath: '/finance/logistics-bills' }, ); @@ -649,16 +687,34 @@ export class SettlementService { const [store, bank] = await Promise.all([ this.prisma.store.findUnique({ where: { id: row.storeId }, - select: { name: true }, + select: { + name: true, + cityName: true, + cityRef: { select: { name: true } }, + partnerAccount: { + select: { + name: true, + companyName: true, + parent: { select: { companyName: true } }, + }, + }, + }, }), loadStorePrimaryBank(this.prisma, row.storeId), ]); + const meta = storeWecomMeta(store, String(row.storeId)); + const bankParts = wecomBankParts(bank); void this.wecomPush.dispatchEvent( 'store.withdraw_approved', { - storeName: store?.name || String(row.storeId), + cityName: meta.cityName, + storeName: meta.storeName, + partnerLabel: meta.partnerLabel, withdrawNo: row.withdrawNo, amount: Number(row.amount).toFixed(2), + payee: bankParts.payee, + accountNo: bankParts.accountNo, + bankBranch: bankParts.bankBranch, bankAccount: formatWecomBankAccount(bank), storeId: row.storeId.toString(), }, @@ -930,7 +986,24 @@ export class SettlementService { withdrawItem: { is: null }, status: 'PENDING', }, - include: { store: { select: { id: true, name: true, settlementRate: true } } }, + include: { + store: { + select: { + id: true, + name: true, + cityName: true, + settlementRate: true, + cityRef: { select: { name: true } }, + partnerAccount: { + select: { + name: true, + companyName: true, + parent: { select: { companyName: true } }, + }, + }, + }, + }, + }, }); const byStore = new Map(); @@ -943,10 +1016,19 @@ export class SettlementService { let created = 0; let skipped = 0; - const createdBills: Array<{ storeId: bigint; storeName: string; amount: number }> = []; + const createdBills: Array<{ + storeId: bigint; + storeName: string; + cityName: string; + partnerLabel: string; + redeemCount: number; + redeemAmount: number; + settlementRate: number; + amount: number; + }> = []; for (const [storeIdStr, list] of byStore) { const storeId = BigInt(storeIdStr); - const storeName = list[0]?.store?.name || storeIdStr; + const meta = storeWecomMeta(list[0]?.store, storeIdStr); const existing = await this.prisma.storeBill.findUnique({ where: { storeId_billDate: { storeId, billDate } }, }); @@ -975,7 +1057,14 @@ export class SettlementService { }); }); created += 1; - createdBills.push({ storeId, storeName, amount: updatedTotal }); + createdBills.push({ + storeId, + ...meta, + redeemCount: existing.redeemCount + list.length, + redeemAmount: round2(Number(existing.redeemAmount) + redeemAmount), + settlementRate: rate, + amount: updatedTotal, + }); continue; } @@ -1001,7 +1090,14 @@ export class SettlementService { }); }); created += 1; - createdBills.push({ storeId, storeName, amount: payoutAmount }); + createdBills.push({ + storeId, + ...meta, + redeemCount: list.length, + redeemAmount, + settlementRate: rate, + amount: payoutAmount, + }); } const period = billDate.toISOString().slice(0, 10); @@ -1010,16 +1106,19 @@ export class SettlementService { 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)}`, + const blocks = createdBills.map((b) => + formatStoreBillWecomBlock({ + ...b, + bank: banks.get(b.storeId.toString()) ?? null, + }), ); void this.wecomPush.dispatchEvent( 'finance.store_bill', { period, billCount: String(createdBills.length), - billSummary: capBillSummary(lines), + totalAmount: sumAmounts(createdBills), + billSummary: capBillBlocks(blocks), }, { handlePath: '/finance/store-bills?kind=T1_BILL' }, ); @@ -1476,15 +1575,19 @@ export class SettlementService { bankAccountName: true, bankBranch: true, bankAccountNo: true, + city: { select: { name: true } }, }, }); const results: Array<{ partnerId: string; companyName: string; ok: boolean; message?: string }> = []; const created: Array<{ - label: string; + cityName: string; + partnerLabel: string; + orderCount?: number; + redeemCount?: number; + orderCommission: number; + redeemCommission: number; amount: number; - bankAccountName: string | null; - bankBranch: string | null; - bankAccountNo: string | null; + bank?: WecomBankLike | null; }> = []; for (const p of partners) { try { @@ -1497,14 +1600,19 @@ export class SettlementService { { 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, + cityName: p.city?.name?.trim() || '—', + partnerLabel: formatPartnerDashLabel(p.companyName, p.name), + orderCount: Number(bill.orderCount ?? 0), + redeemCount: Number(bill.redeemCount ?? 0), + orderCommission: Number(bill.orderCommission), + redeemCommission: Number(bill.redeemCommission), amount: Number(bill.totalAmount), - bankAccountName: p.bankAccountName, - bankBranch: p.bankBranch, - bankAccountNo: p.bankAccountNo, + bank: { + bankAccountName: p.bankAccountName, + bankBranch: p.bankBranch, + bankAccountNo: p.bankAccountNo, + }, }); } catch (e) { results.push({ @@ -1628,24 +1736,37 @@ 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, + let cityName = '—'; + if (primary.cityId) { + const city = await this.prisma.commonCity.findUnique({ + where: { id: primary.cityId }, + select: { name: true }, + }); + cityName = city?.name?.trim() || '—'; + } + this.notifyPartnerBillDigest(`${body.year}-${String(body.month).padStart(2, '0')}`, [ + { + cityName, + partnerLabel: formatPartnerDashLabel(primary.companyName, primary.name), + orderCount: orders.length, + redeemCount: redeems.length, + orderCommission: round2(orderCommission), + redeemCommission: round2(redeemCommission), + amount: totalAmount, + bank: { bankAccountName: primary.bankAccountName, bankBranch: primary.bankBranch, bankAccountNo: primary.bankAccountNo, }, - ], - ); + }, + ]); } - return serializeBigInt(bill); + return serializeBigInt({ + ...bill, + orderCount: orders.length, + redeemCount: redeems.length, + }); } /** HQ:发送给合伙人(待审核 → 待合伙人确认) */ @@ -1880,6 +2001,7 @@ export class SettlementService { deliveryType: { in: ['LOCAL', 'CROSS_CITY'] }, completedAt: { gte: start, lt: end }, }, + include: { city: { select: { name: true } } }, orderBy: { completedAt: 'asc' }, }); @@ -1937,12 +2059,27 @@ export class SettlementService { const period = billDate.toISOString().slice(0, 10); const wineryBank = await loadWineryBankConfig(this.prisma); + const bankParts = wecomBankParts(wineryBank); + const cityNames = joinLimited( + orders.map((o) => o.city?.name || ''), + '城', + ); + const partnerNames = await this.partnerDashLabels(orders.map((o) => o.partnerAccountIdAtPay)); void this.wecomPush.dispatchEvent( 'finance.winery_bill', { period, - amount: wineryAmount.toFixed(2), + billCount: '1', + totalAmount: wineryAmount.toFixed(2), + cityNames, + partnerNames, orderCount: String(orders.length), + orderAmount: orderAmount.toFixed(2), + rate: formatPercent(rate), + amount: wineryAmount.toFixed(2), + payee: bankParts.payee, + accountNo: bankParts.accountNo, + bankBranch: bankParts.bankBranch, bankAccount: formatWecomBankAccount(wineryBank), }, { handlePath: '/finance/winery-bills' }, @@ -2171,12 +2308,14 @@ export class SettlementService { billId?: string; }> = []; const created: Array<{ - label: string; + cityNames: string; + partnerNames: string; + providerName: string; + settlementMethod: string; + orderCount: number; + bottleCount: number; amount: number; - bankAccountName: string | null; - bankName: string | null; - bankBranch: string | null; - bankAccountNo: string | null; + bank?: WecomBankLike | null; }> = []; for (const p of providers) { @@ -2203,15 +2342,8 @@ 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, - }); + if (!bill.skipped && bill.wecom) { + created.push(bill.wecom); } } catch (e) { results.push({ @@ -2237,6 +2369,20 @@ export class SettlementService { }; } + private async partnerDashLabels(ids: Array, max = 5): Promise { + const uniq = [...new Set(ids.filter((id): id is bigint => id != null))]; + if (!uniq.length) return '—'; + const partners = await this.prisma.partnerAccount.findMany({ + where: { id: { in: uniq } }, + select: { name: true, companyName: true }, + }); + return joinLimited( + partners.map((p) => formatPartnerDashLabel(p.companyName, p.name)), + '家', + max, + ); + } + async generateLogisticsBill( body: { providerId: string; year: number; month: number }, opts?: { notify?: boolean }, @@ -2291,6 +2437,8 @@ export class SettlementService { quantity: true, deliveryType: true, payStatus: true, + partnerAccountIdAtPay: true, + city: { select: { name: true } }, }, }, }, @@ -2392,25 +2540,38 @@ export class SettlementService { return header; }); + const wecom = { + cityNames: joinLimited( + eligible.map((d) => d.order.city?.name || ''), + '城', + ), + partnerNames: await this.partnerDashLabels(eligible.map((d) => d.order.partnerAccountIdAtPay)), + providerName: provider.name, + settlementMethod: + LOGISTICS_SETTLEMENT_METHOD_LABELS[ + provider.settlementMethod as keyof typeof LOGISTICS_SETTLEMENT_METHOD_LABELS + ] ?? String(provider.settlementMethod), + orderCount, + bottleCount, + amount: Number(bill.logisticsAmount), + bank: { + bankAccountName: provider.bankAccountName, + bankName: provider.bankName, + bankBranch: provider.bankBranch, + bankAccountNo: provider.bankAccountNo, + }, + }; + 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, - }, - ], - ); + this.notifyLogisticsBillDigest(`${body.year}-${String(body.month).padStart(2, '0')}`, [ + wecom, + ]); } return { skipped: false, bill: serializeBigInt(bill), + wecom, }; } diff --git a/server/dukang-api/src/modules/settlement/wecom-bill-digest.ts b/server/dukang-api/src/modules/settlement/wecom-bill-digest.ts new file mode 100644 index 0000000..4665c54 --- /dev/null +++ b/server/dukang-api/src/modules/settlement/wecom-bill-digest.ts @@ -0,0 +1,157 @@ +/** 企微结算账单正文:分字段块 + 字节上限(机器人 markdown 约 4096 字节) */ + +export type WecomBankLike = { + bankAccountName?: string | null; + bankName?: string | null; + bankBranch?: string | null; + bankAccountNo?: string | null; +}; + +export function formatYuan(n: number): string { + return `¥${Number(n).toFixed(2)}`; +} + +export function formatPercent(rate: number): string { + const p = Number(rate) * 100; + if (!Number.isFinite(p)) return '—'; + const rounded = Math.round(p * 100) / 100; + const text = Number.isInteger(rounded) ? String(rounded) : String(rounded); + return `${text}%`; +} + +export function formatPartnerDashLabel( + company?: string | null, + name?: string | null, +): string { + const person = (name || '').trim() || '—'; + const org = (company || '').trim(); + return org ? `${org}-${person}` : person; +} + +export function wecomBankParts(bank?: WecomBankLike | null): { + payee: string; + accountNo: string; + bankBranch: string; +} { + if (!bank) return { payee: '未登记', accountNo: '—', bankBranch: '—' }; + const payee = bank.bankAccountName?.trim() || ''; + const accountNo = bank.bankAccountNo?.trim() || ''; + const bankBranch = bank.bankName?.trim() || bank.bankBranch?.trim() || ''; + if (!payee && !accountNo) { + return { payee: '未登记', accountNo: '—', bankBranch: bankBranch || '—' }; + } + return { + payee: payee || '—', + accountNo: accountNo || '—', + bankBranch: bankBranch || '—', + }; +} + +export function formatWecomFieldBlock(rows: Array<[string, string]>): string { + return rows.map(([k, v]) => `${k}:${v}`).join('\n'); +} + +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 '—'; + if (uniq.length <= max) return uniq.join('、'); + return `${uniq.slice(0, max).join('、')} 等${uniq.length}${unit}`; +} + +const BILL_DIGEST_MAX_BYTES = 3200; + +export function capBillBlocks(blocks: string[]): string { + if (!blocks.length) return '无'; + const out: string[] = []; + let bytes = 0; + let shown = 0; + for (const block of blocks) { + const extra = (shown ? 2 : 0) + Buffer.byteLength(block, 'utf8'); + if (shown > 0 && bytes + extra > BILL_DIGEST_MAX_BYTES) { + out.push(`另有 ${blocks.length - shown} 笔未列出,请到 HQ 查看`); + break; + } + out.push(block); + bytes += extra; + shown += 1; + } + return out.join('\n\n'); +} + +export function formatStoreBillWecomBlock(row: { + cityName: string; + storeName: string; + partnerLabel: string; + redeemCount: number; + redeemAmount: number; + settlementRate: number; + amount: number; + bank?: WecomBankLike | null; +}): string { + const bank = wecomBankParts(row.bank); + return formatWecomFieldBlock([ + ['城市', row.cityName || '—'], + ['门店', row.storeName || '—'], + ['合伙人', row.partnerLabel || '—'], + ['核销笔数', String(row.redeemCount)], + ['核销金额', formatYuan(row.redeemAmount)], + ['结算比例', formatPercent(row.settlementRate)], + ['账单', formatYuan(row.amount)], + ['收款人', bank.payee], + ['收款账户', bank.accountNo], + ['收款开户行', bank.bankBranch], + ]); +} + +export function formatPartnerBillWecomBlock(row: { + cityName: string; + partnerLabel: string; + orderCount?: number; + redeemCount?: number; + orderCommission: 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( + ['订单佣金', formatYuan(row.orderCommission)], + ['核销佣金', formatYuan(row.redeemCommission)], + ['账单', formatYuan(row.amount)], + ['收款人', bank.payee], + ['收款账户', bank.accountNo], + ['收款开户行', bank.bankBranch], + ); + return formatWecomFieldBlock(rows); +} + +export function formatLogisticsBillWecomBlock(row: { + cityNames: string; + partnerNames: string; + providerName: string; + settlementMethod: string; + orderCount: number; + bottleCount: number; + amount: number; + bank?: WecomBankLike | null; +}): string { + const bank = wecomBankParts(row.bank); + return formatWecomFieldBlock([ + ['城市', row.cityNames || '—'], + ['合伙人', row.partnerNames || '—'], + ['承运商', row.providerName || '—'], + ['结算方式', row.settlementMethod || '—'], + ['发货笔数', String(row.orderCount)], + ['瓶数', String(row.bottleCount)], + ['账单', formatYuan(row.amount)], + ['收款人', bank.payee], + ['收款账户', bank.accountNo], + ['收款开户行', bank.bankBranch], + ]); +} diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index 95bdec8..5e5c580 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -10,7 +10,6 @@ import type { FreightPayType } from '@prisma/client'; import { calcBenefitAmount, generateOrderNo, - maskContactPhone, minBottleQtyForDelivery, orderTabToStatuses, toMinSaleQuantity, @@ -522,7 +521,7 @@ export class TradeService { where: { id: orderId }, include: { city: { select: { name: true } }, - user: { select: { phone: true } }, + user: { select: { phone: true, nickname: true, hqRemark: true } }, }, }); if (!order) return; @@ -543,7 +542,9 @@ export class TradeService { 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); + const userName = order.user?.nickname?.trim() || '—'; + const userPhone = (order.user?.phone || '').trim() || '—'; + const userRemark = (order.user?.hqRemark || '').trim() || '—'; void this.wecomPush.dispatchEvent( 'order.paid', { @@ -553,7 +554,10 @@ export class TradeService { skuSummary, deliveryLabel, receiverInfo, - phoneMasked, + userName, + userPhone, + userRemark, + phoneMasked: userPhone, }, { handlePath: `/orders?orderNo=${encodeURIComponent(order.orderNo)}` }, );