v4.0.18版本提交

This commit is contained in:
2026-09-08 10:07:34 +08:00
parent 4bdb09068c
commit 23ba639e9b
46 changed files with 1922 additions and 162 deletions
@@ -0,0 +1,26 @@
-- v4.0.18:HQ 财务银行账户总目录(其他账户 + 来源账户备注 overlay)
CREATE TABLE IF NOT EXISTS `finance_bank_account` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(128) NULL,
`bank_account_name` VARCHAR(64) NOT NULL,
`bank_account_no` VARCHAR(32) NOT NULL,
`bank_branch` VARCHAR(128) NULL,
`remark` VARCHAR(256) NULL,
`status` ENUM('ACTIVE','DISABLED') NOT NULL DEFAULT 'ACTIVE',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `idx_finance_bank_account_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='财务手工银行账户(不挂门店)';
CREATE TABLE IF NOT EXISTS `finance_bank_account_note` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`owner_type` ENUM('STORE','WINERY','PARTNER','LOGISTICS') NOT NULL,
`source_id` VARCHAR(64) NOT NULL,
`remark` VARCHAR(256) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_finance_bank_account_note_owner_source` (`owner_type`, `source_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='财务银行账户备注 overlay';
+36
View File
@@ -262,6 +262,13 @@ enum AccountStatus {
DISABLED
}
enum FinanceBankAccountOwnerType {
STORE
WINERY
PARTNER
LOGISTICS
}
enum HqAdminRole {
SUPER_ADMIN
OPS
@@ -2176,6 +2183,35 @@ model WineryBillItem {
@@map("winery_bill_item")
}
/// HQ 财务手工登记的银行账户(不挂门店,不进入打款)
model FinanceBankAccount {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
name String? @db.VarChar(128)
bankAccountName String @map("bank_account_name") @db.VarChar(64)
bankAccountNo String @map("bank_account_no") @db.VarChar(32)
bankBranch String? @map("bank_branch") @db.VarChar(128)
remark String? @db.VarChar(256)
status AccountStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@index([status])
@@map("finance_bank_account")
}
/// HQ 财务对来源账户的备注 overlay(不改写门店/合伙人/酒厂/物流源字段)
model FinanceBankAccountNote {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
ownerType FinanceBankAccountOwnerType @map("owner_type")
sourceId String @map("source_id") @db.VarChar(64)
remark String @db.VarChar(256)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@unique([ownerType, sourceId])
@@map("finance_bank_account_note")
}
// ─── LOG ──────────────────────────────────────────────
model LogThirdParty {
@@ -35,6 +35,8 @@ export const HqOperationAction = {
STORE_AUDIT: 'STORE_AUDIT',
STORE_ACCOUNT_CREATE: 'STORE_ACCOUNT_CREATE',
STORE_ACCOUNT_UPDATE: 'STORE_ACCOUNT_UPDATE',
STORE_ACCOUNT_STAFF_CREATE: 'STORE_ACCOUNT_STAFF_CREATE',
STORE_ACCOUNT_STAFF_UPDATE: 'STORE_ACCOUNT_STAFF_UPDATE',
STORE_ACCOUNT_STAFF_DELETE: 'STORE_ACCOUNT_STAFF_DELETE',
STORE_MEDIA_CREATE: 'STORE_MEDIA_CREATE',
STORE_MEDIA_UPDATE: 'STORE_MEDIA_UPDATE',
@@ -176,6 +178,8 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.STORE_AUDIT]: '门店审核',
[HqOperationAction.STORE_ACCOUNT_CREATE]: '新增门店账户',
[HqOperationAction.STORE_ACCOUNT_UPDATE]: '编辑门店账户',
[HqOperationAction.STORE_ACCOUNT_STAFF_CREATE]: '新增门店子账号',
[HqOperationAction.STORE_ACCOUNT_STAFF_UPDATE]: '编辑门店子账号',
[HqOperationAction.STORE_ACCOUNT_STAFF_DELETE]: '删除门店子账号',
[HqOperationAction.STORE_MEDIA_CREATE]: '新增门店资源',
[HqOperationAction.STORE_MEDIA_UPDATE]: '编辑门店资源',
@@ -30,6 +30,11 @@ import {
renderWecomTemplate,
} from './wecom-push-template.defaults';
/** 含 markdown 表格时改走 markdown_v2,群聊才能渲染表格 */
function wecomMarkdownUsesTable(content: string): boolean {
return /\|[^\n]*\|\s*\n\s*\|?\s*:?-{3,}/.test(content);
}
type PushRow = {
id: bigint;
name: string;
@@ -327,14 +332,16 @@ export class WecomMessagePushService implements OnModuleInit {
async sendMarkdownToWebhook(webhookUrl: string, content: string): Promise<boolean> {
const url = (webhookUrl || '').trim();
if (!url) return false;
const useTable = wecomMarkdownUsesTable(content);
const text = content.slice(0, useTable ? 4096 : 4000);
const payload = useTable
? { msgtype: 'markdown_v2', markdown_v2: { content: text } }
: { msgtype: 'markdown', markdown: { content: text } };
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
msgtype: 'markdown',
markdown: { content: content.slice(0, 4000) },
}),
body: JSON.stringify(payload),
});
const data = (await res.json().catch(() => ({}))) as {
errcode?: number;
@@ -580,20 +587,23 @@ export class WecomMessagePushService implements OnModuleInit {
},
'finance.partner_bill': {
vars: {
period: '2026-07',
period: '2026-07-27 ~ 2026-08-02',
billCount: '1',
totalAmount: '120.00',
billSummary: [
'城市:郑州',
'合伙人:郑州某某商贸-张三',
'订单笔数:8',
'核销笔数:12',
'订单佣金:¥100.00',
'核销佣金:¥20.00',
'账单:¥120.00',
'收款人:张三',
'收款账户:6222000011112222',
'收款开户行:郑州支行',
'| 郑州某某商贸-张三 | |',
'| :--- | ---: |',
'| 账期 | 2026-07-27 ~ 2026-08-02 |',
'| 订单数量 | 8 |',
'| 订单金额 | ¥1000.00 |',
'| 订单佣金 | ¥100.00 |',
'| 核销单数量 | 12 |',
'| 核销金额 | ¥200.00 |',
'| 核销佣金 | ¥20.00 |',
'| 累计金额 | ¥120.00 |',
'| 收款人 | 张三 |',
'| 收款银行账号 | 6222000011112222 |',
'| 开户行 | 郑州支行 |',
].join('\n'),
},
handlePath: '/finance/partner-bills',
@@ -9,7 +9,8 @@ import {
toWecomPluginMetricsView,
toWecomPluginUserView,
wecomPluginMetricsPeriod,
type WecomReportStats,
wecomReportCountedOrderWhere,
type WecomReportStats,
} from '@dukang/domain';
import {
WECOM_PLUGIN_TOOL_PATHS,
@@ -428,10 +429,11 @@ export class WecomPluginQueryService {
);
}
/** 日报口径:用户=有效未合并;订单金额=已付 payAmount;核销=RedeemRecord。today 期末为当前时刻。 */
/** 日报口径:用户=有效未合并;订单排除待支付/已取消/退款中;金额=已付 payAmount;核销=RedeemRecord。today 期末为当前时刻。 */
private async loadStats(start: Date, cutoff: Date): Promise<WecomReportStats> {
const userBase = { status: 1, mergedIntoUserId: null } as const;
const partnerBase = { isPrimary: 1 } as const;
const countedOrder = wecomReportCountedOrderWhere();
const paid = { payStatus: 'PAID' as const };
const [
@@ -462,15 +464,15 @@ export class WecomPluginQueryService {
}),
this.prisma.store.count({ where: { createdAt: { lt: cutoff } } }),
this.prisma.store.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
this.prisma.order.count({ where: { createdAt: { lt: cutoff } } }),
this.prisma.order.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
this.prisma.order.count({ where: { ...countedOrder, createdAt: { lt: cutoff } } }),
this.prisma.order.count({ where: { ...countedOrder, createdAt: { gte: start, lt: cutoff } } }),
this.prisma.order.aggregate({
_sum: { payAmount: true },
where: { ...paid, paidAt: { lt: cutoff } },
where: { ...countedOrder, ...paid, paidAt: { lt: cutoff } },
}),
this.prisma.order.aggregate({
_sum: { payAmount: true },
where: { ...paid, paidAt: { gte: start, lt: cutoff } },
where: { ...countedOrder, ...paid, paidAt: { gte: start, lt: cutoff } },
}),
this.prisma.redeemRecord.count({ where: { createdAt: { lt: cutoff } } }),
this.prisma.redeemRecord.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
@@ -305,7 +305,7 @@ export const WECOM_PLUGIN_OPENAPI = {
get: {
summary: '经营指标',
description:
'today=今日截至当前;daily/weekly/monthly 与企微经营报告同一口径。stats 含 users/partners/stores/orders 存量与增量;storesIncrement 与 newStores 均为新增门店数。',
'today=今日截至当前;daily/weekly/monthly 与企微经营报告同一口径。订单排除待支付/已取消/退款中。stats 含 users/partners/stores/orders 存量与增量;storesIncrement 与 newStores 均为新增门店数。',
operationId: '查询经营指标',
parameters: [
{
@@ -150,13 +150,9 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
},
{
eventKey: 'finance.partner_bill',
title: '合伙人账单已生成',
title: '合伙人账单',
body: [
'**合伙人账单已生成**',
'账期:{{period}}',
'账单笔数:{{billCount}}',
'累计金额:¥{{totalAmount}}',
'时间:{{time}}',
'**合伙人账单**',
'',
'{{billSummary}}',
'',
@@ -315,6 +311,17 @@ export const WECOM_PUSH_TEMPLATE_LEGACY_BODIES: Partial<Record<WecomTemplateEven
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
[
'**合伙人月账单已生成**',
'账期:{{period}}',
'账单笔数:{{billCount}}',
'累计金额:¥{{totalAmount}}',
'时间:{{time}}',
'',
'{{billSummary}}',
'',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
],
'finance.winery_bill': [
[
@@ -16,9 +16,11 @@ import {
} from './dto/admin-query.dto';
import {
CreateStoreAccountDto,
CreateStoreAccountStaffDto,
CreateStoreDto,
CreateStoreMediaDto,
UpdateStoreAccountDto,
UpdateStoreAccountStaffDto,
UpdateStoreDto,
UpdateStoreMediaDto,
UpdateStoreStatusDto,
@@ -113,6 +115,37 @@ export class AdminStoreAccountsController {
return this.service.updateStoreAccount(BigInt(id), dto, user.actorId);
}
@Post(':id/staff')
@HqOperation({
action: HqOperationAction.STORE_ACCOUNT_STAFF_CREATE,
refType: 'STORE_ACCOUNT',
refIdField: 'id',
includeBody: true,
})
createStaff(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: CreateStoreAccountStaffDto,
) {
return this.service.createStoreStaff(BigInt(id), dto, user.actorId);
}
@Put(':id/staff/:staffId')
@HqOperation({
action: HqOperationAction.STORE_ACCOUNT_STAFF_UPDATE,
refType: 'STORE_ACCOUNT',
refIdParam: 'staffId',
includeBody: true,
})
updateStaff(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Param('staffId') staffId: string,
@Body() dto: UpdateStoreAccountStaffDto,
) {
return this.service.updateStoreStaff(BigInt(id), BigInt(staffId), dto, user.actorId);
}
@Delete(':id/staff/:staffId')
@HqOperation({
action: HqOperationAction.STORE_ACCOUNT_STAFF_DELETE,
@@ -1,5 +1,9 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
STORE_STAFF_DEFAULT_PERMISSIONS,
StoreStaffRole,
} from '@dukang/shared-types';
import {
assertCanDeleteStore,
isMobilePhone,
@@ -23,9 +27,11 @@ import {
import { AnalyticsService } from '../analytics/analytics.service';
import type {
CreateStoreAccountDto,
CreateStoreAccountStaffDto,
CreateStoreDto,
CreateStoreMediaDto,
UpdateStoreAccountDto,
UpdateStoreAccountStaffDto,
UpdateStoreDto,
UpdateStoreMediaDto,
UpdateStoreStatusDto,
@@ -1103,7 +1109,7 @@ export class AdminStoresService {
...account,
stores: account.bindings.map((b) => b.store),
store: account.bindings[0]?.store ?? null,
staff: account.childAccounts,
staff: account.childAccounts.map((row) => this.mapStoreStaff(row)),
});
}
@@ -1120,17 +1126,102 @@ export class AdminStoresService {
return serializeBigInt(account);
}
async createStoreStaff(parentAccountId: bigint, dto: CreateStoreAccountStaffDto, actorId: bigint) {
const parent = await this.assertPrimaryStoreAccount(parentAccountId, actorId);
const phone = dto.phone.trim();
if (!isMobilePhone(phone)) {
throw new BadRequestException('请输入正确的手机号码');
}
const existing = await this.prisma.storeAccount.findUnique({ where: { phone } });
if (existing) throw new BadRequestException('该手机号已被使用');
const name = dto.name.trim();
if (!name) throw new BadRequestException('请填写真实姓名');
const storeIds = await this.resolveStaffStoreIds(parent.id, dto.storeIds);
const staffRole = (dto.staffRole as StoreStaffRole | undefined) ?? StoreStaffRole.CASHIER;
const permissions = dto.permissions?.length
? dto.permissions
: [...STORE_STAFF_DEFAULT_PERMISSIONS];
const account = await this.prisma.storeAccount.create({
data: {
phone,
name,
isPrimary: 0,
parentAccountId: parent.id,
staffRole,
permissions,
status: 'ACTIVE',
isTest: parent.isTest,
bindings: {
create: storeIds.map((storeId) => ({ storeId })),
},
},
include: this.staffBindingsInclude,
});
return serializeBigInt(this.mapStoreStaff(account));
}
async updateStoreStaff(
parentAccountId: bigint,
staffId: bigint,
dto: UpdateStoreAccountStaffDto,
actorId: bigint,
) {
const parent = await this.assertPrimaryStoreAccount(parentAccountId, actorId);
const staff = await this.assertStaffOwned(parent.id, staffId);
const data: Prisma.StoreAccountUpdateInput = {};
if (dto.name !== undefined) {
const name = dto.name.trim();
if (!name) throw new BadRequestException('请填写真实姓名');
data.name = name;
}
if (dto.staffRole !== undefined) data.staffRole = dto.staffRole as StoreStaffRole;
if (dto.permissions !== undefined) data.permissions = dto.permissions;
if (dto.status !== undefined) data.status = dto.status as 'ACTIVE' | 'DISABLED';
if (dto.phone !== undefined) {
const phone = dto.phone.trim();
if (!isMobilePhone(phone)) {
throw new BadRequestException('请输入正确的手机号码');
}
const phoneTaken = await this.prisma.storeAccount.findUnique({ where: { phone } });
if (phoneTaken && phoneTaken.id !== staff.id) {
throw new BadRequestException('该手机号已被使用');
}
data.phone = phone;
if (phone !== staff.phone) {
data.wxOpenId = null;
data.wxUnionId = null;
}
}
if (dto.storeIds !== undefined) {
const storeIds = await this.resolveStaffStoreIds(parent.id, dto.storeIds);
await this.prisma.$transaction([
this.prisma.storeAccountStore.deleteMany({ where: { storeAccountId: staff.id } }),
this.prisma.storeAccountStore.createMany({
data: storeIds.map((storeId) => ({ storeAccountId: staff.id, storeId })),
}),
this.prisma.storeAccount.update({ where: { id: staff.id }, data }),
]);
} else if (Object.keys(data).length) {
await this.prisma.storeAccount.update({ where: { id: staff.id }, data });
}
const updated = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: staff.id },
include: this.staffBindingsInclude,
});
return serializeBigInt(this.mapStoreStaff(updated));
}
/** HQ 删除门店子账号(非主账号) */
async deleteStoreStaff(parentAccountId: bigint, staffId: bigint, actorId: bigint) {
await this.assertStoreAccountInScope(actorId, parentAccountId);
const parent = await this.prisma.storeAccount.findUnique({ where: { id: parentAccountId } });
if (!parent || parent.isPrimary !== 1) {
throw new BadRequestException('主账号不存在');
}
const staff = await this.prisma.storeAccount.findFirst({
where: { id: staffId, parentAccountId, isPrimary: 0 },
});
if (!staff) throw new NotFoundException('子账号不存在');
const parent = await this.assertPrimaryStoreAccount(parentAccountId, actorId);
const staff = await this.assertStaffOwned(parent.id, staffId);
const pending = await this.prisma.redeemPendingRecord.count({
where: { storeAccountId: staffId },
@@ -1139,7 +1230,76 @@ export class AdminStoresService {
throw new BadRequestException('该子账号仍有待处理核销单,无法删除');
}
await this.prisma.storeAccount.delete({ where: { id: staffId } });
await this.prisma.storeAccount.delete({ where: { id: staff.id } });
return { ok: true };
}
private readonly staffBindingsInclude = {
bindings: {
include: {
store: { select: { id: true, name: true, status: true } },
},
},
} as const;
private async assertPrimaryStoreAccount(parentAccountId: bigint, actorId: bigint) {
await this.assertStoreAccountInScope(actorId, parentAccountId);
const parent = await this.prisma.storeAccount.findUnique({ where: { id: parentAccountId } });
if (!parent || parent.isPrimary !== 1) {
throw new BadRequestException('主账号不存在');
}
return parent;
}
private async assertStaffOwned(parentAccountId: bigint, staffId: bigint) {
const staff = await this.prisma.storeAccount.findFirst({
where: { id: staffId, parentAccountId, isPrimary: 0 },
});
if (!staff) throw new NotFoundException('子账号不存在');
return staff;
}
/** 子账号只能绑定主账号已管理的门店 */
private async resolveStaffStoreIds(primaryAccountId: bigint, storeIds: string[]) {
const unique = [...new Set(storeIds.map((id) => id.trim()).filter(Boolean))];
if (!unique.length) throw new BadRequestException('请至少绑定一家门店');
const ids = unique.map((id) => BigInt(id));
const owned = await this.prisma.storeAccountStore.findMany({
where: { storeAccountId: primaryAccountId, storeId: { in: ids } },
select: { storeId: true },
});
if (owned.length !== ids.length) {
throw new BadRequestException('只能绑定主账号已管理的门店');
}
return ids;
}
private mapStoreStaff(row: {
id: bigint;
name: string;
phone: string;
staffRole: string | null;
permissions: Prisma.JsonValue;
status: string;
lastLoginAt: Date | null;
createdAt: Date;
bindings: Array<{ store: { id: bigint; name: string; status: string } }>;
}) {
return {
id: row.id.toString(),
name: row.name,
phone: row.phone,
staffRole: row.staffRole ?? StoreStaffRole.CASHIER,
permissions: Array.isArray(row.permissions) ? row.permissions : [],
status: row.status,
storeIds: row.bindings.map((b) => b.store.id.toString()),
stores: row.bindings.map((b) => ({
id: b.store.id.toString(),
name: b.store.name,
status: b.store.status,
})),
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
}
@@ -10,6 +10,7 @@ import { Prisma, type WecomReportPush } from '@prisma/client';
import {
formatWecomReportMarkdown,
isWecomReportKind,
wecomReportCountedOrderWhere,
wecomReportCutoff,
wecomReportPeriod,
wecomReportShouldFire,
@@ -274,6 +275,7 @@ export class AdminWecomReportsService implements OnModuleInit {
private async loadStats(start: Date, cutoff: Date): Promise<WecomReportStats> {
const userBase = { status: 1, mergedIntoUserId: null } as const;
const partnerBase = { isPrimary: 1 } as const;
const countedOrder = wecomReportCountedOrderWhere();
const paid = { payStatus: 'PAID' as const };
const [
@@ -304,15 +306,15 @@ export class AdminWecomReportsService implements OnModuleInit {
}),
this.prisma.store.count({ where: { createdAt: { lt: cutoff } } }),
this.prisma.store.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
this.prisma.order.count({ where: { createdAt: { lt: cutoff } } }),
this.prisma.order.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
this.prisma.order.count({ where: { ...countedOrder, createdAt: { lt: cutoff } } }),
this.prisma.order.count({ where: { ...countedOrder, createdAt: { gte: start, lt: cutoff } } }),
this.prisma.order.aggregate({
_sum: { payAmount: true },
where: { ...paid, paidAt: { lt: cutoff } },
where: { ...countedOrder, ...paid, paidAt: { lt: cutoff } },
}),
this.prisma.order.aggregate({
_sum: { payAmount: true },
where: { ...paid, paidAt: { gte: start, lt: cutoff } },
where: { ...countedOrder, ...paid, paidAt: { gte: start, lt: cutoff } },
}),
this.prisma.redeemRecord.count({ where: { createdAt: { lt: cutoff } } }),
this.prisma.redeemRecord.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
@@ -1,6 +1,7 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsBoolean,
IsIn,
@@ -16,6 +17,7 @@ import {
ValidateIf,
ValidateNested,
} from 'class-validator';
import { AccountStatus, StoreStaffRole } from '@dukang/shared-types';
export class UpdateStoreStatusDto {
@IsString()
@@ -341,6 +343,61 @@ export class UpdateStoreAccountDto {
status?: string;
}
export class CreateStoreAccountStaffDto {
@IsString()
@IsNotEmpty()
phone: string;
@IsString()
@IsNotEmpty()
name: string;
@IsArray()
@ArrayMinSize(1)
@IsString({ each: true })
storeIds: string[];
@IsOptional()
@IsString()
@IsIn(Object.values(StoreStaffRole))
staffRole?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
permissions?: string[];
}
export class UpdateStoreAccountStaffDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
@IsIn(Object.values(StoreStaffRole))
staffRole?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
permissions?: string[];
@IsOptional()
@IsString()
@IsIn(Object.values(AccountStatus))
status?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
storeIds?: string[];
}
export class CreatePartnerDto {
@IsString()
@IsNotEmpty()
@@ -0,0 +1,354 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
FINANCE_BANK_ACCOUNT_TYPES,
type FinanceBankAccountDto,
type FinanceBankAccountInputDto,
type FinanceBankAccountType,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { loadWineryBankConfig } from '../../common/store/store-bank.util';
import {
buildFinanceBankAccountsPdf,
buildFinanceBankAccountsXlsx,
buildFinanceBankExportFilename,
} from './finance-bank-export.util';
const BANK_NO_RE = /^\d{8,32}$/;
const TYPE_ORDER: Record<FinanceBankAccountType, number> = {
STORE: 0,
WINERY: 1,
PARTNER: 2,
LOGISTICS: 3,
OTHER: 4,
};
const SOURCE_NOTE_TYPES = ['STORE', 'WINERY', 'PARTNER', 'LOGISTICS'] as const;
export type FinanceBankAccountListQuery = {
type?: string;
cityId?: string;
keyword?: string;
page?: number;
pageSize?: number;
};
@Injectable()
export class FinanceBankAccountService {
constructor(private readonly prisma: PrismaService) {}
async list(query: FinanceBankAccountListQuery) {
const page = Math.max(1, query.page ?? 1);
const pageSize = Math.min(100, Math.max(1, query.pageSize ?? 20));
const all = await this.collectFiltered(query);
const start = (page - 1) * pageSize;
return {
items: all.slice(start, start + pageSize),
total: all.length,
page,
pageSize,
};
}
async export(query: FinanceBankAccountListQuery & { format?: string }) {
const format = query.format === 'pdf' ? 'pdf' : 'xlsx';
const rows = await this.collectFiltered(query);
if (!rows.length) throw new BadRequestException('没有可导出的银行账户');
const buffer =
format === 'pdf' ? await buildFinanceBankAccountsPdf(rows) : await buildFinanceBankAccountsXlsx(rows);
return {
filename: buildFinanceBankExportFilename(format, rows.length),
mimeType:
format === 'pdf'
? 'application/pdf'
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
contentBase64: buffer.toString('base64'),
count: rows.length,
};
}
async createOther(dto: FinanceBankAccountInputDto) {
const data = this.normalizeInput(dto);
const created = await this.prisma.financeBankAccount.create({
data: {
name: data.name,
bankAccountName: data.bankAccountName,
bankAccountNo: data.bankAccountNo,
bankBranch: data.bankBranch,
remark: data.remark,
status: 'ACTIVE',
},
});
return this.serializeOther(created, created.remark);
}
async updateOther(id: bigint, dto: FinanceBankAccountInputDto) {
const existing = await this.prisma.financeBankAccount.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('账户不存在');
const data = this.normalizeInput(dto);
const updated = await this.prisma.financeBankAccount.update({
where: { id },
data: {
name: data.name,
bankAccountName: data.bankAccountName,
bankAccountNo: data.bankAccountNo,
bankBranch: data.bankBranch,
remark: data.remark,
},
});
return this.serializeOther(updated, updated.remark);
}
async removeOther(id: bigint) {
const existing = await this.prisma.financeBankAccount.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('账户不存在');
await this.prisma.financeBankAccount.delete({ where: { id } });
return { ok: true };
}
async updateRemark(compositeId: string, remarkRaw?: string) {
const parsed = this.parseCompositeId(compositeId);
const remark = remarkRaw?.trim() || null;
if (remark && remark.length > 256) throw new BadRequestException('备注最多 256 字');
if (parsed.type === 'OTHER') {
const id = this.parseBigIntId(parsed.sourceId, '账户不存在');
const existing = await this.prisma.financeBankAccount.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('账户不存在');
const updated = await this.prisma.financeBankAccount.update({
where: { id },
data: { remark },
});
return this.serializeOther(updated, updated.remark);
}
const ownerType = parsed.type;
if (remark) {
await this.prisma.financeBankAccountNote.upsert({
where: { ownerType_sourceId: { ownerType, sourceId: parsed.sourceId } },
create: { ownerType, sourceId: parsed.sourceId, remark },
update: { remark },
});
} else {
await this.prisma.financeBankAccountNote.deleteMany({
where: { ownerType, sourceId: parsed.sourceId },
});
}
return { ok: true, id: compositeId, remark };
}
private async collectFiltered(query: FinanceBankAccountListQuery): Promise<FinanceBankAccountDto[]> {
const typeFilter = this.parseType(query.type);
const cityId = query.cityId?.trim() || '';
const keyword = query.keyword?.trim().toLowerCase() || '';
const rows = await this.collectAll();
return rows
.filter((row) => {
if (typeFilter && row.type !== typeFilter) return false;
if (cityId && row.cityId !== cityId) return false;
if (!keyword) return true;
const hay = [row.ownerName, row.bankAccountName, row.bankAccountNo, row.bankBranch ?? '', row.remark ?? '']
.join(' ')
.toLowerCase();
return hay.includes(keyword);
})
.sort((a, b) => {
const t = TYPE_ORDER[a.type] - TYPE_ORDER[b.type];
if (t !== 0) return t;
const city = (a.cityName ?? '').localeCompare(b.cityName ?? '', 'zh');
if (city !== 0) return city;
return a.ownerName.localeCompare(b.ownerName, 'zh');
});
}
private async collectAll(): Promise<FinanceBankAccountDto[]> {
const [storeRows, partnerRows, logisticsRows, otherRows, notes, winery] = await Promise.all([
this.prisma.storeBankAccount.findMany({
where: { status: 'ACTIVE' },
include: { store: { select: { id: true, name: true, cityId: true, cityName: true } } },
orderBy: { id: 'asc' },
}),
this.prisma.partnerAccount.findMany({
where: { status: 'ACTIVE', parentAccountId: null, isPrimary: 1 },
select: {
id: true,
name: true,
companyName: true,
cityId: true,
bankAccountName: true,
bankAccountNo: true,
bankBranch: true,
city: { select: { name: true } },
},
}),
this.prisma.fulfillmentProvider.findMany({
where: { status: 'ACTIVE' },
select: {
id: true,
name: true,
bankAccountName: true,
bankAccountNo: true,
bankBranch: true,
},
}),
this.prisma.financeBankAccount.findMany({
where: { status: 'ACTIVE' },
orderBy: { id: 'asc' },
}),
this.prisma.financeBankAccountNote.findMany(),
loadWineryBankConfig(this.prisma),
]);
const noteMap = new Map(notes.map((n) => [`${n.ownerType}:${n.sourceId}`, n.remark]));
const items: FinanceBankAccountDto[] = [];
for (const row of storeRows) {
const name = row.bankAccountName.trim();
const no = row.bankAccountNo.replace(/\s+/g, '');
if (!name || !no) continue;
const sourceId = row.id.toString();
items.push({
id: `STORE:${sourceId}`,
type: 'STORE',
ownerName: row.store.name,
ownerId: row.store.id.toString(),
cityId: row.store.cityId.toString(),
cityName: row.store.cityName,
bankAccountName: name,
bankAccountNo: no,
bankBranch: row.bankBranch,
remark: noteMap.get(`STORE:${sourceId}`) ?? null,
isDefault: row.isDefault === 1,
editable: false,
});
}
const wineryName = winery.bankAccountName?.trim() ?? '';
const wineryNo = winery.bankAccountNo?.replace(/\s+/g, '') ?? '';
if (wineryName && wineryNo) {
items.push({
id: 'WINERY:winery',
type: 'WINERY',
ownerName: '酒厂',
ownerId: null,
cityId: null,
cityName: null,
bankAccountName: wineryName,
bankAccountNo: wineryNo,
bankBranch: winery.bankBranch?.trim() || winery.bankName?.trim() || null,
remark: noteMap.get('WINERY:winery') ?? null,
editable: false,
});
}
for (const row of partnerRows) {
const name = row.bankAccountName?.trim() ?? '';
const no = row.bankAccountNo?.replace(/\s+/g, '') ?? '';
if (!name || !no) continue;
const sourceId = row.id.toString();
items.push({
id: `PARTNER:${sourceId}`,
type: 'PARTNER',
ownerName: row.companyName?.trim() || row.name,
ownerId: sourceId,
cityId: row.cityId?.toString() ?? null,
cityName: row.city?.name ?? null,
bankAccountName: name,
bankAccountNo: no,
bankBranch: row.bankBranch,
remark: noteMap.get(`PARTNER:${sourceId}`) ?? null,
editable: false,
});
}
for (const row of logisticsRows) {
const name = row.bankAccountName?.trim() ?? '';
const no = row.bankAccountNo?.replace(/\s+/g, '') ?? '';
if (!name || !no) continue;
const sourceId = row.id.toString();
items.push({
id: `LOGISTICS:${sourceId}`,
type: 'LOGISTICS',
ownerName: row.name,
ownerId: sourceId,
cityId: null,
cityName: null,
bankAccountName: name,
bankAccountNo: no,
bankBranch: row.bankBranch,
remark: noteMap.get(`LOGISTICS:${sourceId}`) ?? null,
editable: false,
});
}
for (const row of otherRows) {
items.push(this.serializeOther(row, row.remark));
}
return items;
}
private serializeOther(
row: {
id: bigint;
name: string | null;
bankAccountName: string;
bankAccountNo: string;
bankBranch: string | null;
},
remark: string | null,
): FinanceBankAccountDto {
return {
id: `OTHER:${row.id.toString()}`,
type: 'OTHER',
ownerName: row.name?.trim() || row.bankAccountName,
ownerId: row.id.toString(),
cityId: null,
cityName: null,
bankAccountName: row.bankAccountName,
bankAccountNo: row.bankAccountNo,
bankBranch: row.bankBranch,
remark,
editable: true,
};
}
private normalizeInput(dto: FinanceBankAccountInputDto) {
const bankAccountName = dto.bankAccountName?.trim() ?? '';
const bankAccountNo = dto.bankAccountNo?.replace(/\s+/g, '') ?? '';
const bankBranch = dto.bankBranch?.trim() || null;
const name = dto.name?.trim() || null;
const remark = dto.remark?.trim() || null;
if (!bankAccountName) throw new BadRequestException('请填写户名');
if (!BANK_NO_RE.test(bankAccountNo)) throw new BadRequestException('请填写正确的银行账号');
if (remark && remark.length > 256) throw new BadRequestException('备注最多 256 字');
return { name, bankAccountName, bankAccountNo, bankBranch, remark };
}
private parseType(raw?: string): FinanceBankAccountType | null {
if (!raw?.trim()) return null;
const value = raw.trim().toUpperCase();
if ((FINANCE_BANK_ACCOUNT_TYPES as readonly string[]).includes(value)) {
return value as FinanceBankAccountType;
}
throw new BadRequestException('无效的账户类型');
}
private parseCompositeId(raw: string): { type: FinanceBankAccountType; sourceId: string } {
const idx = raw.indexOf(':');
if (idx <= 0) throw new BadRequestException('无效的账户编号');
const type = this.parseType(raw.slice(0, idx));
const sourceId = raw.slice(idx + 1).trim();
if (!type || !sourceId) throw new BadRequestException('无效的账户编号');
if (type === 'WINERY' && sourceId !== 'winery') throw new BadRequestException('无效的账户编号');
if (type !== 'OTHER' && !(SOURCE_NOTE_TYPES as readonly string[]).includes(type)) {
throw new BadRequestException('无效的账户编号');
}
return { type, sourceId };
}
private parseBigIntId(raw: string, message: string): bigint {
if (!/^\d+$/.test(raw)) throw new NotFoundException(message);
return BigInt(raw);
}
}
@@ -0,0 +1,83 @@
import * as fs from 'fs';
import * as path from 'path';
import ExcelJS from 'exceljs';
import PDFDocument from 'pdfkit';
import {
FINANCE_BANK_ACCOUNT_TYPE_LABELS,
type FinanceBankAccountDto,
} from '@dukang/shared-types';
import { pickExportColumns, type ExportColumnDef } from '../../common/export/column-export.util';
function resolvePdfFontPath(): string {
const candidates = [
process.env.EXPORT_PDF_FONT_PATH,
path.join(process.cwd(), 'assets', 'fonts', 'NotoSansSC-Regular.otf'),
path.join(process.cwd(), 'assets', 'fonts', 'simhei.ttf'),
path.join(process.cwd(), 'assets', 'fonts', 'msyh.ttc'),
path.join(process.cwd(), 'dist', 'assets', 'fonts', 'simhei.ttf'),
'/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
'C:\\Windows\\Fonts\\simhei.ttf',
'C:\\Windows\\Fonts\\msyh.ttc',
].filter(Boolean) as string[];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
throw new Error('未找到可用于 PDF 的中文字体,请将字体文件放到 server/dukang-api/assets/fonts/');
}
function columnDefs(): ExportColumnDef<FinanceBankAccountDto>[] {
return [
{ key: 'type', header: '类型', value: (r) => FINANCE_BANK_ACCOUNT_TYPE_LABELS[r.type] },
{ key: 'ownerName', header: '归属', value: (r) => r.ownerName },
{ key: 'cityName', header: '城市', value: (r) => r.cityName ?? '' },
{ key: 'bankAccountName', header: '户名', value: (r) => r.bankAccountName },
{ key: 'bankAccountNo', header: '银行账号', value: (r) => r.bankAccountNo },
{ key: 'bankBranch', header: '开户行', value: (r) => r.bankBranch ?? '' },
{ key: 'isDefault', header: '默认', value: (r) => (r.type === 'STORE' ? (r.isDefault ? '是' : '否') : '') },
{ key: 'remark', header: '备注', value: (r) => r.remark ?? '' },
];
}
export async function buildFinanceBankAccountsXlsx(rows: FinanceBankAccountDto[]): Promise<Buffer> {
const cols = pickExportColumns(columnDefs());
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('银行账户');
sheet.addRow(cols.map((c) => c.header));
for (const row of rows) {
sheet.addRow(cols.map((c) => c.value(row)));
}
sheet.columns.forEach((col) => {
col.width = 18;
});
const buffer = await workbook.xlsx.writeBuffer();
return Buffer.from(buffer);
}
export async function buildFinanceBankAccountsPdf(rows: FinanceBankAccountDto[]): Promise<Buffer> {
const cols = pickExportColumns(columnDefs());
const fontPath = resolvePdfFontPath();
const chunks: Buffer[] = [];
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 24, bufferPages: true });
doc.on('data', (c) => chunks.push(c as Buffer));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
doc.font(fontPath);
doc.fontSize(12).text('银行账户');
doc.moveDown(0.4);
doc.fontSize(9).text(cols.map((c) => c.header).join(' | '));
doc.moveDown(0.3);
for (const row of rows) {
doc.text(cols.map((c) => String(c.value(row))).join(' | '));
}
doc.end();
});
}
export function buildFinanceBankExportFilename(format: 'xlsx' | 'pdf', count: number): string {
const stamp = new Date().toISOString().slice(0, 10);
return `银行账户_${stamp}_${count}.${format}`;
}
@@ -1,5 +1,7 @@
import { BadRequestException, Body, Controller, ForbiddenException, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { BadRequestException, Body, Controller, Delete, ForbiddenException, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import { FinanceBankAccountService } from './finance-bank-account.service';
import { HqPermissionGuard, RequireHqPermissions } from '../../common/guards/hq-permission.guard';
import { parseShanghaiYmd } from '@dukang/domain';
import { DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
import { SettlementService } from './settlement.service';
@@ -37,6 +39,40 @@ class UpdatePartnerBankDto {
bankBranch!: string;
}
class FinanceBankAccountBodyDto {
@IsOptional()
@IsString()
@MaxLength(128)
name?: string;
@IsString()
@IsNotEmpty({ message: '请填写户名' })
@MaxLength(64)
bankAccountName!: string;
@IsString()
@IsNotEmpty({ message: '请填写银行账号' })
@MaxLength(32)
bankAccountNo!: string;
@IsOptional()
@IsString()
@MaxLength(128)
bankBranch?: string;
@IsOptional()
@IsString()
@MaxLength(256)
remark?: string;
}
class FinanceBankAccountRemarkBodyDto {
@IsOptional()
@IsString()
@MaxLength(256)
remark?: string;
}
@Controller('partner/settlement')
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
export class SettlementController {
@@ -627,6 +663,56 @@ export class AdminLogisticsBillController {
}
}
@Controller('admin/finance/bank-accounts')
@UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('finance')
export class AdminFinanceBankAccountController {
constructor(private readonly financeBankAccounts: FinanceBankAccountService) {}
@Get()
list(@Query() query: Record<string, string>) {
return this.financeBankAccounts.list({
type: query.type,
cityId: query.cityId,
keyword: query.keyword,
page: query.page ? Number(query.page) : 1,
pageSize: query.pageSize ? Number(query.pageSize) : 20,
});
}
@Get('export')
export(@Query() query: Record<string, string>) {
return this.financeBankAccounts.export({
type: query.type,
cityId: query.cityId,
keyword: query.keyword,
format: query.format,
});
}
@Post()
create(@Body() dto: FinanceBankAccountBodyDto) {
return this.financeBankAccounts.createOther(dto);
}
@Put('other/:id')
updateOther(@Param('id') id: string, @Body() dto: FinanceBankAccountBodyDto) {
if (!/^\d+$/.test(id)) throw new BadRequestException('无效的账户编号');
return this.financeBankAccounts.updateOther(BigInt(id), dto);
}
@Delete('other/:id')
removeOther(@Param('id') id: string) {
if (!/^\d+$/.test(id)) throw new BadRequestException('无效的账户编号');
return this.financeBankAccounts.removeOther(BigInt(id));
}
@Put(':id/remark')
updateRemark(@Param('id') id: string, @Body() dto: FinanceBankAccountRemarkBodyDto) {
return this.financeBankAccounts.updateRemark(id, dto.remark);
}
}
@Controller('partner/me')
@UseGuards(JwtAuthGuard)
export class PartnerMeController {
@@ -4,7 +4,9 @@ import { AnalyticsModule } from '../analytics/analytics.module';
import { CityScopeModule } from '../city-scope/city-scope.module';
import { FulfillmentModule } from '../fulfillment/fulfillment.module';
import { SettlementService } from './settlement.service';
import { FinanceBankAccountService } from './finance-bank-account.service';
import {
AdminFinanceBankAccountController,
AdminLogisticsBillController,
AdminPartnerBillController,
AdminStoreBillController,
@@ -32,8 +34,9 @@ import {
AdminPartnerBillController,
AdminWineryBillController,
AdminLogisticsBillController,
AdminFinanceBankAccountController,
],
providers: [SettlementService],
providers: [SettlementService, FinanceBankAccountService],
exports: [SettlementService],
})
export class SettlementModule {}
@@ -54,6 +54,7 @@ import {
} from '../../common/store/store-bank.util';
import {
capBillBlocks,
filterNonZeroWecomBills,
formatLogisticsBillWecomBlock,
formatPartnerBillWecomBlock,
formatPartnerDashLabel,
@@ -199,6 +200,15 @@ function round2(n: number) {
return Math.round(n * 100) / 100;
}
function formatPartnerBillPeriodLabel(weekStartYmd: string): string {
try {
const { periodStart, periodEnd } = resolvePartnerWeekPeriod(weekStartYmd);
return `${shanghaiYmd(periodStart)} ~ ${shanghaiYmd(periodEnd)}`;
} catch {
return weekStartYmd;
}
}
function sumAmounts(rows: Array<{ amount: number }>): string {
return rows.reduce((s, r) => s + Number(r.amount || 0), 0).toFixed(2);
}
@@ -345,21 +355,25 @@ export class SettlementService implements OnModuleInit {
cityName: string;
partnerLabel: string;
orderCount?: number;
orderAmount?: number;
redeemCount?: number;
redeemAmount?: number;
orderCommission: number;
redeemCommission: number;
amount: number;
bank?: WecomBankLike | null;
}>,
) {
if (!rows.length) return;
const blocks = rows.map((r) => formatPartnerBillWecomBlock(r));
const billed = filterNonZeroWecomBills(rows);
if (!billed.length) return;
const periodLabel = formatPartnerBillPeriodLabel(period);
const blocks = billed.map((r) => formatPartnerBillWecomBlock({ ...r, period: periodLabel }));
void this.wecomPush.dispatchEvent(
'finance.partner_bill',
{
period,
billCount: String(rows.length),
totalAmount: sumAmounts(rows),
period: periodLabel,
billCount: String(billed.length),
totalAmount: sumAmounts(billed),
billSummary: capBillBlocks(blocks),
},
{ handlePath: '/finance/partner-bills' },
@@ -1895,7 +1909,9 @@ export class SettlementService implements OnModuleInit {
cityName: string;
partnerLabel: string;
orderCount?: number;
orderAmount?: number;
redeemCount?: number;
redeemAmount?: number;
orderCommission: number;
redeemCommission: number;
amount: number;
@@ -1915,7 +1931,9 @@ export class SettlementService implements OnModuleInit {
cityName: p.city?.name?.trim() || '—',
partnerLabel: formatPartnerDashLabel(p.companyName, p.name),
orderCount: Number(bill.orderCount ?? 0),
orderAmount: Number(bill.orderAmount ?? 0),
redeemCount: Number(bill.redeemCount ?? 0),
redeemAmount: Number(bill.redeemAmount ?? 0),
orderCommission: Number(bill.orderCommission),
redeemCommission: Number(bill.redeemCommission),
amount: Number(bill.totalAmount),
@@ -2016,6 +2034,7 @@ export class SettlementService implements OnModuleInit {
};
});
const orderCommission = orderRows.reduce((sum, r) => sum + r.commission, 0);
const orderAmount = round2(orderRows.reduce((sum, r) => sum + r.baseAmount, 0));
const stores = await this.prisma.store.findMany({
where: { partnerAccountId: primary.id },
@@ -2047,6 +2066,7 @@ export class SettlementService implements OnModuleInit {
};
});
const redeemCommission = redeemRows.reduce((sum, r) => sum + r.commission, 0);
const redeemAmount = round2(redeemRows.reduce((sum, r) => sum + r.baseAmount, 0));
const totalAmount = round2(orderCommission + redeemCommission);
const itemRows = [...orderRows, ...redeemRows];
@@ -2111,7 +2131,9 @@ export class SettlementService implements OnModuleInit {
cityName,
partnerLabel: formatPartnerDashLabel(primary.companyName, primary.name),
orderCount: orders.length,
orderAmount,
redeemCount: redeems.length,
redeemAmount,
orderCommission: round2(orderCommission),
redeemCommission: round2(redeemCommission),
amount: totalAmount,
@@ -2128,6 +2150,8 @@ export class SettlementService implements OnModuleInit {
...bill,
orderCount: orders.length,
redeemCount: redeems.length,
orderAmount,
redeemAmount,
});
}
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import {
filterNonZeroWecomBills,
formatPartnerBillWecomBlock,
formatWecomKvTable,
} from './wecom-bill-digest';
describe('filterNonZeroWecomBills', () => {
it('drops zero-amount bills', () => {
expect(
filterNonZeroWecomBills([
{ amount: 0, name: 'zero' },
{ amount: 12.5, name: 'keep' },
{ amount: 0.0, name: 'also-zero' },
]).map((r) => r.name),
).toEqual(['keep']);
});
});
describe('formatWecomKvTable', () => {
it('uses the title as table header and fields as rows', () => {
const md = formatWecomKvTable('郑州某某商贸-张三', [
['账期', '2026-07-27 ~ 2026-08-02'],
['累计金额', '¥120.00'],
]);
expect(md).toBe(
[
'| 郑州某某商贸-张三 | |',
'| :--- | ---: |',
'| 账期 | 2026-07-27 ~ 2026-08-02 |',
'| 累计金额 | ¥120.00 |',
].join('\n'),
);
});
});
describe('formatPartnerBillWecomBlock', () => {
it('renders partner company-name header and settlement fields', () => {
const md = formatPartnerBillWecomBlock({
partnerLabel: '郑州某某商贸-张三',
period: '2026-07-27 ~ 2026-08-02',
orderCount: 8,
orderAmount: 1000,
orderCommission: 100,
redeemCount: 12,
redeemAmount: 200,
redeemCommission: 20,
amount: 120,
bank: {
bankAccountName: '张三',
bankAccountNo: '6222000011112222',
bankBranch: '郑州支行',
},
});
expect(md).toContain('| 郑州某某商贸-张三 | |');
expect(md).toContain('| 账期 | 2026-07-27 ~ 2026-08-02 |');
expect(md).toContain('| 订单数量 | 8 |');
expect(md).toContain('| 订单金额 | ¥1000.00 |');
expect(md).toContain('| 订单佣金 | ¥100.00 |');
expect(md).toContain('| 核销单数量 | 12 |');
expect(md).toContain('| 核销金额 | ¥200.00 |');
expect(md).toContain('| 核销佣金 | ¥20.00 |');
expect(md).toContain('| 累计金额 | ¥120.00 |');
expect(md).toContain('| 收款人 | 张三 |');
expect(md).toContain('| 收款银行账号 | 6222000011112222 |');
expect(md).toContain('| 开户行 | 郑州支行 |');
});
});
@@ -1,4 +1,4 @@
/** 企微结算账单正文:字段块 + 字节上限(机器人 markdown 约 4096 字节) */
/** 企微结算账单正文:字段块 / markdown_v2 表格 + 字节上限(机器人约 4096 字节) */
export type WecomBankLike = {
bankAccountName?: string | null;
@@ -51,6 +51,27 @@ export function formatWecomFieldBlock(rows: Array<[string, string]>): string {
return rows.map(([k, v]) => `${k}${v}`).join('\n');
}
function escapeWecomTableCell(v: string): string {
const text = String(v ?? '')
.replace(/\|/g, '')
.replace(/\r?\n/g, ' ')
.trim();
return text || '—';
}
/** 企微 markdown_v2 键值表:表头为标题,下列为字段/值 */
export function formatWecomKvTable(header: string, rows: Array<[string, string]>): string {
const lines = [`| ${escapeWecomTableCell(header)} | |`, '| :--- | ---: |'];
for (const [k, v] of rows) {
lines.push(`| ${escapeWecomTableCell(k)} | ${escapeWecomTableCell(v)} |`);
}
return lines.join('\n');
}
export function filterNonZeroWecomBills<T extends { amount: number }>(rows: T[]): T[] {
return rows.filter((r) => Number(r.amount) > 0);
}
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 '—';
@@ -104,31 +125,31 @@ export function formatStoreBillWecomBlock(row: {
}
export function formatPartnerBillWecomBlock(row: {
cityName: string;
partnerLabel: string;
period: string;
orderCount?: number;
redeemCount?: number;
orderAmount?: number;
orderCommission: number;
redeemCount?: number;
redeemAmount?: 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(
return formatWecomKvTable(row.partnerLabel || '—', [
['账期', row.period || '—'],
['订单数量', String(row.orderCount ?? 0)],
['订单金额', formatYuan(row.orderAmount ?? 0)],
['订单佣金', formatYuan(row.orderCommission)],
['核销单数量', String(row.redeemCount ?? 0)],
['核销金额', formatYuan(row.redeemAmount ?? 0)],
['核销佣金', formatYuan(row.redeemCommission)],
['账单', formatYuan(row.amount)],
['累计金额', formatYuan(row.amount)],
['收款人', bank.payee],
['收款账户', bank.accountNo],
['收款开户行', bank.bankBranch],
);
return formatWecomFieldBlock(rows);
['收款银行账号', bank.accountNo],
['开户行', bank.bankBranch],
]);
}
export function formatLogisticsBillWecomBlock(row: {
@@ -8,7 +8,12 @@ import {
import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types';
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types';
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT, validateBusinessHours } from '@dukang/domain';
import {
formatStoreDisplayAddress,
isStoreContactPhone,
STORE_CONTACT_PHONE_HINT,
validateBusinessHours,
} from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { mapStoreCompat } from '../../common/compat/v31-compat';
@@ -123,7 +128,7 @@ export class StoreService {
district?: string | null;
address?: string | null;
}) {
return `${store.province ?? ''}${store.cityName ?? ''}${store.district ?? ''}${store.address ?? ''}`.trim();
return formatStoreDisplayAddress(store, '');
}
/** 缺坐标时用地址正向地理编码并回写 */