v4.0.18版本提交
This commit is contained in:
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user