feat(wecom): 结算账单明细与成交播报用户明文

支付/核销补 HQ 备注且手机号不脱敏;结算通知按城市、合伙人、笔数和累计金额分块推送。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-27 11:44:49 +08:00
parent 3dbb73e428
commit 737efb89a3
11 changed files with 688 additions and 162 deletions
@@ -208,8 +208,8 @@ export class WecomMessagePushService implements OnModuleInit {
this.logger.log(`seeded wecom push template: ${def.eventKey}`);
continue;
}
const legacy = WECOM_PUSH_TEMPLATE_LEGACY_BODIES[def.eventKey];
if (legacy && existing.body === legacy) {
const legacyBodies = WECOM_PUSH_TEMPLATE_LEGACY_BODIES[def.eventKey] ?? [];
if (legacyBodies.includes(existing.body)) {
await this.prisma.wecomPushTemplate.update({
where: { eventKey: def.eventKey },
data: {
@@ -473,7 +473,10 @@ export class WecomMessagePushService implements OnModuleInit {
skuSummary: '杜康原浆 52度 瓶×2',
deliveryLabel: '同城',
receiverInfo: '张三 13800008000 河南省郑州市金水区示例路1号',
phoneMasked: '138****8000',
userName: '好客用户',
userPhone: '13800008000',
userRemark: '大客户',
phoneMasked: '13800008000',
},
handlePath: '/orders?orderNo=DK202608200001',
},
@@ -484,7 +487,8 @@ export class WecomMessagePushService implements OnModuleInit {
storeName: '示例门店',
channel: '扫码',
userName: '好客用户',
userPhone: '138****8000',
userPhone: '13800008000',
userRemark: '大客户',
},
handlePath: '/redeem-records?redeemNo=RD202608200001',
},
@@ -531,9 +535,14 @@ export class WecomMessagePushService implements OnModuleInit {
},
'store.withdraw_approved': {
vars: {
cityName: '郑州',
storeName: '示例门店',
partnerLabel: '郑州某某商贸-张三',
withdrawNo: 'SW202608200001',
amount: '500.00',
payee: '张三',
accountNo: '6222000011112222',
bankBranch: '郑州支行',
bankAccount: '张三 / 郑州支行 / 6222000011112222',
storeId: '1',
},
@@ -553,7 +562,19 @@ export class WecomMessagePushService implements OnModuleInit {
vars: {
period: '2026-08-25',
billCount: '2',
billSummary: '示例门店 ¥300.00 张三 / 郑州支行 / 6222000011112222',
totalAmount: '500.00',
billSummary: [
'城市:郑州',
'门店:示例门店',
'合伙人:郑州某某商贸-张三',
'核销笔数:12',
'核销金额:¥500.00',
'结算比例:60%',
'账单:¥300.00',
'收款人:张三',
'收款账户:6222000011112222',
'收款开户行:郑州支行',
].join('\n'),
},
handlePath: '/finance/store-bills?kind=T1_BILL',
},
@@ -561,15 +582,36 @@ export class WecomMessagePushService implements OnModuleInit {
vars: {
period: '2026-07',
billCount: '1',
billSummary: '郑州某某商贸 ¥120.00 张三 / 郑州支行 / 6222000011112222',
totalAmount: '120.00',
billSummary: [
'城市:郑州',
'合伙人:郑州某某商贸-张三',
'订单笔数:8',
'核销笔数:12',
'订单佣金:¥100.00',
'核销佣金:¥20.00',
'账单:¥120.00',
'收款人:张三',
'收款账户:6222000011112222',
'收款开户行:郑州支行',
].join('\n'),
},
handlePath: '/finance/partner-bills',
},
'finance.winery_bill': {
vars: {
period: '2026-08-23',
billCount: '1',
totalAmount: '1990.00',
cityNames: '郑州、洛阳',
partnerNames: '郑州某某商贸-张三',
amount: '1990.00',
orderCount: '8',
orderAmount: '6633.33',
rate: '30%',
payee: '杜康酒厂',
accountNo: '6222000099998888',
bankBranch: '洛阳支行',
bankAccount: '杜康酒厂 / 洛阳支行 / 6222000099998888',
},
handlePath: '/finance/winery-bills',
@@ -578,7 +620,19 @@ export class WecomMessagePushService implements OnModuleInit {
vars: {
period: '2026-07',
billCount: '1',
billSummary: '小飞侠 ¥86.00 承运商 / 郑州支行 / 6222000033334444',
totalAmount: '86.00',
billSummary: [
'城市:郑州',
'合伙人:郑州某某商贸-张三',
'承运商:小飞侠',
'结算方式:充值扣款',
'发货笔数:10',
'瓶数:60',
'账单:¥86.00',
'收款人:承运商',
'收款账户:6222000033334444',
'收款开户行:郑州支行',
].join('\n'),
},
handlePath: '/finance/logistics-bills',
},
@@ -20,7 +20,8 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
'商品:{{skuSummary}}',
'物流:{{deliveryLabel}}',
'收货:{{receiverInfo}}',
'用户:{{phoneMasked}}',
'用户:{{userName}} {{userPhone}}',
'备注:{{userRemark}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
@@ -36,6 +37,7 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
'门店:{{storeName}}',
'渠道:{{channel}}',
'用户:{{userName}} {{userPhone}}',
'备注:{{userRemark}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
@@ -102,10 +104,14 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
title: '门店提现已通过',
body: [
'**门店提现已通过**',
'城市:{{cityName}}',
'门店:{{storeName}}',
'合伙人:{{partnerLabel}}',
'提现单号:{{withdrawNo}}',
'金额:¥{{amount}}',
'收款账户{{bankAccount}}',
'账单:¥{{amount}}',
'收款{{payee}}',
'收款账户:{{accountNo}}',
'收款开户行:{{bankBranch}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
@@ -132,9 +138,12 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
body: [
'**门店日账单已生成**',
'账期:{{period}}',
'笔数:{{billCount}}',
'{{billSummary}}',
'账单笔数:{{billCount}}',
'累计金额:¥{{totalAmount}}',
'时间:{{time}}',
'',
'{{billSummary}}',
'',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
handleLabel: '去处理',
@@ -145,9 +154,12 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
body: [
'**合伙人月账单已生成**',
'账期:{{period}}',
'笔数:{{billCount}}',
'{{billSummary}}',
'账单笔数:{{billCount}}',
'累计金额:¥{{totalAmount}}',
'时间:{{time}}',
'',
'{{billSummary}}',
'',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
handleLabel: '去处理',
@@ -158,10 +170,20 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
body: [
'**酒厂日账单已生成**',
'账期:{{period}}',
'订单数:{{orderCount}}',
'金额:¥{{amount}}',
'收款账户:{{bankAccount}}',
'账单笔数:{{billCount}}',
'累计金额:¥{{totalAmount}}',
'时间:{{time}}',
'',
'城市:{{cityNames}}',
'合伙人:{{partnerNames}}',
'订单笔数:{{orderCount}}',
'订单金额:¥{{orderAmount}}',
'结算比例:{{rate}}',
'账单:¥{{amount}}',
'收款人:{{payee}}',
'收款账户:{{accountNo}}',
'收款开户行:{{bankBranch}}',
'',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
handleLabel: '去处理',
@@ -172,62 +194,149 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
body: [
'**物流月对账已生成**',
'账期:{{period}}',
'笔数:{{billCount}}',
'{{billSummary}}',
'账单笔数:{{billCount}}',
'累计金额:¥{{totalAmount}}',
'时间:{{time}}',
'',
'{{billSummary}}',
'',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
handleLabel: '去处理',
},
];
/** v3.5.3 默认正文;ensureTemplates 仅在库中仍为此文案时升级,不覆盖 HQ 手改 */
export const WECOM_PUSH_TEMPLATE_LEGACY_BODIES: Partial<Record<WecomTemplateEventKey, string>> = {
/** 上一版默认正文;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'),
[
'**订单支付成功**',
'订单号:{{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 {
@@ -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)}`,
@@ -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<string, typeof payouts>();
@@ -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<bigint | null | undefined>, max = 5): Promise<string> {
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,
};
}
@@ -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],
]);
}
@@ -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)}` },
);