v3.5.11企业微信通知调整
CI / verify (pull_request) Has been cancelled

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
@@ -20,6 +20,8 @@ import type {
} from './dto/admin-mutate.dto';
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
import { HqPermissionsResolver, hqStoreCityWhere, mergeHqStoreCityWhere, type HqCityScope } from '../../common/guards/hq-permission.guard';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
import { formatHqWecomLabel, formatPartnerWecomLabel } from '../store/wecom-submitter-label';
/** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */
function normalizeStoreOptionalText(value: unknown): string | null {
@@ -56,6 +58,7 @@ export class AdminStoresService {
private readonly analyticsService: AnalyticsService,
private readonly testWhitelist: TestWhitelistService,
private readonly hqPermissions: HqPermissionsResolver,
private readonly wecomPush: WecomMessagePushService,
) {}
private async storeIdsInScope(scope: HqCityScope): Promise<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);
}
@@ -8,6 +8,7 @@ import {
calcRedeemSettleAmount,
generateRedeemNo,
generateRedeemPendingNo,
maskContactPhone,
REDEEM_WEAKNET_FAIL_THRESHOLD,
resolveSettlementRate,
validateRedeemAmount,
@@ -194,7 +195,10 @@ export class RedeemService {
const redeemChannel = analyticsExtra?.channel === 'phone' ? 'PHONE' : 'SCAN';
const [userRow, storeRow] = await Promise.all([
this.prisma.user.findUnique({ where: { id: userId }, select: { isTest: true } }),
this.prisma.user.findUnique({
where: { id: userId },
select: { isTest: true, nickname: true, phone: true },
}),
this.prisma.store.findUnique({
where: { id: account.storeId },
select: { isTest: true },
@@ -290,6 +294,8 @@ export class RedeemService {
amount: amountNum.toFixed(2),
storeName: account.store.name || String(account.storeId),
channel: channelLabel,
userName: userRow?.nickname?.trim() || '—',
userPhone: maskContactPhone(userRow?.phone),
},
{
handlePath: `/redeem-records?redeemNo=${encodeURIComponent(record.redeemNo)}`,
@@ -26,6 +26,7 @@ import {
loadStorePrimaryBank,
loadStorePrimaryBanksMap,
loadWineryBankConfig,
formatWecomBankAccount,
} from '../../common/store/store-bank.util';
function generateBillNo(prefix: string) {
@@ -62,6 +63,14 @@ function round2(n: number) {
return Math.round(n * 100) / 100;
}
const BILL_DIGEST_MAX_LINES = 15;
function capBillSummary(lines: string[]): string {
if (!lines.length) return '无';
if (lines.length <= BILL_DIGEST_MAX_LINES) return lines.join('\n');
return `${lines.slice(0, BILL_DIGEST_MAX_LINES).join('\n')}\n…等共 ${lines.length}`;
}
function getStoreWithdrawDailyLimit(): number {
const raw = process.env.STORE_WITHDRAW_DAILY_LIMIT;
const n = raw != null && raw !== '' ? Number(raw) : DEFAULT_STORE_WITHDRAW_DAILY_LIMIT;
@@ -95,6 +104,59 @@ export class SettlementService {
private readonly wecomPush: WecomMessagePushService,
) {}
private notifyPartnerBillDigest(
period: string,
rows: Array<{
label: string;
amount: number;
bankAccountName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
}>,
) {
if (!rows.length) return;
const lines = rows.map(
(r) =>
`${r.label} ¥${Number(r.amount).toFixed(2)} ${formatWecomBankAccount(r)}`,
);
void this.wecomPush.dispatchEvent(
'finance.partner_bill',
{
period,
billCount: String(rows.length),
billSummary: capBillSummary(lines),
},
{ handlePath: '/finance/partner-bills' },
);
}
private notifyLogisticsBillDigest(
period: string,
rows: Array<{
label: string;
amount: number;
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
}>,
) {
if (!rows.length) return;
const lines = rows.map(
(r) =>
`${r.label} ¥${Number(r.amount).toFixed(2)} ${formatWecomBankAccount(r)}`,
);
void this.wecomPush.dispatchEvent(
'finance.logistics_bill',
{
period,
billCount: String(rows.length),
billSummary: capBillSummary(lines),
},
{ handlePath: '/finance/logistics-bills' },
);
}
// ─── Store payout (line) ─────────────────────────────
async createStorePayout(
@@ -584,6 +646,27 @@ export class SettlementService {
},
});
const [store, bank] = await Promise.all([
this.prisma.store.findUnique({
where: { id: row.storeId },
select: { name: true },
}),
loadStorePrimaryBank(this.prisma, row.storeId),
]);
void this.wecomPush.dispatchEvent(
'store.withdraw_approved',
{
storeName: store?.name || String(row.storeId),
withdrawNo: row.withdrawNo,
amount: Number(row.amount).toFixed(2),
bankAccount: formatWecomBankAccount(bank),
storeId: row.storeId.toString(),
},
{
handlePath: `/finance/store-bills?kind=WITHDRAW&status=PAID&storeId=${row.storeId.toString()}`,
},
);
return serializeBigInt(updated);
}
@@ -651,7 +734,7 @@ export class SettlementService {
);
this.alert.notify({
level: 'P1',
category: 'finance',
category: 'settlement',
title: '门店提现超时未审',
detail: [
`待审 ${summary.pendingCount} 笔,超时 ${summary.overdueCount} 笔,超时金额 ¥${summary.overdueAmount.toFixed(2)}`,
@@ -847,7 +930,7 @@ export class SettlementService {
withdrawItem: { is: null },
status: 'PENDING',
},
include: { store: { select: { id: true, settlementRate: true } } },
include: { store: { select: { id: true, name: true, settlementRate: true } } },
});
const byStore = new Map<string, typeof payouts>();
@@ -860,8 +943,10 @@ export class SettlementService {
let created = 0;
let skipped = 0;
const createdBills: Array<{ storeId: bigint; storeName: string; amount: number }> = [];
for (const [storeIdStr, list] of byStore) {
const storeId = BigInt(storeIdStr);
const storeName = list[0]?.store?.name || storeIdStr;
const existing = await this.prisma.storeBill.findUnique({
where: { storeId_billDate: { storeId, billDate } },
});
@@ -873,13 +958,14 @@ export class SettlementService {
const redeemAmount = round2(list.reduce((s, p) => s + Number(p.redeemAmount), 0));
const payoutAmount = round2(list.reduce((s, p) => s + Number(p.payoutAmount), 0));
const rate = Number(list[0].settlementRate);
const updatedTotal = round2(Number(existing.payoutAmount) + payoutAmount);
await this.prisma.$transaction(async (tx) => {
await tx.storeBill.update({
where: { id: existing.id },
data: {
redeemCount: existing.redeemCount + list.length,
redeemAmount: round2(Number(existing.redeemAmount) + redeemAmount),
payoutAmount: round2(Number(existing.payoutAmount) + payoutAmount),
payoutAmount: updatedTotal,
settlementRate: rate,
},
});
@@ -889,6 +975,7 @@ export class SettlementService {
});
});
created += 1;
createdBills.push({ storeId, storeName, amount: updatedTotal });
continue;
}
@@ -914,9 +1001,31 @@ export class SettlementService {
});
});
created += 1;
createdBills.push({ storeId, storeName, amount: payoutAmount });
}
return { billDate: billDate.toISOString().slice(0, 10), created, skipped, payoutLinked: payouts.length };
const period = billDate.toISOString().slice(0, 10);
if (createdBills.length) {
const banks = await loadStorePrimaryBanksMap(
this.prisma,
createdBills.map((b) => b.storeId),
);
const lines = createdBills.map(
(b) =>
`${b.storeName} ¥${b.amount.toFixed(2)} ${formatWecomBankAccount(banks.get(b.storeId.toString()) ?? null)}`,
);
void this.wecomPush.dispatchEvent(
'finance.store_bill',
{
period,
billCount: String(createdBills.length),
billSummary: capBillSummary(lines),
},
{ handlePath: '/finance/store-bills?kind=T1_BILL' },
);
}
return { billDate: period, created, skipped, payoutLinked: payouts.length };
}
/**
@@ -1360,17 +1469,43 @@ export class SettlementService {
async generateAllPartnerBills(body: { year: number; month: number }) {
const partners = await this.prisma.partnerAccount.findMany({
where: { isPrimary: 1, status: 'ACTIVE' },
select: { id: true, companyName: true },
select: {
id: true,
companyName: true,
name: true,
bankAccountName: true,
bankBranch: true,
bankAccountNo: true,
},
});
const results: Array<{ partnerId: string; companyName: string; ok: boolean; message?: string }> = [];
const created: Array<{
label: string;
amount: number;
bankAccountName: string | null;
bankBranch: string | null;
bankAccountNo: string | null;
}> = [];
for (const p of partners) {
try {
await this.generatePartnerBill({
partnerId: p.id.toString(),
year: body.year,
month: body.month,
});
const bill = await this.generatePartnerBill(
{
partnerId: p.id.toString(),
year: body.year,
month: body.month,
},
{ notify: false },
);
results.push({ partnerId: p.id.toString(), companyName: p.companyName ?? '', ok: true });
const label =
[p.companyName?.trim(), p.name?.trim()].filter(Boolean).join(' · ') || p.id.toString();
created.push({
label,
amount: Number(bill.totalAmount),
bankAccountName: p.bankAccountName,
bankBranch: p.bankBranch,
bankAccountNo: p.bankAccountNo,
});
} catch (e) {
results.push({
partnerId: p.id.toString(),
@@ -1380,6 +1515,10 @@ export class SettlementService {
});
}
}
this.notifyPartnerBillDigest(
`${body.year}-${String(body.month).padStart(2, '0')}`,
created,
);
return {
total: partners.length,
success: results.filter((r) => r.ok).length,
@@ -1403,7 +1542,10 @@ export class SettlementService {
return serializeBigInt(bill);
}
async generatePartnerBill(body: { partnerId: string; year: number; month: number }) {
async generatePartnerBill(
body: { partnerId: string; year: number; month: number },
opts?: { notify?: boolean },
) {
const partnerAccountId = BigInt(body.partnerId);
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const periodStart = new Date(body.year, body.month - 1, 1);
@@ -1485,6 +1627,24 @@ export class SettlementService {
},
});
if (opts?.notify !== false) {
const label =
[primary.companyName?.trim(), primary.name?.trim()].filter(Boolean).join(' · ') ||
primary.id.toString();
this.notifyPartnerBillDigest(
`${body.year}-${String(body.month).padStart(2, '0')}`,
[
{
label,
amount: totalAmount,
bankAccountName: primary.bankAccountName,
bankBranch: primary.bankBranch,
bankAccountNo: primary.bankAccountNo,
},
],
);
}
return serializeBigInt(bill);
}
@@ -1775,8 +1935,21 @@ export class SettlementService {
return header;
});
const period = billDate.toISOString().slice(0, 10);
const wineryBank = await loadWineryBankConfig(this.prisma);
void this.wecomPush.dispatchEvent(
'finance.winery_bill',
{
period,
amount: wineryAmount.toFixed(2),
orderCount: String(orders.length),
bankAccount: formatWecomBankAccount(wineryBank),
},
{ handlePath: '/finance/winery-bills' },
);
return serializeBigInt({
billDate: billDate.toISOString().slice(0, 10),
billDate: period,
skipped: false,
bill,
});
@@ -1997,14 +2170,25 @@ export class SettlementService {
message?: string;
billId?: string;
}> = [];
const created: Array<{
label: string;
amount: number;
bankAccountName: string | null;
bankName: string | null;
bankBranch: string | null;
bankAccountNo: string | null;
}> = [];
for (const p of providers) {
try {
const bill = await this.generateLogisticsBill({
providerId: p.id.toString(),
year: body.year,
month: body.month,
});
const bill = await this.generateLogisticsBill(
{
providerId: p.id.toString(),
year: body.year,
month: body.month,
},
{ notify: false },
);
results.push({
providerId: p.id.toString(),
providerCode: p.code,
@@ -2019,6 +2203,16 @@ export class SettlementService {
: String(bill.bill.id)
: undefined,
});
if (!bill.skipped && bill.bill) {
created.push({
label: p.name,
amount: Number(bill.bill.logisticsAmount ?? 0),
bankAccountName: p.bankAccountName,
bankName: p.bankName,
bankBranch: p.bankBranch,
bankAccountNo: p.bankAccountNo,
});
}
} catch (e) {
results.push({
providerId: p.id.toString(),
@@ -2030,6 +2224,11 @@ export class SettlementService {
}
}
this.notifyLogisticsBillDigest(
`${body.year}-${String(body.month).padStart(2, '0')}`,
created,
);
return {
total: providers.length,
success: results.filter((r) => r.ok).length,
@@ -2038,7 +2237,10 @@ export class SettlementService {
};
}
async generateLogisticsBill(body: { providerId: string; year: number; month: number }) {
async generateLogisticsBill(
body: { providerId: string; year: number; month: number },
opts?: { notify?: boolean },
) {
const fulfillmentProviderId = BigInt(body.providerId);
const provider = await this.prisma.fulfillmentProvider.findUnique({
where: { id: fulfillmentProviderId },
@@ -2190,6 +2392,22 @@ export class SettlementService {
return header;
});
if (opts?.notify !== false) {
this.notifyLogisticsBillDigest(
`${body.year}-${String(body.month).padStart(2, '0')}`,
[
{
label: provider.name,
amount: Number(bill.logisticsAmount),
bankAccountName: provider.bankAccountName,
bankName: provider.bankName,
bankBranch: provider.bankBranch,
bankAccountNo: provider.bankAccountNo,
},
],
);
}
return {
skipped: false,
bill: serializeBigInt(bill),
@@ -21,6 +21,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
import { StoreService } from './store.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
import { formatHqWecomLabel, formatPartnerWecomLabel, formatShopWecomLabel } from './wecom-submitter-label';
import { HqPermissionsResolver, hqStoreCityWhere } from '../../common/guards/hq-permission.guard';
type ChangeableField = (typeof STORE_INFO_CHANGEABLE_FIELDS)[number];
@@ -335,13 +336,18 @@ export class StoreInfoChangeService {
`Store info change submitted storeId=${input.storeId} fields=${changedFields.join(',')}`,
);
const submitterLabel = input.submitterType === 'PARTNER' ? '合伙人' : '门店';
const submitter =
input.submitterType === 'PARTNER'
? await formatPartnerWecomLabel(this.prisma, input.submitterId)
: input.submitterType === 'HQ_DIRECT_ADMIN'
? await formatHqWecomLabel(this.prisma, input.submitterId)
: await formatShopWecomLabel(this.prisma, input.submitterId);
void this.wecomPush.dispatchEvent(
'store.info_change_pending',
{
storeName: String(store.name ?? input.storeId),
cityName: String(store.cityName ?? '—'),
submitter: submitterLabel,
submitter,
changedFields: formatStoreInfoChangeFieldLabels(changedFields),
requestId: created.id.toString(),
},
@@ -15,6 +15,7 @@ import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { StoreService } from './store.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
import { formatPartnerWecomLabel, formatShopWecomLabel } from './wecom-submitter-label';
import { HqPermissionsResolver, hqStoreCityWhere } from '../../common/guards/hq-permission.guard';
type PackageInput = Record<string, unknown>;
@@ -193,13 +194,16 @@ export class StorePackageService {
where: { id: storeId },
select: { name: true, cityName: true },
});
const submitterLabel = submitterType === 'PARTNER' ? '合伙人' : '门店';
const submitter =
submitterType === 'PARTNER'
? await formatPartnerWecomLabel(this.prisma, submitterId)
: await formatShopWecomLabel(this.prisma, submitterId);
void this.wecomPush.dispatchEvent(
'store.package_audit_pending',
{
storeName: store?.name ?? String(storeId),
cityName: store?.cityName || '—',
submitter: submitterLabel,
submitter,
packageCount: String(packages.length),
requestId: req.id.toString(),
},
@@ -24,6 +24,7 @@ import {
normalizeTestPhone,
} from '../../common/test-whitelist/test-whitelist.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
import { formatPartnerWecomLabel } from './wecom-submitter-label';
function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
@@ -76,25 +77,29 @@ export class StoreService {
private readonly wecomPush: WecomMessagePushService,
) {}
/** 门店进入 PENDING 时通知企微(失败不挡业务) */
/** 门店进入 PENDING 或 HQ 新建时通知企微(失败不挡业务) */
private notifyStoreAuditPending(opts: {
storeId: bigint;
storeName: string;
cityName?: string | null;
partnerLabel?: string | null;
submitter?: string | null;
submitType: '新建' | '重提';
handlePath?: string;
}) {
void this.wecomPush.dispatchEvent(
'store.audit_pending',
{
storeName: opts.storeName,
cityName: opts.cityName || '—',
submitter: opts.submitter || opts.partnerLabel || '—',
partnerLabel: opts.partnerLabel || '—',
action: opts.submitType,
storeId: opts.storeId.toString(),
},
{
handlePath: `/stores?auditStatus=PENDING&storeId=${opts.storeId.toString()}`,
handlePath:
opts.handlePath || `/stores?auditStatus=PENDING&storeId=${opts.storeId.toString()}`,
},
);
}
@@ -587,15 +592,16 @@ export class StoreService {
});
if (!this.config.autoApproveStore) {
const partner = await this.prisma.partnerAccount.findUnique({
where: { id: primaryId },
select: { name: true, phone: true },
});
const [submitter, partnerLabel] = await Promise.all([
formatPartnerWecomLabel(this.prisma, partnerAccountId),
formatPartnerWecomLabel(this.prisma, primaryId),
]);
this.notifyStoreAuditPending({
storeId: store.id,
storeName: store.name,
cityName: store.cityName,
partnerLabel: partner?.name || partner?.phone || String(primaryId),
submitter,
partnerLabel,
submitType: '新建',
});
}
@@ -757,15 +763,16 @@ export class StoreService {
remark: '合伙人修改资料后重新提交审核',
},
});
const partner = await this.prisma.partnerAccount.findUnique({
where: { id: primaryId },
select: { name: true, phone: true },
});
const [submitter, partnerLabel] = await Promise.all([
formatPartnerWecomLabel(this.prisma, partnerAccountId),
formatPartnerWecomLabel(this.prisma, primaryId),
]);
this.notifyStoreAuditPending({
storeId: updated.id,
storeName: updated.name,
cityName: updated.cityName,
partnerLabel: partner?.name || partner?.phone || String(primaryId),
submitter,
partnerLabel,
submitType: '重提',
});
}
@@ -882,15 +889,16 @@ export class StoreService {
remark: '合伙人重新上传资料后重新提交审核',
},
});
const partner = await this.prisma.partnerAccount.findUnique({
where: { id: primaryId },
select: { name: true, phone: true },
});
const [submitter, partnerLabel] = await Promise.all([
formatPartnerWecomLabel(this.prisma, partnerAccountId),
formatPartnerWecomLabel(this.prisma, primaryId),
]);
this.notifyStoreAuditPending({
storeId: store.id,
storeName: store.name,
cityName: store.cityName,
partnerLabel: partner?.name || partner?.phone || String(primaryId),
submitter,
partnerLabel,
submitType: '重提',
});
}
@@ -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 {
calcBenefitAmount,
generateOrderNo,
maskContactPhone,
minBottleQtyForDelivery,
orderTabToStatuses,
toMinSaleQuantity,
@@ -501,10 +502,22 @@ export class TradeService {
if (!order) return;
if (!order.isTest) {
const skuSummary = `${order.productName}×${order.quantity}`;
const phone = order.user?.phone || '';
const phoneMasked =
phone.length >= 7 ? `${phone.slice(0, 3)}****${phone.slice(-4)}` : phone || '—';
const unit = order.saleUnit === 'BOX' ? '箱' : '瓶';
const spec = (order.productSpec || '').trim();
const skuSummary = spec
? `${order.productName} ${spec} ${unit}×${order.quantity}`
: `${order.productName} ${unit}×${order.quantity}`;
const deliveryLabel =
order.deliveryType === 'ON_SITE_PICKUP'
? '现场提货'
: order.deliveryType === 'CROSS_CITY'
? '跨城'
: '同城';
const receiverInfo =
order.deliveryType === 'ON_SITE_PICKUP'
? `${order.receiverName} ${order.receiverPhone}`.trim()
: `${order.receiverName} ${order.receiverPhone} ${order.receiverAddress}`.trim();
const phoneMasked = maskContactPhone(order.user?.phone || order.receiverPhone);
void this.wecomPush.dispatchEvent(
'order.paid',
{
@@ -512,6 +525,8 @@ export class TradeService {
payAmount: Number(order.payAmount).toFixed(2),
cityName: order.city?.name || '—',
skuSummary,
deliveryLabel,
receiverInfo,
phoneMasked,
},
{ handlePath: `/orders?orderNo=${encodeURIComponent(order.orderNo)}` },