366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
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 [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<string>();
|
|
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);
|
|
}
|
|
}
|