v3.5.11企业微信通知调整
CI / verify (pull_request) Waiting to run

This commit is contained in:
2026-08-26 09:55:19 +08:00
parent 632539e8dc
commit 3afbdfe6b9
17 changed files with 775 additions and 83 deletions
+1 -1
View File
@@ -30,7 +30,7 @@
| 3.5.8 | 08-24 | HQ 单账号追加/撤销权限;运营改客服修复;城市门店服务+城市范围 | [`v3.5.8`](./杜康好客-v3.5.8-开发文档.md) | | 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.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.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) |
--- ---
+2 -2
View File
@@ -65,11 +65,11 @@
| 3.5.8 | [`HQ 追加/撤销权限 + 运营改客服 + 城市门店服务`](./杜康好客-v3.5.8-开发文档.md) | 🔶 开发完成 | | 3.5.8 | [`HQ 追加/撤销权限 + 运营改客服 + 城市门店服务`](./杜康好客-v3.5.8-开发文档.md) | 🔶 开发完成 |
| 3.5.9 | [`门店累计核销 + HQ 去截断 + 列设置`](./杜康好客-v3.5.9-开发文档.md) | 🔶 开发完成 | | 3.5.9 | [`门店累计核销 + HQ 去截断 + 列设置`](./杜康好客-v3.5.9-开发文档.md) | 🔶 开发完成 |
| 3.5.10 | [`门店详情去分享按钮 + 同城提示改承运商 HTML + 小程序客服接企微`](./杜康好客-v3.5.10-开发文档.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.10:门店详情去掉分享按钮;同城送提示改承运商 HTML(`GET /catalog/local-deliveries`);小程序客服接通企微(需填 CorpID) |
| 2026-08-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置/列宽存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏;订单列表状态可多选 | | 2026-08-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置/列宽存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏;订单列表状态可多选 |
| 2026-08-24 | v3.5.8HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 | | 2026-08-24 | v3.5.8HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 |
+5 -5
View File
@@ -14,7 +14,7 @@
| 3 | 承运商 | 文案 | 「起送瓶数」标明不影响用户起购 | | 3 | 承运商 | 文案 | 「起送瓶数」标明不影响用户起购 |
| 4 | 企微提交人 | 优化 | 门店新建/信息变更/套餐待审显示提交人名称 | | 4 | 企微提交人 | 优化 | 门店新建/信息变更/套餐待审显示提交人名称 |
| 5 | 成交播报 | 优化 | 支付成功补规格/物流/收货;核销成功补用户 | | 5 | 成交播报 | 优化 | 支付成功补规格/物流/收货;核销成功补用户 |
| 6 | 结算通知 | 需求 | `alert.settlement` 改名;提现通过 + 财务四账单(含收款账户) | | 6 | 结算通知 | 需求 | `alert.settlement` 改名为「结算任务失败通知」并归入系统监控;提现通过 + 财务四账单(含收款账户) |
**不做**:起购迁到承运商;多城按地址匹配起购(仍读当前 ACTIVE 城市);HQ 直改已营业门店不发待审通知。 **不做**:起购迁到承运商;多城按地址匹配起购(仍读当前 ACTIVE 城市);HQ 直改已营业门店不发待审通知。
@@ -73,11 +73,11 @@
## 5. 结算通知 ## 5. 结算通知
条件 key 仍为 `alert.settlement`HQ 展示 **「结算通知」**(原「结算任务告警」)。分组从「系统与结算」拆为「系统监控」+「结算通知」。 条件 key 仍为 `alert.settlement`HQ 展示 **「结算任务失败通知」**(原「结算任务告警」),放在 **系统监控** 组。提现通过 / 四账单为独立条件,仍在「结算通知」
| key | 触发 | 收款账户 | | key | 触发 | 收款账户 |
|-----|------|----------| |-----|------|----------|
| `alert.settlement` | 账单定时任务失败、提现超时未审 | — | | `alert.settlement` | 结算任务失败通知(系统监控组):账单定时任务失败、提现超时未审 | — |
| `store.withdraw_approved` | 门店手动提现审核通过 | 门店主账号卡 | | `store.withdraw_approved` | 门店手动提现审核通过 | 门店主账号卡 |
| `finance.store_bill` | 门店日账单 `created>0` | 各店主账号卡 | | `finance.store_bill` | 门店日账单 `created>0` | 各店主账号卡 |
| `finance.partner_bill` | 合伙人月账单生成成功 | 合伙人卡 | | `finance.partner_bill` | 合伙人月账单生成成功 | 合伙人卡 |
@@ -97,6 +97,6 @@
- [ ] HQ 新建门店:企微可见 HQ 名称 - [ ] HQ 新建门店:企微可见 HQ 名称
- [ ] 支付成功:规格单位、物流方式、收货信息按提货/配送分支正确 - [ ] 支付成功:规格单位、物流方式、收货信息按提货/配送分支正确
- [ ] 核销成功:有用户昵称与脱敏手机 - [ ] 核销成功:有用户昵称与脱敏手机
- [ ] HQ 推送条件展示「结算通知」,不再出现「结算任务告警」 - [ ] HQ 推送条件展示「结算任务失败通知」,在系统监控组
- [ ] 账单任务失败 / 提现超时出现在勾了「结算通知」的群 - [ ] 账单任务失败 / 提现超时出现在勾了「结算任务失败通知」的群
- [ ] 提现通过、四类账单生成含收款账户;无账单不刷屏 - [ ] 提现通过、四类账单生成含收款账户;无账单不刷屏
@@ -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',
]);
});
});
+122 -6
View File
@@ -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 = [ export const WECOM_PUSH_CONDITIONS = [
'alert.ops', 'alert.ops',
'support_ticket.created', 'support_ticket.created',
@@ -11,9 +11,14 @@ export const WECOM_PUSH_CONDITIONS = [
'store.package_audit_pending', 'store.package_audit_pending',
'store.info_change_pending', 'store.info_change_pending',
'store.withdraw_pending', 'store.withdraw_pending',
'store.withdraw_approved',
'order.paid', 'order.paid',
'redeem.success', 'redeem.success',
'invoice.pending', 'invoice.pending',
'finance.store_bill',
'finance.partner_bill',
'finance.winery_bill',
'finance.logistics_bill',
] as const; ] as const;
export type WecomPushCondition = (typeof WECOM_PUSH_CONDITIONS)[number]; export type WecomPushCondition = (typeof WECOM_PUSH_CONDITIONS)[number];
@@ -24,15 +29,20 @@ export const WECOM_PUSH_CONDITION_LABELS: Record<WecomPushCondition, string> = {
'alert.pay': '支付异常', 'alert.pay': '支付异常',
'alert.redeem': '核销异常', 'alert.redeem': '核销异常',
'alert.system': '系统监控(5xx、回调、定时任务)', 'alert.system': '系统监控(5xx、回调、定时任务)',
'alert.settlement': '结算任务告警', 'alert.settlement': '结算任务失败通知',
'dev_plan.task_dispatch': '开发任务评审派发', 'dev_plan.task_dispatch': '开发任务评审派发',
'store.audit_pending': '门店提交/重提待审', 'store.audit_pending': '门店提交/重提待审',
'store.package_audit_pending': '套餐变更待审', 'store.package_audit_pending': '套餐变更待审',
'store.info_change_pending': '门店信息变更待审', 'store.info_change_pending': '门店信息变更待审',
'store.withdraw_pending': '门店手动提现待审', 'store.withdraw_pending': '门店手动提现待审',
'store.withdraw_approved': '门店手动提现已通过',
'order.paid': '订单支付成功', 'order.paid': '订单支付成功',
'redeem.success': '门店核销成功', 'redeem.success': '门店核销成功',
'invoice.pending': '发票申请待开票', 'invoice.pending': '发票申请待开票',
'finance.store_bill': '门店日账单已生成',
'finance.partner_bill': '合伙人月账单已生成',
'finance.winery_bill': '酒厂日账单已生成',
'finance.logistics_bill': '物流月对账已生成',
}; };
export const WECOM_PUSH_CONDITION_GROUPS: Array<{ export const WECOM_PUSH_CONDITION_GROUPS: Array<{
@@ -68,9 +78,20 @@ export const WECOM_PUSH_CONDITION_GROUPS: Array<{
}, },
{ {
key: 'system', key: 'system',
label: '系统与结算', label: '系统监控',
conditions: ['alert.system', 'alert.settlement'], 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', key: 'dev_plan',
label: '开发计划', label: '开发计划',
@@ -115,9 +136,20 @@ export const WECOM_PUSH_DEFAULT_DEAL_BROADCAST_CONDITIONS: WecomPushCondition[]
'redeem.success', '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_STORE_AUDIT_PUSH_NAME = '门店审核通知群';
export const WECOM_BIZ_TODO_PUSH_NAME = '业务待办通知群'; export const WECOM_BIZ_TODO_PUSH_NAME = '业务待办通知群';
export const WECOM_DEAL_BROADCAST_PUSH_NAME = '成交播报群'; export const WECOM_DEAL_BROADCAST_PUSH_NAME = '成交播报群';
export const WECOM_SETTLEMENT_PUSH_NAME = '结算通知群';
/** 占位 webhook(未配置真实 key 时禁用,避免误推) */ /** 占位 webhook(未配置真实 key 时禁用,避免误推) */
export const WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK = export const WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK =
@@ -131,7 +163,12 @@ export const WECOM_TEMPLATE_EVENT_KEYS = [
'store.package_audit_pending', 'store.package_audit_pending',
'store.info_change_pending', 'store.info_change_pending',
'store.withdraw_pending', 'store.withdraw_pending',
'store.withdraw_approved',
'invoice.pending', 'invoice.pending',
'finance.store_bill',
'finance.partner_bill',
'finance.winery_bill',
'finance.logistics_bill',
] as const; ] as const;
export type WecomTemplateEventKey = (typeof WECOM_TEMPLATE_EVENT_KEYS)[number]; export type WecomTemplateEventKey = (typeof WECOM_TEMPLATE_EVENT_KEYS)[number];
@@ -143,14 +180,50 @@ export const WECOM_TEMPLATE_EVENT_LABELS: Record<WecomTemplateEventKey, string>
'store.package_audit_pending': '套餐变更待审', 'store.package_audit_pending': '套餐变更待审',
'store.info_change_pending': '门店信息变更待审', 'store.info_change_pending': '门店信息变更待审',
'store.withdraw_pending': '门店手动提现待审', 'store.withdraw_pending': '门店手动提现待审',
'store.withdraw_approved': '门店手动提现已通过',
'invoice.pending': '发票申请待开票', 'invoice.pending': '发票申请待开票',
'finance.store_bill': '门店日账单已生成',
'finance.partner_bill': '合伙人月账单已生成',
'finance.winery_bill': '酒厂日账单已生成',
'finance.logistics_bill': '物流月对账已生成',
}; };
/** 各事件可用占位符说明(HQ 模板编辑) */ /** 各事件可用占位符说明(HQ 模板编辑) */
export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]> = { export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]> = {
'order.paid': ['orderNo', 'payAmount', 'cityName', 'skuSummary', 'phoneMasked', 'time', 'handleUrl', 'handleLabel'], 'order.paid': [
'redeem.success': ['redeemNo', 'amount', 'storeName', 'channel', 'time', 'handleUrl', 'handleLabel'], 'orderNo',
'store.audit_pending': ['storeName', 'cityName', 'partnerLabel', 'action', 'time', 'handleUrl', 'handleLabel', 'storeId'], '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': [ 'store.package_audit_pending': [
'storeName', 'storeName',
'cityName', 'cityName',
@@ -181,6 +254,16 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]
'handleLabel', 'handleLabel',
'storeId', 'storeId',
], ],
'store.withdraw_approved': [
'storeName',
'withdrawNo',
'amount',
'bankAccount',
'time',
'handleUrl',
'handleLabel',
'storeId',
],
'invoice.pending': [ 'invoice.pending': [
'invoiceNo', 'invoiceNo',
'orderNo', 'orderNo',
@@ -191,6 +274,39 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]
'handleUrl', 'handleUrl',
'handleLabel', 'handleLabel',
], ],
'finance.store_bill': [
'period',
'billCount',
'billSummary',
'time',
'handleUrl',
'handleLabel',
],
'finance.partner_bill': [
'period',
'billCount',
'billSummary',
'time',
'handleUrl',
'handleLabel',
],
'finance.winery_bill': [
'period',
'amount',
'orderCount',
'bankAccount',
'time',
'handleUrl',
'handleLabel',
],
'finance.logistics_bill': [
'period',
'billCount',
'billSummary',
'time',
'handleUrl',
'handleLabel',
],
}; };
export type WecomPushTemplateDto = { export type WecomPushTemplateDto = {
@@ -63,6 +63,20 @@ export async function loadStorePrimaryBanksMap(
return map; return map;
} }
export function formatWecomBankAccount(bank?: {
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
} | null): string {
if (!bank) return '未登记收款账户';
const name = bank.bankAccountName?.trim() || '';
const no = bank.bankAccountNo?.trim() || '';
if (!name && !no) return '未登记收款账户';
const bankLine = bank.bankName?.trim() || bank.bankBranch?.trim() || '';
return [name, bankLine, no].filter(Boolean).join(' / ');
}
export async function loadWineryBankConfig( export async function loadWineryBankConfig(
prisma: PrismaClient, prisma: PrismaClient,
): Promise<StoreBankAccountSnapshot & { bankName?: string | null }> { ): Promise<StoreBankAccountSnapshot & { bankName?: string | null }> {
@@ -7,7 +7,9 @@ import {
WECOM_PUSH_DEFAULT_BIZ_TODO_CONDITIONS, WECOM_PUSH_DEFAULT_BIZ_TODO_CONDITIONS,
WECOM_PUSH_DEFAULT_DEAL_BROADCAST_CONDITIONS, WECOM_PUSH_DEFAULT_DEAL_BROADCAST_CONDITIONS,
WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS, WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS,
WECOM_PUSH_DEFAULT_SETTLEMENT_CONDITIONS,
WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS, WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS,
WECOM_SETTLEMENT_PUSH_NAME,
WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK, WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK,
WECOM_STORE_AUDIT_PUSH_NAME, WECOM_STORE_AUDIT_PUSH_NAME,
WECOM_TEMPLATE_EVENT_KEYS, 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 { applyWecomAtMentionInContent } from '../../modules/dev-plan/dev-plan-wecom-mention.util';
import { import {
WECOM_PUSH_TEMPLATE_DEFAULTS, WECOM_PUSH_TEMPLATE_DEFAULTS,
WECOM_PUSH_TEMPLATE_LEGACY_BODIES,
buildHqHandleUrl, buildHqHandleUrl,
getDefaultTemplate, getDefaultTemplate,
renderWecomTemplate, renderWecomTemplate,
@@ -66,7 +69,7 @@ export class WecomMessagePushService implements OnModuleInit {
} }
} }
/** 表空时从 .env / 旧设置迁移;并确保门店审核 / 业务待办 / 成交播报 / 模板缺行 */ /** 表空时从 .env / 旧设置迁移;并确保门店审核 / 业务待办 / 成交播报 / 结算通知 / 模板缺行 */
async ensureDefaults(): Promise<void> { async ensureDefaults(): Promise<void> {
const count = await this.prisma.wecomMessagePush.count(); const count = await this.prisma.wecomMessagePush.count();
if (count === 0) { if (count === 0) {
@@ -141,6 +144,12 @@ export class WecomMessagePushService implements OnModuleInit {
30, 30,
process.env.WECOM_DEAL_BROADCAST_WEBHOOK_URL, 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(); await this.ensureTemplates();
} }
@@ -181,22 +190,36 @@ export class WecomMessagePushService implements OnModuleInit {
this.logger.log(`seeded wecom message push: ${name} (placeholder, disabled)`); this.logger.log(`seeded wecom message push: ${name} (placeholder, disabled)`);
} }
/** 插入缺失 eventKey,不覆盖已有文案 */ /** 插入缺失 eventKey;仅当 body 仍为上一版默认文案时升级,不覆盖 HQ 手改 */
async ensureTemplates(): Promise<void> { async ensureTemplates(): Promise<void> {
for (const def of WECOM_PUSH_TEMPLATE_DEFAULTS) { for (const def of WECOM_PUSH_TEMPLATE_DEFAULTS) {
const existing = await this.prisma.wecomPushTemplate.findUnique({ const existing = await this.prisma.wecomPushTemplate.findUnique({
where: { eventKey: def.eventKey }, where: { eventKey: def.eventKey },
}); });
if (existing) continue; if (!existing) {
await this.prisma.wecomPushTemplate.create({ await this.prisma.wecomPushTemplate.create({
data: { data: {
eventKey: def.eventKey, eventKey: def.eventKey,
title: def.title, title: def.title,
body: def.body, body: def.body,
handleLabel: def.handleLabel, handleLabel: def.handleLabel,
}, },
}); });
this.logger.log(`seeded wecom push template: ${def.eventKey}`); 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', orderNo: 'DK202608200001',
payAmount: '199.00', payAmount: '199.00',
cityName: '郑州', cityName: '郑州',
skuSummary: '杜康原浆 ×2', skuSummary: '杜康原浆 52度 瓶×2',
deliveryLabel: '同城',
receiverInfo: '张三 13800008000 河南省郑州市金水区示例路1号',
phoneMasked: '138****8000', phoneMasked: '138****8000',
}, },
handlePath: '/orders?orderNo=DK202608200001', handlePath: '/orders?orderNo=DK202608200001',
@@ -458,6 +483,8 @@ export class WecomMessagePushService implements OnModuleInit {
amount: '88.00', amount: '88.00',
storeName: '示例门店', storeName: '示例门店',
channel: '扫码', channel: '扫码',
userName: '好客用户',
userPhone: '138****8000',
}, },
handlePath: '/redeem-records?redeemNo=RD202608200001', handlePath: '/redeem-records?redeemNo=RD202608200001',
}, },
@@ -465,7 +492,8 @@ export class WecomMessagePushService implements OnModuleInit {
vars: { vars: {
storeName: '示例门店', storeName: '示例门店',
cityName: '郑州', cityName: '郑州',
partnerLabel: '示例合伙人', submitter: '郑州某某商贸 · 张三',
partnerLabel: '郑州某某商贸 · 张三',
action: '新建', action: '新建',
storeId: '1', storeId: '1',
}, },
@@ -475,7 +503,7 @@ export class WecomMessagePushService implements OnModuleInit {
vars: { vars: {
storeName: '示例门店', storeName: '示例门店',
cityName: '郑州', cityName: '郑州',
submitter: '合伙人', submitter: '郑州某某商贸 · 张三',
packageCount: '3', packageCount: '3',
requestId: '1', requestId: '1',
}, },
@@ -485,7 +513,7 @@ export class WecomMessagePushService implements OnModuleInit {
vars: { vars: {
storeName: '示例门店', storeName: '示例门店',
cityName: '郑州', cityName: '郑州',
submitter: '门店', submitter: '门店 · 李四',
changedFields: '门店名称、详细地址', changedFields: '门店名称、详细地址',
requestId: '1', requestId: '1',
}, },
@@ -501,6 +529,16 @@ export class WecomMessagePushService implements OnModuleInit {
}, },
handlePath: '/finance/store-bills?kind=WITHDRAW&status=PENDING_REVIEW&storeId=1', 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': { 'invoice.pending': {
vars: { vars: {
invoiceNo: 'IV202608200001', invoiceNo: 'IV202608200001',
@@ -511,6 +549,39 @@ export class WecomMessagePushService implements OnModuleInit {
}, },
handlePath: '/invoices?status=PENDING&invoiceNo=IV202608200001', 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]; return samples[eventKey];
} }
@@ -18,6 +18,8 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
'实付:¥{{payAmount}}', '实付:¥{{payAmount}}',
'城市:{{cityName}}', '城市:{{cityName}}',
'商品:{{skuSummary}}', '商品:{{skuSummary}}',
'物流:{{deliveryLabel}}',
'收货:{{receiverInfo}}',
'用户:{{phoneMasked}}', '用户:{{phoneMasked}}',
'时间:{{time}}', '时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})', '[{{handleLabel}}]({{handleUrl}})',
@@ -33,6 +35,7 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
'金额:¥{{amount}}', '金额:¥{{amount}}',
'门店:{{storeName}}', '门店:{{storeName}}',
'渠道:{{channel}}', '渠道:{{channel}}',
'用户:{{userName}} {{userPhone}}',
'时间:{{time}}', '时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})', '[{{handleLabel}}]({{handleUrl}})',
].join('\n'), ].join('\n'),
@@ -45,6 +48,7 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
'**门店审核待处理 · {{action}}**', '**门店审核待处理 · {{action}}**',
'门店:{{storeName}}', '门店:{{storeName}}',
'城市:{{cityName}}', '城市:{{cityName}}',
'提交人:{{submitter}}',
'合伙人:{{partnerLabel}}', '合伙人:{{partnerLabel}}',
'时间:{{time}}', '时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})', '[{{handleLabel}}]({{handleUrl}})',
@@ -58,7 +62,7 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
'**套餐变更待审核**', '**套餐变更待审核**',
'门店:{{storeName}}', '门店:{{storeName}}',
'城市:{{cityName}}', '城市:{{cityName}}',
'提交{{submitter}}', '提交{{submitter}}',
'套餐条数:{{packageCount}}', '套餐条数:{{packageCount}}',
'时间:{{time}}', '时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})', '[{{handleLabel}}]({{handleUrl}})',
@@ -72,7 +76,7 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
'**门店信息变更待审**', '**门店信息变更待审**',
'门店:{{storeName}}', '门店:{{storeName}}',
'城市:{{cityName}}', '城市:{{cityName}}',
'提交{{submitter}}', '提交{{submitter}}',
'变更字段:{{changedFields}}', '变更字段:{{changedFields}}',
'时间:{{time}}', '时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})', '[{{handleLabel}}]({{handleUrl}})',
@@ -93,6 +97,20 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
].join('\n'), ].join('\n'),
handleLabel: '去处理', handleLabel: '去处理',
}, },
{
eventKey: 'store.withdraw_approved',
title: '门店提现已通过',
body: [
'**门店提现已通过**',
'门店:{{storeName}}',
'提现单号:{{withdrawNo}}',
'金额:¥{{amount}}',
'收款账户:{{bankAccount}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
handleLabel: '去处理',
},
{ {
eventKey: 'invoice.pending', eventKey: 'invoice.pending',
title: '发票申请待开票', title: '发票申请待开票',
@@ -108,8 +126,110 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
].join('\n'), ].join('\n'),
handleLabel: '去处理', 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<Record<WecomTemplateEventKey, string>> = {
'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 { export function getDefaultTemplate(eventKey: string): WecomTemplateDefault | undefined {
return WECOM_PUSH_TEMPLATE_DEFAULTS.find((t) => t.eventKey === eventKey); return WECOM_PUSH_TEMPLATE_DEFAULTS.find((t) => t.eventKey === eventKey);
} }
@@ -28,7 +28,7 @@ export class SettlementScheduler {
this.logger.error('Winery bill job failed', e instanceof Error ? e.stack : e); this.logger.error('Winery bill job failed', e instanceof Error ? e.stack : e);
this.alert.notify({ this.alert.notify({
level: 'P0', level: 'P0',
category: 'job', category: 'settlement',
title: '酒厂日账单任务失败', title: '酒厂日账单任务失败',
detail: e instanceof Error ? e.message : String(e), detail: e instanceof Error ? e.message : String(e),
dedupeKey: `job_winery_bill|${new Date().toISOString().slice(0, 10)}`, 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.logger.error('Store bill job failed', e instanceof Error ? e.stack : e);
this.alert.notify({ this.alert.notify({
level: 'P0', level: 'P0',
category: 'job', category: 'settlement',
title: '门店日账单任务失败', title: '门店日账单任务失败',
detail: e instanceof Error ? e.message : String(e), detail: e instanceof Error ? e.message : String(e),
dedupeKey: `job_store_bill|${new Date().toISOString().slice(0, 10)}`, 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.logger.error('Store withdraw overdue scan failed', e instanceof Error ? e.stack : e);
this.alert.notify({ this.alert.notify({
level: 'P1', level: 'P1',
category: 'job', category: 'settlement',
title: '门店提现超时扫描失败', title: '门店提现超时扫描失败',
detail: e instanceof Error ? e.message : String(e), detail: e instanceof Error ? e.message : String(e),
dedupeKey: `job_store_withdraw_overdue_fail|${new Date().toISOString().slice(0, 10)}`, dedupeKey: `job_store_withdraw_overdue_fail|${new Date().toISOString().slice(0, 10)}`,
@@ -79,7 +79,7 @@ export class SettlementScheduler {
if (result.failed > 0) { if (result.failed > 0) {
this.alert.notify({ this.alert.notify({
level: 'P0', level: 'P0',
category: 'job', category: 'settlement',
title: '合伙人月账单部分失败', title: '合伙人月账单部分失败',
detail: `total=${result.total} success=${result.success} failed=${result.failed}`, detail: `total=${result.total} success=${result.success} failed=${result.failed}`,
dedupeKey: `job_partner_bill|${new Date().toISOString().slice(0, 7)}`, 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.logger.error('Partner bill job failed', e instanceof Error ? e.stack : e);
this.alert.notify({ this.alert.notify({
level: 'P0', level: 'P0',
category: 'job', category: 'settlement',
title: '合伙人月账单任务失败', title: '合伙人月账单任务失败',
detail: e instanceof Error ? e.message : String(e), detail: e instanceof Error ? e.message : String(e),
dedupeKey: `job_partner_bill|${new Date().toISOString().slice(0, 7)}`, dedupeKey: `job_partner_bill|${new Date().toISOString().slice(0, 7)}`,
@@ -103,7 +103,7 @@ export class SettlementScheduler {
if (logistics.failed > 0) { if (logistics.failed > 0) {
this.alert.notify({ this.alert.notify({
level: 'P0', level: 'P0',
category: 'job', category: 'settlement',
title: '物流月对账部分失败', title: '物流月对账部分失败',
detail: `total=${logistics.total} success=${logistics.success} failed=${logistics.failed}`, detail: `total=${logistics.total} success=${logistics.success} failed=${logistics.failed}`,
dedupeKey: `job_logistics_bill|${new Date().toISOString().slice(0, 7)}`, 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.logger.error('Logistics bill job failed', e instanceof Error ? e.stack : e);
this.alert.notify({ this.alert.notify({
level: 'P0', level: 'P0',
category: 'job', category: 'settlement',
title: '物流月对账任务失败', title: '物流月对账任务失败',
detail: e instanceof Error ? e.message : String(e), detail: e instanceof Error ? e.message : String(e),
dedupeKey: `job_logistics_bill|${new Date().toISOString().slice(0, 7)}`, dedupeKey: `job_logistics_bill|${new Date().toISOString().slice(0, 7)}`,
@@ -20,6 +20,8 @@ import type {
} from './dto/admin-mutate.dto'; } from './dto/admin-mutate.dto';
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service'; import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
import { HqPermissionsResolver, hqStoreCityWhere, mergeHqStoreCityWhere, type HqCityScope } from '../../common/guards/hq-permission.guard'; 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" */ /** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */
function normalizeStoreOptionalText(value: unknown): string | null { function normalizeStoreOptionalText(value: unknown): string | null {
@@ -56,6 +58,7 @@ export class AdminStoresService {
private readonly analyticsService: AnalyticsService, private readonly analyticsService: AnalyticsService,
private readonly testWhitelist: TestWhitelistService, private readonly testWhitelist: TestWhitelistService,
private readonly hqPermissions: HqPermissionsResolver, private readonly hqPermissions: HqPermissionsResolver,
private readonly wecomPush: WecomMessagePushService,
) {} ) {}
private async storeIdsInScope(scope: HqCityScope): Promise<bigint[] | null> { private async storeIdsInScope(scope: HqCityScope): Promise<bigint[] | null> {
@@ -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); return this.detailStore(store.id, actorId);
} }
@@ -8,6 +8,7 @@ import {
calcRedeemSettleAmount, calcRedeemSettleAmount,
generateRedeemNo, generateRedeemNo,
generateRedeemPendingNo, generateRedeemPendingNo,
maskContactPhone,
REDEEM_WEAKNET_FAIL_THRESHOLD, REDEEM_WEAKNET_FAIL_THRESHOLD,
resolveSettlementRate, resolveSettlementRate,
validateRedeemAmount, validateRedeemAmount,
@@ -194,7 +195,10 @@ export class RedeemService {
const redeemChannel = analyticsExtra?.channel === 'phone' ? 'PHONE' : 'SCAN'; const redeemChannel = analyticsExtra?.channel === 'phone' ? 'PHONE' : 'SCAN';
const [userRow, storeRow] = await Promise.all([ 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({ this.prisma.store.findUnique({
where: { id: account.storeId }, where: { id: account.storeId },
select: { isTest: true }, select: { isTest: true },
@@ -290,6 +294,8 @@ export class RedeemService {
amount: amountNum.toFixed(2), amount: amountNum.toFixed(2),
storeName: account.store.name || String(account.storeId), storeName: account.store.name || String(account.storeId),
channel: channelLabel, channel: channelLabel,
userName: userRow?.nickname?.trim() || '—',
userPhone: maskContactPhone(userRow?.phone),
}, },
{ {
handlePath: `/redeem-records?redeemNo=${encodeURIComponent(record.redeemNo)}`, handlePath: `/redeem-records?redeemNo=${encodeURIComponent(record.redeemNo)}`,
@@ -26,6 +26,7 @@ import {
loadStorePrimaryBank, loadStorePrimaryBank,
loadStorePrimaryBanksMap, loadStorePrimaryBanksMap,
loadWineryBankConfig, loadWineryBankConfig,
formatWecomBankAccount,
} from '../../common/store/store-bank.util'; } from '../../common/store/store-bank.util';
function generateBillNo(prefix: string) { function generateBillNo(prefix: string) {
@@ -62,6 +63,14 @@ function round2(n: number) {
return Math.round(n * 100) / 100; 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 { function getStoreWithdrawDailyLimit(): number {
const raw = process.env.STORE_WITHDRAW_DAILY_LIMIT; const raw = process.env.STORE_WITHDRAW_DAILY_LIMIT;
const n = raw != null && raw !== '' ? Number(raw) : DEFAULT_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 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) ───────────────────────────── // ─── Store payout (line) ─────────────────────────────
async createStorePayout( 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); return serializeBigInt(updated);
} }
@@ -651,7 +734,7 @@ export class SettlementService {
); );
this.alert.notify({ this.alert.notify({
level: 'P1', level: 'P1',
category: 'finance', category: 'settlement',
title: '门店提现超时未审', title: '门店提现超时未审',
detail: [ detail: [
`待审 ${summary.pendingCount} 笔,超时 ${summary.overdueCount} 笔,超时金额 ¥${summary.overdueAmount.toFixed(2)}`, `待审 ${summary.pendingCount} 笔,超时 ${summary.overdueCount} 笔,超时金额 ¥${summary.overdueAmount.toFixed(2)}`,
@@ -847,7 +930,7 @@ export class SettlementService {
withdrawItem: { is: null }, withdrawItem: { is: null },
status: 'PENDING', status: 'PENDING',
}, },
include: { store: { select: { id: true, settlementRate: true } } }, include: { store: { select: { id: true, name: true, settlementRate: true } } },
}); });
const byStore = new Map<string, typeof payouts>(); const byStore = new Map<string, typeof payouts>();
@@ -860,8 +943,10 @@ export class SettlementService {
let created = 0; let created = 0;
let skipped = 0; let skipped = 0;
const createdBills: Array<{ storeId: bigint; storeName: string; amount: number }> = [];
for (const [storeIdStr, list] of byStore) { for (const [storeIdStr, list] of byStore) {
const storeId = BigInt(storeIdStr); const storeId = BigInt(storeIdStr);
const storeName = list[0]?.store?.name || storeIdStr;
const existing = await this.prisma.storeBill.findUnique({ const existing = await this.prisma.storeBill.findUnique({
where: { storeId_billDate: { storeId, billDate } }, 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 redeemAmount = round2(list.reduce((s, p) => s + Number(p.redeemAmount), 0));
const payoutAmount = round2(list.reduce((s, p) => s + Number(p.payoutAmount), 0)); const payoutAmount = round2(list.reduce((s, p) => s + Number(p.payoutAmount), 0));
const rate = Number(list[0].settlementRate); const rate = Number(list[0].settlementRate);
const updatedTotal = round2(Number(existing.payoutAmount) + payoutAmount);
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
await tx.storeBill.update({ await tx.storeBill.update({
where: { id: existing.id }, where: { id: existing.id },
data: { data: {
redeemCount: existing.redeemCount + list.length, redeemCount: existing.redeemCount + list.length,
redeemAmount: round2(Number(existing.redeemAmount) + redeemAmount), redeemAmount: round2(Number(existing.redeemAmount) + redeemAmount),
payoutAmount: round2(Number(existing.payoutAmount) + payoutAmount), payoutAmount: updatedTotal,
settlementRate: rate, settlementRate: rate,
}, },
}); });
@@ -889,6 +975,7 @@ export class SettlementService {
}); });
}); });
created += 1; created += 1;
createdBills.push({ storeId, storeName, amount: updatedTotal });
continue; continue;
} }
@@ -914,9 +1001,31 @@ export class SettlementService {
}); });
}); });
created += 1; 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 }) { async generateAllPartnerBills(body: { year: number; month: number }) {
const partners = await this.prisma.partnerAccount.findMany({ const partners = await this.prisma.partnerAccount.findMany({
where: { isPrimary: 1, status: 'ACTIVE' }, 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 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) { for (const p of partners) {
try { try {
await this.generatePartnerBill({ const bill = await this.generatePartnerBill(
partnerId: p.id.toString(), {
year: body.year, partnerId: p.id.toString(),
month: body.month, year: body.year,
}); month: body.month,
},
{ notify: false },
);
results.push({ partnerId: p.id.toString(), companyName: p.companyName ?? '', ok: true }); 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) { } catch (e) {
results.push({ results.push({
partnerId: p.id.toString(), partnerId: p.id.toString(),
@@ -1380,6 +1515,10 @@ export class SettlementService {
}); });
} }
} }
this.notifyPartnerBillDigest(
`${body.year}-${String(body.month).padStart(2, '0')}`,
created,
);
return { return {
total: partners.length, total: partners.length,
success: results.filter((r) => r.ok).length, success: results.filter((r) => r.ok).length,
@@ -1403,7 +1542,10 @@ export class SettlementService {
return serializeBigInt(bill); 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 partnerAccountId = BigInt(body.partnerId);
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const periodStart = new Date(body.year, body.month - 1, 1); 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); return serializeBigInt(bill);
} }
@@ -1775,8 +1935,21 @@ export class SettlementService {
return header; 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({ return serializeBigInt({
billDate: billDate.toISOString().slice(0, 10), billDate: period,
skipped: false, skipped: false,
bill, bill,
}); });
@@ -1997,14 +2170,25 @@ export class SettlementService {
message?: string; message?: string;
billId?: 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) { for (const p of providers) {
try { try {
const bill = await this.generateLogisticsBill({ const bill = await this.generateLogisticsBill(
providerId: p.id.toString(), {
year: body.year, providerId: p.id.toString(),
month: body.month, year: body.year,
}); month: body.month,
},
{ notify: false },
);
results.push({ results.push({
providerId: p.id.toString(), providerId: p.id.toString(),
providerCode: p.code, providerCode: p.code,
@@ -2019,6 +2203,16 @@ export class SettlementService {
: String(bill.bill.id) : String(bill.bill.id)
: undefined, : 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) { } catch (e) {
results.push({ results.push({
providerId: p.id.toString(), providerId: p.id.toString(),
@@ -2030,6 +2224,11 @@ export class SettlementService {
} }
} }
this.notifyLogisticsBillDigest(
`${body.year}-${String(body.month).padStart(2, '0')}`,
created,
);
return { return {
total: providers.length, total: providers.length,
success: results.filter((r) => r.ok).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 fulfillmentProviderId = BigInt(body.providerId);
const provider = await this.prisma.fulfillmentProvider.findUnique({ const provider = await this.prisma.fulfillmentProvider.findUnique({
where: { id: fulfillmentProviderId }, where: { id: fulfillmentProviderId },
@@ -2190,6 +2392,22 @@ export class SettlementService {
return header; 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 { return {
skipped: false, skipped: false,
bill: serializeBigInt(bill), bill: serializeBigInt(bill),
@@ -21,6 +21,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
import { StoreService } from './store.service'; import { StoreService } from './store.service';
import { PartnerCityService } from '../city-scope/partner-city.service'; import { PartnerCityService } from '../city-scope/partner-city.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.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'; import { HqPermissionsResolver, hqStoreCityWhere } from '../../common/guards/hq-permission.guard';
type ChangeableField = (typeof STORE_INFO_CHANGEABLE_FIELDS)[number]; 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(',')}`, `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( void this.wecomPush.dispatchEvent(
'store.info_change_pending', 'store.info_change_pending',
{ {
storeName: String(store.name ?? input.storeId), storeName: String(store.name ?? input.storeId),
cityName: String(store.cityName ?? '—'), cityName: String(store.cityName ?? '—'),
submitter: submitterLabel, submitter,
changedFields: formatStoreInfoChangeFieldLabels(changedFields), changedFields: formatStoreInfoChangeFieldLabels(changedFields),
requestId: created.id.toString(), requestId: created.id.toString(),
}, },
@@ -15,6 +15,7 @@ import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { StoreService } from './store.service'; import { StoreService } from './store.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.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'; import { HqPermissionsResolver, hqStoreCityWhere } from '../../common/guards/hq-permission.guard';
type PackageInput = Record<string, unknown>; type PackageInput = Record<string, unknown>;
@@ -193,13 +194,16 @@ export class StorePackageService {
where: { id: storeId }, where: { id: storeId },
select: { name: true, cityName: true }, 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( void this.wecomPush.dispatchEvent(
'store.package_audit_pending', 'store.package_audit_pending',
{ {
storeName: store?.name ?? String(storeId), storeName: store?.name ?? String(storeId),
cityName: store?.cityName || '—', cityName: store?.cityName || '—',
submitter: submitterLabel, submitter,
packageCount: String(packages.length), packageCount: String(packages.length),
requestId: req.id.toString(), requestId: req.id.toString(),
}, },
@@ -24,6 +24,7 @@ import {
normalizeTestPhone, normalizeTestPhone,
} from '../../common/test-whitelist/test-whitelist.service'; } from '../../common/test-whitelist/test-whitelist.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.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 { function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180; const toRad = (d: number) => (d * Math.PI) / 180;
@@ -76,25 +77,29 @@ export class StoreService {
private readonly wecomPush: WecomMessagePushService, private readonly wecomPush: WecomMessagePushService,
) {} ) {}
/** 门店进入 PENDING 时通知企微(失败不挡业务) */ /** 门店进入 PENDING 或 HQ 新建时通知企微(失败不挡业务) */
private notifyStoreAuditPending(opts: { private notifyStoreAuditPending(opts: {
storeId: bigint; storeId: bigint;
storeName: string; storeName: string;
cityName?: string | null; cityName?: string | null;
partnerLabel?: string | null; partnerLabel?: string | null;
submitter?: string | null;
submitType: '新建' | '重提'; submitType: '新建' | '重提';
handlePath?: string;
}) { }) {
void this.wecomPush.dispatchEvent( void this.wecomPush.dispatchEvent(
'store.audit_pending', 'store.audit_pending',
{ {
storeName: opts.storeName, storeName: opts.storeName,
cityName: opts.cityName || '—', cityName: opts.cityName || '—',
submitter: opts.submitter || opts.partnerLabel || '—',
partnerLabel: opts.partnerLabel || '—', partnerLabel: opts.partnerLabel || '—',
action: opts.submitType, action: opts.submitType,
storeId: opts.storeId.toString(), 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) { if (!this.config.autoApproveStore) {
const partner = await this.prisma.partnerAccount.findUnique({ const [submitter, partnerLabel] = await Promise.all([
where: { id: primaryId }, formatPartnerWecomLabel(this.prisma, partnerAccountId),
select: { name: true, phone: true }, formatPartnerWecomLabel(this.prisma, primaryId),
}); ]);
this.notifyStoreAuditPending({ this.notifyStoreAuditPending({
storeId: store.id, storeId: store.id,
storeName: store.name, storeName: store.name,
cityName: store.cityName, cityName: store.cityName,
partnerLabel: partner?.name || partner?.phone || String(primaryId), submitter,
partnerLabel,
submitType: '新建', submitType: '新建',
}); });
} }
@@ -757,15 +763,16 @@ export class StoreService {
remark: '合伙人修改资料后重新提交审核', remark: '合伙人修改资料后重新提交审核',
}, },
}); });
const partner = await this.prisma.partnerAccount.findUnique({ const [submitter, partnerLabel] = await Promise.all([
where: { id: primaryId }, formatPartnerWecomLabel(this.prisma, partnerAccountId),
select: { name: true, phone: true }, formatPartnerWecomLabel(this.prisma, primaryId),
}); ]);
this.notifyStoreAuditPending({ this.notifyStoreAuditPending({
storeId: updated.id, storeId: updated.id,
storeName: updated.name, storeName: updated.name,
cityName: updated.cityName, cityName: updated.cityName,
partnerLabel: partner?.name || partner?.phone || String(primaryId), submitter,
partnerLabel,
submitType: '重提', submitType: '重提',
}); });
} }
@@ -882,15 +889,16 @@ export class StoreService {
remark: '合伙人重新上传资料后重新提交审核', remark: '合伙人重新上传资料后重新提交审核',
}, },
}); });
const partner = await this.prisma.partnerAccount.findUnique({ const [submitter, partnerLabel] = await Promise.all([
where: { id: primaryId }, formatPartnerWecomLabel(this.prisma, partnerAccountId),
select: { name: true, phone: true }, formatPartnerWecomLabel(this.prisma, primaryId),
}); ]);
this.notifyStoreAuditPending({ this.notifyStoreAuditPending({
storeId: store.id, storeId: store.id,
storeName: store.name, storeName: store.name,
cityName: store.cityName, cityName: store.cityName,
partnerLabel: partner?.name || partner?.phone || String(primaryId), submitter,
partnerLabel,
submitType: '重提', submitType: '重提',
}); });
} }
@@ -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<string> {
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<string> {
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<string> {
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}`;
}
@@ -10,6 +10,7 @@ import type { FreightPayType } from '@prisma/client';
import { import {
calcBenefitAmount, calcBenefitAmount,
generateOrderNo, generateOrderNo,
maskContactPhone,
minBottleQtyForDelivery, minBottleQtyForDelivery,
orderTabToStatuses, orderTabToStatuses,
toMinSaleQuantity, toMinSaleQuantity,
@@ -501,10 +502,22 @@ export class TradeService {
if (!order) return; if (!order) return;
if (!order.isTest) { if (!order.isTest) {
const skuSummary = `${order.productName}×${order.quantity}`; const unit = order.saleUnit === 'BOX' ? '箱' : '瓶';
const phone = order.user?.phone || ''; const spec = (order.productSpec || '').trim();
const phoneMasked = const skuSummary = spec
phone.length >= 7 ? `${phone.slice(0, 3)}****${phone.slice(-4)}` : phone || '—'; ? `${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( void this.wecomPush.dispatchEvent(
'order.paid', 'order.paid',
{ {
@@ -512,6 +525,8 @@ export class TradeService {
payAmount: Number(order.payAmount).toFixed(2), payAmount: Number(order.payAmount).toFixed(2),
cityName: order.city?.name || '—', cityName: order.city?.name || '—',
skuSummary, skuSummary,
deliveryLabel,
receiverInfo,
phoneMasked, phoneMasked,
}, },
{ handlePath: `/orders?orderNo=${encodeURIComponent(order.orderNo)}` }, { handlePath: `/orders?orderNo=${encodeURIComponent(order.orderNo)}` },