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 = { 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 { 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 { const [storeBindings, partnerRows, logisticsRows, otherRows, notes, winery] = await Promise.all([ this.prisma.storeAccountStore.findMany({ include: { store: { select: { id: true, name: true, cityId: true, cityName: true } }, storeAccount: { select: { isPrimary: true, bankAccountName: true, bankAccountNo: true, bankBranch: true, }, }, }, orderBy: [{ storeAccount: { isPrimary: 'desc' } }, { storeAccountId: '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[] = []; const seenStores = new Set(); for (const binding of storeBindings) { const sourceId = binding.store.id.toString(); if (seenStores.has(sourceId)) continue; seenStores.add(sourceId); const name = binding.storeAccount.bankAccountName?.trim() ?? ''; const no = binding.storeAccount.bankAccountNo?.replace(/\s+/g, '') ?? ''; if (!name || !no) continue; items.push({ id: `STORE:${sourceId}`, type: 'STORE', ownerName: binding.store.name, ownerId: sourceId, cityId: binding.store.cityId.toString(), cityName: binding.store.cityName, bankAccountName: name, bankAccountNo: no, bankBranch: binding.storeAccount.bankBranch, remark: noteMap.get(`STORE:${sourceId}`) ?? null, 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); } }