@@ -43,6 +43,7 @@
|
||||
"bullmq": "^5.12.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"docx": "^9.5.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^4.21.0",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- v3.5.6: 开发计划任务附件
|
||||
ALTER TABLE `dev_plan_task` ADD COLUMN `attachment_urls` JSON NULL AFTER `support_ticket_id`;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- v3.5.6: 财务账单打款凭证
|
||||
ALTER TABLE `store_bill` ADD COLUMN `payment_ref` VARCHAR(128) NULL AFTER `status`;
|
||||
ALTER TABLE `partner_bill` ADD COLUMN `payment_ref` VARCHAR(128) NULL AFTER `status`;
|
||||
ALTER TABLE `winery_bill` ADD COLUMN `payment_ref` VARCHAR(128) NULL AFTER `status`;
|
||||
ALTER TABLE `logistics_bill` ADD COLUMN `payment_ref` VARCHAR(128) NULL AFTER `status`;
|
||||
@@ -733,6 +733,7 @@ model DevPlanTask {
|
||||
status DevPlanTaskStatus @default(TODO)
|
||||
creatorHqAccountId BigInt @map("creator_hq_account_id") @db.UnsignedBigInt
|
||||
supportTicketId BigInt? @map("support_ticket_id") @db.UnsignedBigInt
|
||||
attachmentUrls Json? @map("attachment_urls")
|
||||
lastDispatchedAt DateTime? @map("last_dispatched_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
completedAt DateTime? @map("completed_at") @db.DateTime(3)
|
||||
@@ -1073,6 +1074,7 @@ model LogisticsBill {
|
||||
settlementMethod LogisticsSettlementMethod @map("settlement_method")
|
||||
pricingSnapshotJson String? @map("pricing_snapshot_json") @db.Text
|
||||
status FinancePayStatus @default(UNPAID)
|
||||
paymentRef String? @map("payment_ref") @db.VarChar(128)
|
||||
paidAt DateTime? @map("paid_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
@@ -1215,6 +1217,7 @@ model PartnerBill {
|
||||
redeemCommission Decimal @default(0) @map("redeem_commission") @db.Decimal(10, 2)
|
||||
totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
|
||||
status PartnerBillStatus @default(PENDING_REVIEW)
|
||||
paymentRef String? @map("payment_ref") @db.VarChar(128)
|
||||
confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3)
|
||||
sentAt DateTime? @map("sent_at") @db.DateTime(3)
|
||||
paidAt DateTime? @map("paid_at") @db.DateTime(3)
|
||||
@@ -1863,6 +1866,7 @@ model StoreBill {
|
||||
settlementRate Decimal @map("settlement_rate") @db.Decimal(5, 4)
|
||||
payoutAmount Decimal @map("payout_amount") @db.Decimal(10, 2)
|
||||
status FinancePayStatus @default(UNPAID)
|
||||
paymentRef String? @map("payment_ref") @db.VarChar(128)
|
||||
paidAt DateTime? @map("paid_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
@@ -1946,6 +1950,7 @@ model WineryBill {
|
||||
wineryRate Decimal @map("winery_rate") @db.Decimal(5, 4)
|
||||
wineryAmount Decimal @map("winery_amount") @db.Decimal(10, 2)
|
||||
status FinancePayStatus @default(UNPAID)
|
||||
paymentRef String? @map("payment_ref") @db.VarChar(128)
|
||||
paidAt DateTime? @map("paid_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
|
||||
export type StoreBankAccountSnapshot = {
|
||||
bankAccountName: string | null;
|
||||
bankAccountNo: string | null;
|
||||
bankBranch: string | null;
|
||||
};
|
||||
|
||||
export async function loadStorePrimaryBank(
|
||||
prisma: PrismaClient,
|
||||
storeId: bigint,
|
||||
): Promise<StoreBankAccountSnapshot | null> {
|
||||
const binding = await prisma.storeAccountStore.findFirst({
|
||||
where: { storeId, storeAccount: { isPrimary: 1 } },
|
||||
include: {
|
||||
storeAccount: {
|
||||
select: { bankAccountName: true, bankAccountNo: true, bankBranch: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (binding) return binding.storeAccount;
|
||||
const fallback = await prisma.storeAccountStore.findFirst({
|
||||
where: { storeId },
|
||||
orderBy: { storeAccountId: 'asc' },
|
||||
include: {
|
||||
storeAccount: {
|
||||
select: { bankAccountName: true, bankAccountNo: true, bankBranch: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return fallback?.storeAccount ?? null;
|
||||
}
|
||||
|
||||
export async function loadStorePrimaryBanksMap(
|
||||
prisma: PrismaClient,
|
||||
storeIds: bigint[],
|
||||
): Promise<Map<string, StoreBankAccountSnapshot>> {
|
||||
const map = new Map<string, StoreBankAccountSnapshot>();
|
||||
if (!storeIds.length) return map;
|
||||
const bindings = await prisma.storeAccountStore.findMany({
|
||||
where: { storeId: { in: storeIds } },
|
||||
include: {
|
||||
storeAccount: {
|
||||
select: {
|
||||
isPrimary: true,
|
||||
bankAccountName: true,
|
||||
bankAccountNo: true,
|
||||
bankBranch: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ storeAccount: { isPrimary: 'desc' } }, { storeAccountId: 'asc' }],
|
||||
});
|
||||
for (const binding of bindings) {
|
||||
const key = String(binding.storeId);
|
||||
if (map.has(key)) continue;
|
||||
map.set(key, {
|
||||
bankAccountName: binding.storeAccount.bankAccountName,
|
||||
bankAccountNo: binding.storeAccount.bankAccountNo,
|
||||
bankBranch: binding.storeAccount.bankBranch,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function loadWineryBankConfig(
|
||||
prisma: PrismaClient,
|
||||
): Promise<StoreBankAccountSnapshot & { bankName?: string | null }> {
|
||||
const keys = [
|
||||
'WINERY_BANK_ACCOUNT_NAME',
|
||||
'WINERY_BANK_NAME',
|
||||
'WINERY_BANK_BRANCH',
|
||||
'WINERY_BANK_ACCOUNT_NO',
|
||||
];
|
||||
const rows = await prisma.systemConfig.findMany({
|
||||
where: { configKey: { in: keys } },
|
||||
select: { configKey: true, value: true },
|
||||
});
|
||||
const map = new Map(rows.map((r) => [r.configKey, r.value]));
|
||||
return {
|
||||
bankAccountName: map.get('WINERY_BANK_ACCOUNT_NAME') ?? null,
|
||||
bankName: map.get('WINERY_BANK_NAME') ?? null,
|
||||
bankBranch: map.get('WINERY_BANK_BRANCH') ?? null,
|
||||
bankAccountNo: map.get('WINERY_BANK_ACCOUNT_NO') ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import {
|
||||
DEV_PLAN_TASK_STATUS_LABELS,
|
||||
DEV_PLAN_TASK_TYPE_LABELS,
|
||||
type DevPlanTaskStatusDto,
|
||||
type DevPlanTaskTypeDto,
|
||||
} from '@dukang/shared-types';
|
||||
import ExcelJS from 'exceljs';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import PDFDocument from 'pdfkit';
|
||||
import {
|
||||
Document,
|
||||
HeadingLevel,
|
||||
ImageRun,
|
||||
Packer,
|
||||
Paragraph,
|
||||
Table,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TextRun,
|
||||
WidthType,
|
||||
} from 'docx';
|
||||
|
||||
const EXPORT_HEADERS = [
|
||||
'任务编号',
|
||||
'类型',
|
||||
'状态',
|
||||
'内容',
|
||||
'关联工单',
|
||||
'创建人',
|
||||
'创建时间',
|
||||
'完成时间',
|
||||
'附件',
|
||||
] as const;
|
||||
|
||||
export type DevPlanTaskExportRow = {
|
||||
taskNo: string;
|
||||
type: string;
|
||||
status: string;
|
||||
content: string;
|
||||
supportTicketNo: string;
|
||||
creatorName: string;
|
||||
createdAt: string;
|
||||
completedAt: string;
|
||||
attachmentUrls: string[];
|
||||
};
|
||||
|
||||
export type DevPlanExportFormat = 'markdown' | 'docx' | 'xlsx' | 'pdf';
|
||||
|
||||
export function mapTaskToExportRow(task: {
|
||||
taskNo: string;
|
||||
type: DevPlanTaskTypeDto;
|
||||
status: DevPlanTaskStatusDto;
|
||||
content: string;
|
||||
supportTicketNo?: string | null;
|
||||
creatorName?: string | null;
|
||||
createdAt: string;
|
||||
completedAt?: string | null;
|
||||
attachmentUrls?: string[] | null;
|
||||
}): DevPlanTaskExportRow {
|
||||
return {
|
||||
taskNo: task.taskNo,
|
||||
type: DEV_PLAN_TASK_TYPE_LABELS[task.type] ?? task.type,
|
||||
status: DEV_PLAN_TASK_STATUS_LABELS[task.status] ?? task.status,
|
||||
content: task.content,
|
||||
supportTicketNo: task.supportTicketNo ?? '',
|
||||
creatorName: task.creatorName ?? '',
|
||||
createdAt: task.createdAt.slice(0, 19).replace('T', ' '),
|
||||
completedAt: task.completedAt ? task.completedAt.slice(0, 19).replace('T', ' ') : '',
|
||||
attachmentUrls: task.attachmentUrls ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function rowToCells(row: DevPlanTaskExportRow): string[] {
|
||||
return [
|
||||
row.taskNo,
|
||||
row.type,
|
||||
row.status,
|
||||
row.content,
|
||||
row.supportTicketNo,
|
||||
row.creatorName,
|
||||
row.createdAt,
|
||||
row.completedAt,
|
||||
row.attachmentUrls.join(' '),
|
||||
];
|
||||
}
|
||||
|
||||
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',
|
||||
'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/');
|
||||
}
|
||||
|
||||
export function buildDevPlanMarkdown(rows: DevPlanTaskExportRow[]): Buffer {
|
||||
const lines: string[] = ['# 开发计划任务导出', ''];
|
||||
rows.forEach((row, index) => {
|
||||
lines.push(`## ${index + 1}. ${row.taskNo} [${row.type}] ${row.status}`);
|
||||
lines.push('');
|
||||
lines.push(row.content);
|
||||
lines.push('');
|
||||
if (row.supportTicketNo) lines.push(`- 关联工单:${row.supportTicketNo}`);
|
||||
if (row.creatorName) lines.push(`- 创建人:${row.creatorName}`);
|
||||
lines.push(`- 创建时间:${row.createdAt}`);
|
||||
if (row.completedAt) lines.push(`- 完成时间:${row.completedAt}`);
|
||||
if (row.attachmentUrls.length) {
|
||||
lines.push('- 附件:');
|
||||
for (const url of row.attachmentUrls) {
|
||||
lines.push(` `);
|
||||
lines.push(` ${url}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
});
|
||||
return Buffer.from(lines.join('\n'), 'utf8');
|
||||
}
|
||||
|
||||
async function fetchImageBuffer(url: string): Promise<Buffer | null> {
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
||||
if (!res.ok) return null;
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
return buf.length > 0 && buf.length < 5 * 1024 * 1024 ? buf : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildDevPlanDocx(rows: DevPlanTaskExportRow[]): Promise<Buffer> {
|
||||
const children: (Paragraph | Table)[] = [
|
||||
new Paragraph({ text: '开发计划任务导出', heading: HeadingLevel.HEADING_1 }),
|
||||
];
|
||||
|
||||
for (const row of rows) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
text: `${row.taskNo} [${row.type}] ${row.status}`,
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
}),
|
||||
new Paragraph({ children: [new TextRun(row.content)] }),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun(`创建人:${row.creatorName || '—'} 创建:${row.createdAt}`),
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (row.supportTicketNo) {
|
||||
children.push(new Paragraph({ children: [new TextRun(`关联工单:${row.supportTicketNo}`)] }));
|
||||
}
|
||||
if (row.completedAt) {
|
||||
children.push(new Paragraph({ children: [new TextRun(`完成时间:${row.completedAt}`)] }));
|
||||
}
|
||||
for (const url of row.attachmentUrls) {
|
||||
const img = await fetchImageBuffer(url);
|
||||
if (img) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new ImageRun({
|
||||
data: img,
|
||||
transformation: { width: 320, height: 240 },
|
||||
type: 'png',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
children.push(new Paragraph({ children: [new TextRun(url)] }));
|
||||
}
|
||||
}
|
||||
children.push(new Paragraph({ text: '' }));
|
||||
}
|
||||
|
||||
const doc = new Document({
|
||||
sections: [{ children }],
|
||||
});
|
||||
return Packer.toBuffer(doc);
|
||||
}
|
||||
|
||||
export async function buildDevPlanXlsx(rows: DevPlanTaskExportRow[]): Promise<Buffer> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('开发任务');
|
||||
sheet.addRow([...EXPORT_HEADERS]);
|
||||
for (const row of rows) {
|
||||
sheet.addRow(rowToCells(row));
|
||||
}
|
||||
sheet.columns.forEach((col, i) => {
|
||||
col.width = i === 3 || i === 8 ? 40 : 16;
|
||||
});
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
export async function buildDevPlanPdf(rows: DevPlanTaskExportRow[]): Promise<Buffer> {
|
||||
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', (chunk) => chunks.push(chunk as Buffer));
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
doc.registerFont('zh', fontPath);
|
||||
doc.font('zh');
|
||||
|
||||
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||
const colWidths = [72, 48, 48, 180, 72, 56, 78, 78, 120];
|
||||
const scale = pageWidth / colWidths.reduce((s, w) => s + w, 0);
|
||||
const widths = colWidths.map((w) => w * scale);
|
||||
const rowHeight = 36;
|
||||
let y = doc.page.margins.top;
|
||||
|
||||
const drawRow = (cells: string[], isHeader = false) => {
|
||||
let x = doc.page.margins.left;
|
||||
const height = isHeader ? 24 : rowHeight;
|
||||
if (y + height > doc.page.height - doc.page.margins.bottom) {
|
||||
doc.addPage({ size: 'A4', layout: 'landscape', margin: 24 });
|
||||
y = doc.page.margins.top;
|
||||
}
|
||||
doc.fontSize(isHeader ? 8 : 7);
|
||||
cells.forEach((cell, index) => {
|
||||
doc.rect(x, y, widths[index], height).stroke('#dddddd');
|
||||
doc.text(cell || '', x + 2, y + 4, {
|
||||
width: widths[index] - 4,
|
||||
height: height - 6,
|
||||
lineBreak: true,
|
||||
});
|
||||
x += widths[index];
|
||||
});
|
||||
y += height;
|
||||
};
|
||||
|
||||
drawRow([...EXPORT_HEADERS], true);
|
||||
for (const row of rows) {
|
||||
drawRow(rowToCells(row));
|
||||
}
|
||||
doc.end();
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDevPlanExportFilename(format: DevPlanExportFormat, count: number): string {
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
const ext = format === 'markdown' ? 'md' : format === 'docx' ? 'docx' : format;
|
||||
return `开发任务导出_${stamp}_${count}条.${ext}`;
|
||||
}
|
||||
|
||||
export function devPlanExportMimeType(format: DevPlanExportFormat): string {
|
||||
switch (format) {
|
||||
case 'markdown':
|
||||
return 'text/markdown;charset=utf-8';
|
||||
case 'docx':
|
||||
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
case 'xlsx':
|
||||
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
case 'pdf':
|
||||
return 'application/pdf';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
UpdateDevPlanTaskDto,
|
||||
UpdateDevPlanVersionDto,
|
||||
DevPlanTaskBatchUpdateDto,
|
||||
DevPlanTaskExportDto,
|
||||
} from './dto/dev-plan.dto';
|
||||
|
||||
@Controller('admin/dev-plan')
|
||||
@@ -98,6 +99,11 @@ export class AdminDevPlanController {
|
||||
return this.service.batchUpdateTasks(body);
|
||||
}
|
||||
|
||||
@Post('tasks/export')
|
||||
exportTasks(@Body() body: DevPlanTaskExportDto) {
|
||||
return this.service.exportTasks(body);
|
||||
}
|
||||
|
||||
@Get('versions')
|
||||
listVersions(
|
||||
@Query('status') status?: string,
|
||||
|
||||
@@ -59,6 +59,26 @@ import type {
|
||||
UpdateDevPlanVersionDto,
|
||||
|
||||
} from './dto/dev-plan.dto';
|
||||
import {
|
||||
buildDevPlanDocx,
|
||||
buildDevPlanExportFilename,
|
||||
buildDevPlanMarkdown,
|
||||
buildDevPlanPdf,
|
||||
buildDevPlanXlsx,
|
||||
devPlanExportMimeType,
|
||||
mapTaskToExportRow,
|
||||
type DevPlanExportFormat,
|
||||
} from './admin-dev-plan-export.util';
|
||||
|
||||
function parseAttachmentUrls(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((u) => String(u).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function attachmentUrlsInput(urls?: string[]): Prisma.InputJsonValue | undefined {
|
||||
const list = (urls ?? []).map((u) => u.trim()).filter(Boolean);
|
||||
return list.length ? (list as unknown as Prisma.InputJsonValue) : undefined;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -132,6 +152,8 @@ export class DevPlanService {
|
||||
|
||||
completedAt: Date | null;
|
||||
|
||||
attachmentUrls?: unknown;
|
||||
|
||||
},
|
||||
|
||||
extras?: { creatorName?: string | null; supportTicketNo?: string | null },
|
||||
@@ -164,6 +186,8 @@ export class DevPlanService {
|
||||
|
||||
completedAt: row.completedAt?.toISOString() ?? null,
|
||||
|
||||
attachmentUrls: parseAttachmentUrls(row.attachmentUrls),
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
@@ -378,6 +402,8 @@ export class DevPlanService {
|
||||
|
||||
supportTicketId: dto.supportTicketId ? BigInt(dto.supportTicketId) : null,
|
||||
|
||||
attachmentUrls: attachmentUrlsInput(dto.attachmentUrls),
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
@@ -400,6 +426,12 @@ export class DevPlanService {
|
||||
|
||||
if (!tasks.length) throw new BadRequestException('至少创建 1 条开发任务');
|
||||
|
||||
const ticket = await this.prisma.commonSupportTicket.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { attachmentUrls: true },
|
||||
});
|
||||
const ticketUrls = parseAttachmentUrls(ticket?.attachmentUrls);
|
||||
|
||||
const created = await this.prisma.$transaction(
|
||||
|
||||
tasks.map((t) =>
|
||||
@@ -418,6 +450,10 @@ export class DevPlanService {
|
||||
|
||||
supportTicketId: ticketId,
|
||||
|
||||
attachmentUrls: attachmentUrlsInput(
|
||||
t.attachmentUrls?.length ? t.attachmentUrls : ticketUrls,
|
||||
),
|
||||
|
||||
},
|
||||
|
||||
}),
|
||||
@@ -464,6 +500,10 @@ export class DevPlanService {
|
||||
|
||||
}
|
||||
|
||||
if (dto.attachmentUrls !== undefined) {
|
||||
data.attachmentUrls = attachmentUrlsInput(dto.attachmentUrls);
|
||||
}
|
||||
|
||||
|
||||
|
||||
await this.prisma.devPlanTask.update({ where: { id }, data });
|
||||
@@ -1157,6 +1197,114 @@ export class DevPlanService {
|
||||
|
||||
}
|
||||
|
||||
private buildTaskExportWhere(query: {
|
||||
status?: string;
|
||||
type?: string;
|
||||
keyword?: string;
|
||||
}): Prisma.DevPlanTaskWhereInput {
|
||||
const where: Prisma.DevPlanTaskWhereInput = {};
|
||||
if (query.status) where.status = query.status as DevPlanTaskStatus;
|
||||
if (query.type) where.type = query.type as DevPlanTaskType;
|
||||
if (query.keyword?.trim()) {
|
||||
where.OR = [
|
||||
{ taskNo: { contains: query.keyword.trim() } },
|
||||
{ content: { contains: query.keyword.trim() } },
|
||||
];
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
async exportTasks(dto: {
|
||||
scope: 'filter' | 'selected';
|
||||
format: DevPlanExportFormat;
|
||||
ids?: string[];
|
||||
status?: string;
|
||||
type?: string;
|
||||
keyword?: string;
|
||||
}) {
|
||||
let rows: Array<{
|
||||
id: bigint;
|
||||
taskNo: string;
|
||||
content: string;
|
||||
type: DevPlanTaskType;
|
||||
status: DevPlanTaskStatus;
|
||||
creatorHqAccountId: bigint;
|
||||
supportTicketId: bigint | null;
|
||||
attachmentUrls: unknown;
|
||||
createdAt: Date;
|
||||
completedAt: Date | null;
|
||||
}>;
|
||||
|
||||
if (dto.scope === 'selected') {
|
||||
if (!dto.ids?.length) throw new BadRequestException('请先勾选要导出的任务');
|
||||
const ids = dto.ids.map(BigInt);
|
||||
rows = await this.prisma.devPlanTask.findMany({
|
||||
where: { id: { in: ids } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (rows.length !== ids.length) throw new BadRequestException('部分任务不存在');
|
||||
} else {
|
||||
rows = await this.prisma.devPlanTask.findMany({
|
||||
where: this.buildTaskExportWhere(dto),
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
if (!rows.length) throw new BadRequestException('没有可导出的任务');
|
||||
|
||||
const creatorIds = rows.map((r) => r.creatorHqAccountId);
|
||||
const ticketIds = rows.map((r) => r.supportTicketId).filter((id): id is bigint => id != null);
|
||||
const [names, tickets] = await Promise.all([
|
||||
this.loadHqNames(creatorIds),
|
||||
ticketIds.length
|
||||
? this.prisma.commonSupportTicket.findMany({
|
||||
where: { id: { in: ticketIds } },
|
||||
select: { id: true, ticketNo: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const ticketMap = new Map(tickets.map((t) => [String(t.id), t.ticketNo] as [string, string]));
|
||||
|
||||
const exportRows = rows.map((r) =>
|
||||
mapTaskToExportRow({
|
||||
taskNo: r.taskNo,
|
||||
type: r.type,
|
||||
status: r.status,
|
||||
content: r.content,
|
||||
supportTicketNo: r.supportTicketId != null ? ticketMap.get(String(r.supportTicketId)) ?? null : null,
|
||||
creatorName: names.get(String(r.creatorHqAccountId)) ?? null,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
completedAt: r.completedAt?.toISOString() ?? null,
|
||||
attachmentUrls: parseAttachmentUrls(r.attachmentUrls),
|
||||
}),
|
||||
);
|
||||
|
||||
let buffer: Buffer;
|
||||
switch (dto.format) {
|
||||
case 'markdown':
|
||||
buffer = buildDevPlanMarkdown(exportRows);
|
||||
break;
|
||||
case 'docx':
|
||||
buffer = await buildDevPlanDocx(exportRows);
|
||||
break;
|
||||
case 'xlsx':
|
||||
buffer = await buildDevPlanXlsx(exportRows);
|
||||
break;
|
||||
case 'pdf':
|
||||
buffer = await buildDevPlanPdf(exportRows);
|
||||
break;
|
||||
default:
|
||||
throw new BadRequestException('不支持的导出格式');
|
||||
}
|
||||
|
||||
return {
|
||||
filename: buildDevPlanExportFilename(dto.format, exportRows.length),
|
||||
mimeType: devPlanExportMimeType(dto.format),
|
||||
contentBase64: buffer.toString('base64'),
|
||||
count: exportRows.length,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,11 @@ export class CreateDevPlanTaskDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
supportTicketId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
attachmentUrls?: string[];
|
||||
}
|
||||
|
||||
export class UpdateDevPlanTaskDto {
|
||||
@@ -52,6 +57,11 @@ export class UpdateDevPlanTaskDto {
|
||||
@IsOptional()
|
||||
@IsIn(DEV_PLAN_TASK_STATUSES)
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
attachmentUrls?: string[];
|
||||
}
|
||||
|
||||
export class CreateDevPlanVersionDto {
|
||||
@@ -131,6 +141,11 @@ export class DevPlanTaskFromTicketDto {
|
||||
|
||||
@IsIn(DEV_PLAN_TASK_TYPES)
|
||||
type!: 'BUG' | 'REQUIREMENT' | 'OPTIMIZATION';
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
attachmentUrls?: string[];
|
||||
}
|
||||
|
||||
export class ReviewSupportTicketDto {
|
||||
@@ -177,6 +192,32 @@ export class DevPlanTaskBatchUpdateDto {
|
||||
versionId?: string | null;
|
||||
}
|
||||
|
||||
export class DevPlanTaskExportDto {
|
||||
@IsIn(['filter', 'selected'])
|
||||
scope!: 'filter' | 'selected';
|
||||
|
||||
@IsIn(['markdown', 'docx', 'xlsx', 'pdf'])
|
||||
format!: 'markdown' | 'docx' | 'xlsx' | 'pdf';
|
||||
|
||||
@ValidateIf((o: DevPlanTaskExportDto) => o.scope === 'selected')
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsString({ each: true })
|
||||
ids?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
type?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export class BatchReviewPreviewDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import {
|
||||
HqPermissionGuard,
|
||||
RequireHqPermissions,
|
||||
} from '../../common/guards/hq-permission.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { AdminRedeemDebugService } from './admin-redeem-debug.service';
|
||||
@@ -13,7 +17,8 @@ import {
|
||||
} from './dto/admin-mutate.dto';
|
||||
|
||||
@Controller('admin/redeem/debug')
|
||||
@UseGuards(HqAuthGuard)
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
@RequireHqPermissions('benefit_debug')
|
||||
export class AdminRedeemDebugController {
|
||||
constructor(private readonly service: AdminRedeemDebugService) {}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { DeliveryProvider } from '@prisma/client';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { loadStorePrimaryBank } from '../../common/store/store-bank.util';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import type { AdminDeliveriesQueryDto, AdminRedeemRecordsQueryDto } from './dto/admin-query.dto';
|
||||
import type { UpdateDeliveryDto } from './dto/admin-mutate.dto';
|
||||
@@ -113,8 +114,13 @@ export class AdminRedeemService {
|
||||
},
|
||||
});
|
||||
if (!record) throw new NotFoundException('核销记录不存在');
|
||||
const primaryAccount = await loadStorePrimaryBank(this.prisma, record.storeId);
|
||||
return serializeBigInt({
|
||||
...record,
|
||||
store: {
|
||||
...record.store,
|
||||
primaryAccount,
|
||||
},
|
||||
allocations: record.allocations.map((a) => ({
|
||||
couponId: a.couponId,
|
||||
amount: Number(a.amount),
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import {
|
||||
HqPermissionGuard,
|
||||
RequireHqPermissions,
|
||||
} from '../../common/guards/hq-permission.guard';
|
||||
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
|
||||
import {
|
||||
XiaofeixiaBatchShipmentQueryDto,
|
||||
@@ -11,7 +15,8 @@ import {
|
||||
|
||||
/** HQ 小飞侠接口联调(仅管理端,勿对 C 端暴露) */
|
||||
@Controller('admin/courier/xiaofeixia')
|
||||
@UseGuards(HqAuthGuard)
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
@RequireHqPermissions('deliveries_debug')
|
||||
export class AdminXiaofeixiaController {
|
||||
constructor(private readonly service: AdminXiaofeixiaService) {}
|
||||
|
||||
|
||||
@@ -302,8 +302,8 @@ export class AdminStoreBillController {
|
||||
refType: 'STORE_BILL',
|
||||
refIdParam: 'id',
|
||||
})
|
||||
confirm(@Param('id') id: string) {
|
||||
return this.settlementService.confirmStoreBill(BigInt(id));
|
||||
confirm(@Param('id') id: string, @Body() body: { paymentRef?: string }) {
|
||||
return this.settlementService.confirmStoreBill(BigInt(id), body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,8 +485,8 @@ export class AdminWineryBillController {
|
||||
refType: 'WINERY_BILL',
|
||||
refIdParam: 'id',
|
||||
})
|
||||
confirm(@Param('id') id: string) {
|
||||
return this.settlementService.confirmWineryBill(BigInt(id));
|
||||
confirm(@Param('id') id: string, @Body() body: { paymentRef?: string }) {
|
||||
return this.settlementService.confirmWineryBill(BigInt(id), body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,8 +565,8 @@ export class AdminLogisticsBillController {
|
||||
refType: 'LOGISTICS_BILL',
|
||||
refIdParam: 'id',
|
||||
})
|
||||
confirm(@Param('id') id: string) {
|
||||
return this.settlementService.confirmLogisticsBill(BigInt(id));
|
||||
confirm(@Param('id') id: string, @Body() body: { paymentRef?: string }) {
|
||||
return this.settlementService.confirmLogisticsBill(BigInt(id), body);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-
|
||||
import { AnalyticsService } from '../analytics/analytics.service';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
|
||||
import {
|
||||
loadStorePrimaryBank,
|
||||
loadStorePrimaryBanksMap,
|
||||
loadWineryBankConfig,
|
||||
} from '../../common/store/store-bank.util';
|
||||
|
||||
function generateBillNo(prefix: string) {
|
||||
return `${prefix}${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
|
||||
@@ -1043,11 +1048,20 @@ export class SettlementService {
|
||||
|
||||
const total = rows.length;
|
||||
const slice = rows.slice((page - 1) * pageSize, page * pageSize);
|
||||
const storeIds = [...new Set(rows.filter((r) => r.kind === 'T1_BILL').map((r) => r.storeId))];
|
||||
const bankMap = await loadStorePrimaryBanksMap(this.prisma, storeIds);
|
||||
|
||||
const redeemAmount = bills.reduce((s, b) => s + Number(b.redeemAmount), 0);
|
||||
const payoutAmount = rows.reduce((s, r) => s + r.amount, 0);
|
||||
|
||||
return serializeBigInt({
|
||||
items: slice,
|
||||
items: slice.map((r) => ({
|
||||
...r,
|
||||
bankAccount:
|
||||
r.kind === 'T1_BILL'
|
||||
? bankMap.get(String(r.storeId)) ?? null
|
||||
: null,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -1112,10 +1126,11 @@ export class SettlementService {
|
||||
},
|
||||
});
|
||||
if (!bill) throw new NotFoundException('门店对账单不存在');
|
||||
return serializeBigInt(bill);
|
||||
const storeAccount = await loadStorePrimaryBank(this.prisma, bill.storeId);
|
||||
return serializeBigInt({ ...bill, storeAccount });
|
||||
}
|
||||
|
||||
async confirmStoreBill(id: bigint) {
|
||||
async confirmStoreBill(id: bigint, dto: { paymentRef?: string } = {}) {
|
||||
const bill = await this.prisma.storeBill.findUnique({ where: { id } });
|
||||
if (!bill) throw new NotFoundException('门店对账单不存在');
|
||||
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可确认打款');
|
||||
@@ -1124,7 +1139,11 @@ export class SettlementService {
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const b = await tx.storeBill.update({
|
||||
where: { id },
|
||||
data: { status: 'PAID', paidAt },
|
||||
data: {
|
||||
status: 'PAID',
|
||||
paidAt,
|
||||
paymentRef: dto.paymentRef?.trim() || null,
|
||||
},
|
||||
});
|
||||
await tx.storePayout.updateMany({
|
||||
where: { storeBillId: id, status: 'PENDING' },
|
||||
@@ -1157,12 +1176,32 @@ export class SettlementService {
|
||||
const where = this.buildStoreBillWhere(query);
|
||||
const bills = await this.prisma.storeBill.findMany({
|
||||
where,
|
||||
include: { store: { select: { name: true, phone: true, cityName: true } } },
|
||||
include: { store: { select: { id: true, name: true, phone: true, cityName: true } } },
|
||||
orderBy: { billDate: 'desc' },
|
||||
});
|
||||
const header = ['账单号', '账单日', '门店', '城市', '核销笔数', '核销金额', '结算比例', '应付金额', '状态', '打款时间'].join(',');
|
||||
const rows = bills.map((b) =>
|
||||
[
|
||||
const bankMap = await loadStorePrimaryBanksMap(
|
||||
this.prisma,
|
||||
bills.map((b) => b.storeId),
|
||||
);
|
||||
const header = [
|
||||
'账单号',
|
||||
'账单日',
|
||||
'门店',
|
||||
'城市',
|
||||
'核销笔数',
|
||||
'核销金额',
|
||||
'结算比例',
|
||||
'应付金额',
|
||||
'状态',
|
||||
'打款时间',
|
||||
'打款凭证',
|
||||
'收款户名',
|
||||
'收款账号',
|
||||
'开户行',
|
||||
].join(',');
|
||||
const rows = bills.map((b) => {
|
||||
const bank = bankMap.get(String(b.storeId));
|
||||
return [
|
||||
csvEscape(b.billNo),
|
||||
b.billDate.toISOString().slice(0, 10),
|
||||
csvEscape(b.store.name),
|
||||
@@ -1173,8 +1212,12 @@ export class SettlementService {
|
||||
Number(b.payoutAmount),
|
||||
b.status,
|
||||
b.paidAt ? b.paidAt.toISOString().slice(0, 19).replace('T', ' ') : '',
|
||||
].join(','),
|
||||
);
|
||||
csvEscape(b.paymentRef ?? ''),
|
||||
csvEscape(bank?.bankAccountName ?? ''),
|
||||
csvEscape(bank?.bankAccountNo ?? ''),
|
||||
csvEscape(bank?.bankBranch ?? ''),
|
||||
].join(',');
|
||||
});
|
||||
return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: bills.length };
|
||||
}
|
||||
|
||||
@@ -1262,7 +1305,15 @@ export class SettlementService {
|
||||
take: pageSize,
|
||||
include: {
|
||||
partnerAccount: {
|
||||
select: { id: true, companyName: true, name: true, phone: true },
|
||||
select: {
|
||||
id: true,
|
||||
companyName: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
bankAccountName: true,
|
||||
bankAccountNo: true,
|
||||
bankBranch: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -1553,14 +1604,19 @@ export class SettlementService {
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
async markPartnerBillPaid(id: bigint, _dto: { paymentRef?: string } = {}) {
|
||||
async markPartnerBillPaid(id: bigint, dto: { paymentRef?: string } = {}) {
|
||||
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
|
||||
if (!bill) throw new NotFoundException('账单不存在');
|
||||
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可标记打款');
|
||||
|
||||
const updated = await this.prisma.partnerBill.update({
|
||||
where: { id },
|
||||
data: { status: 'PAID', paidAt: new Date(), rejectReason: null },
|
||||
data: {
|
||||
status: 'PAID',
|
||||
paidAt: new Date(),
|
||||
rejectReason: null,
|
||||
paymentRef: dto.paymentRef?.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
return serializeBigInt(updated);
|
||||
@@ -1588,7 +1644,17 @@ export class SettlementService {
|
||||
const where = this.buildPartnerBillWhere(query);
|
||||
const bills = await this.prisma.partnerBill.findMany({
|
||||
where,
|
||||
include: { partnerAccount: { select: { companyName: true, phone: true } } },
|
||||
include: {
|
||||
partnerAccount: {
|
||||
select: {
|
||||
companyName: true,
|
||||
phone: true,
|
||||
bankAccountName: true,
|
||||
bankAccountNo: true,
|
||||
bankBranch: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { periodStart: 'desc' },
|
||||
});
|
||||
|
||||
@@ -1605,6 +1671,10 @@ export class SettlementService {
|
||||
'发送时间',
|
||||
'确认时间',
|
||||
'打款时间',
|
||||
'打款凭证',
|
||||
'收款户名',
|
||||
'收款账号',
|
||||
'开户行',
|
||||
'驳回理由',
|
||||
].join(',');
|
||||
const rows = bills.map((b) =>
|
||||
@@ -1621,6 +1691,10 @@ export class SettlementService {
|
||||
b.sentAt ? b.sentAt.toISOString().slice(0, 10) : '',
|
||||
b.confirmedAt ? b.confirmedAt.toISOString().slice(0, 10) : '',
|
||||
b.paidAt ? b.paidAt.toISOString().slice(0, 10) : '',
|
||||
csvEscape(b.paymentRef ?? ''),
|
||||
csvEscape(b.partnerAccount.bankAccountName ?? ''),
|
||||
csvEscape(b.partnerAccount.bankAccountNo ?? ''),
|
||||
csvEscape(b.partnerAccount.bankBranch ?? ''),
|
||||
csvEscape(b.rejectReason ?? ''),
|
||||
].join(','),
|
||||
);
|
||||
@@ -1752,10 +1826,11 @@ export class SettlementService {
|
||||
include: { items: { orderBy: { paidAt: 'desc' } } },
|
||||
});
|
||||
if (!bill) throw new NotFoundException('酒厂对账单不存在');
|
||||
return serializeBigInt(bill);
|
||||
const wineryBank = await loadWineryBankConfig(this.prisma);
|
||||
return serializeBigInt({ ...bill, wineryBank });
|
||||
}
|
||||
|
||||
async confirmWineryBill(id: bigint) {
|
||||
async confirmWineryBill(id: bigint, dto: { paymentRef?: string } = {}) {
|
||||
const bill = await this.prisma.wineryBill.findUnique({ where: { id } });
|
||||
if (!bill) throw new NotFoundException('酒厂对账单不存在');
|
||||
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可确认打款');
|
||||
@@ -1764,7 +1839,11 @@ export class SettlementService {
|
||||
}
|
||||
const updated = await this.prisma.wineryBill.update({
|
||||
where: { id },
|
||||
data: { status: 'PAID', paidAt: new Date() },
|
||||
data: {
|
||||
status: 'PAID',
|
||||
paidAt: new Date(),
|
||||
paymentRef: dto.paymentRef?.trim() || null,
|
||||
},
|
||||
});
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
@@ -1795,6 +1874,7 @@ export class SettlementService {
|
||||
include: { items: true },
|
||||
orderBy: { billDate: 'desc' },
|
||||
});
|
||||
const wineryBank = await loadWineryBankConfig(this.prisma);
|
||||
|
||||
const header = [
|
||||
'账单号',
|
||||
@@ -1806,6 +1886,11 @@ export class SettlementService {
|
||||
'应付',
|
||||
'支付时间',
|
||||
'账单状态',
|
||||
'打款凭证',
|
||||
'收款户名',
|
||||
'开户银行',
|
||||
'开户支行',
|
||||
'收款账号',
|
||||
].join(',');
|
||||
const rows: string[] = [];
|
||||
const statusLabel = (b: { status: string; wineryAmount: Prisma.Decimal | number }) => {
|
||||
@@ -1814,6 +1899,13 @@ export class SettlementService {
|
||||
if (b.status === 'UNPAID') return '未打款';
|
||||
return b.status;
|
||||
};
|
||||
const bankCols = (b: { paymentRef?: string | null }) => [
|
||||
csvEscape(b.paymentRef ?? ''),
|
||||
csvEscape(wineryBank.bankAccountName ?? ''),
|
||||
csvEscape(wineryBank.bankName ?? ''),
|
||||
csvEscape(wineryBank.bankBranch ?? ''),
|
||||
csvEscape(wineryBank.bankAccountNo ?? ''),
|
||||
];
|
||||
for (const b of bills) {
|
||||
if (b.items.length === 0) {
|
||||
rows.push(
|
||||
@@ -1827,6 +1919,7 @@ export class SettlementService {
|
||||
Number(b.wineryAmount),
|
||||
'',
|
||||
statusLabel(b),
|
||||
...bankCols(b),
|
||||
].join(','),
|
||||
);
|
||||
continue;
|
||||
@@ -1843,6 +1936,7 @@ export class SettlementService {
|
||||
Number(item.wineryAmount),
|
||||
item.paidAt.toISOString().slice(0, 19).replace('T', ' '),
|
||||
statusLabel(b),
|
||||
...bankCols(b),
|
||||
].join(','),
|
||||
);
|
||||
}
|
||||
@@ -2275,11 +2369,17 @@ export class SettlementService {
|
||||
});
|
||||
}
|
||||
|
||||
async confirmLogisticsBill(id: bigint) {
|
||||
async confirmLogisticsBill(id: bigint, dto: { paymentRef?: string } = {}) {
|
||||
const bill = await this.prisma.logisticsBill.findUnique({ where: { id } });
|
||||
if (!bill) throw new NotFoundException('物流对账单不存在');
|
||||
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未结算账单可确认');
|
||||
|
||||
const paidData = {
|
||||
status: 'PAID' as const,
|
||||
paidAt: new Date(),
|
||||
paymentRef: dto.paymentRef?.trim() || null,
|
||||
};
|
||||
|
||||
if (bill.settlementMethod === 'PREPAID') {
|
||||
const deducted = await this.prisma.$transaction(async (tx) => {
|
||||
const result = await this.fulfillmentProviderService.deductPrepaid(
|
||||
@@ -2295,7 +2395,7 @@ export class SettlementService {
|
||||
}
|
||||
return tx.logisticsBill.update({
|
||||
where: { id },
|
||||
data: { status: 'PAID', paidAt: new Date() },
|
||||
data: paidData,
|
||||
});
|
||||
});
|
||||
return serializeBigInt(deducted);
|
||||
@@ -2303,7 +2403,7 @@ export class SettlementService {
|
||||
|
||||
const updated = await this.prisma.logisticsBill.update({
|
||||
where: { id },
|
||||
data: { status: 'PAID', paidAt: new Date() },
|
||||
data: paidData,
|
||||
});
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
@@ -2331,7 +2431,16 @@ export class SettlementService {
|
||||
const bills = await this.prisma.logisticsBill.findMany({
|
||||
where,
|
||||
include: {
|
||||
fulfillmentProvider: { select: { code: true, name: true } },
|
||||
fulfillmentProvider: {
|
||||
select: {
|
||||
code: true,
|
||||
name: true,
|
||||
bankAccountName: true,
|
||||
bankName: true,
|
||||
bankBranch: true,
|
||||
bankAccountNo: true,
|
||||
},
|
||||
},
|
||||
items: true,
|
||||
},
|
||||
orderBy: { periodStart: 'desc' },
|
||||
@@ -2349,15 +2458,28 @@ export class SettlementService {
|
||||
'物流费',
|
||||
'发货时间',
|
||||
'账单状态',
|
||||
'打款凭证',
|
||||
'收款户名',
|
||||
'开户银行',
|
||||
'开户支行',
|
||||
'收款账号',
|
||||
].join(',');
|
||||
const rows: string[] = [];
|
||||
for (const b of bills) {
|
||||
const p = b.fulfillmentProvider;
|
||||
const bankCols = [
|
||||
csvEscape(b.paymentRef ?? ''),
|
||||
csvEscape(p.bankAccountName ?? ''),
|
||||
csvEscape(p.bankName ?? ''),
|
||||
csvEscape(p.bankBranch ?? ''),
|
||||
csvEscape(p.bankAccountNo ?? ''),
|
||||
];
|
||||
if (b.items.length === 0) {
|
||||
rows.push(
|
||||
[
|
||||
csvEscape(b.billNo),
|
||||
csvEscape(b.fulfillmentProvider.code),
|
||||
csvEscape(b.fulfillmentProvider.name),
|
||||
csvEscape(p.code),
|
||||
csvEscape(p.name),
|
||||
b.periodStart.toISOString().slice(0, 10),
|
||||
b.periodEnd.toISOString().slice(0, 10),
|
||||
b.settlementMethod,
|
||||
@@ -2366,6 +2488,7 @@ export class SettlementService {
|
||||
Number(b.logisticsAmount),
|
||||
'',
|
||||
b.status,
|
||||
...bankCols,
|
||||
].join(','),
|
||||
);
|
||||
continue;
|
||||
@@ -2374,8 +2497,8 @@ export class SettlementService {
|
||||
rows.push(
|
||||
[
|
||||
csvEscape(b.billNo),
|
||||
csvEscape(b.fulfillmentProvider.code),
|
||||
csvEscape(b.fulfillmentProvider.name),
|
||||
csvEscape(p.code),
|
||||
csvEscape(p.name),
|
||||
b.periodStart.toISOString().slice(0, 10),
|
||||
b.periodEnd.toISOString().slice(0, 10),
|
||||
b.settlementMethod,
|
||||
@@ -2384,6 +2507,7 @@ export class SettlementService {
|
||||
Number(item.logisticsAmount),
|
||||
item.shippedAt.toISOString().slice(0, 19).replace('T', ' '),
|
||||
b.status,
|
||||
...bankCols,
|
||||
].join(','),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user