v3.5.6版本迭代
CI / verify (pull_request) Waiting to run

This commit is contained in:
2026-08-23 16:41:22 +08:00
parent 608e3e5ec5
commit 7e12eb3ca6
33 changed files with 1537 additions and 278 deletions
+1
View File
@@ -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`;
+5
View File
@@ -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(` ![附件](${url})`);
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(','),
);
}