feat: redesign report batch workflow

This commit is contained in:
hectorzhao
2026-09-03 11:04:32 +08:00
parent a50aafb1ec
commit dc201bf92e
15 changed files with 582 additions and 150 deletions
@@ -0,0 +1,61 @@
import JSZip from 'jszip';
import { ReportBatchDownloadService } from './batch-download.service';
describe('ReportBatchDownloadService', () => {
const batch = {
batchNo: 'RB20260903090000TEST',
createdAt: new Date('2026-09-03T01:00:00.000Z'),
exportFiles: [
{ id: 'export-1', channelId: 'channel-1', fileObjectId: 'object-1' },
{ id: 'export-2', channelId: 'channel-2', fileObjectId: 'object-2' },
],
briefs: [
{ channelId: 'channel-1', channelName: '移动/主通道', fileId: 'export-1', content: '移动通道简报' },
{ channelId: 'channel-2', channelName: '联通备用', fileId: 'export-2', content: '联通通道简报' },
],
};
it('renames a channel workbook with date, channel and batch number', async () => {
const files = { getDownload: jest.fn().mockResolvedValue({ content: Buffer.from('xlsx-one') }) };
const service = new ReportBatchDownloadService(files as never);
await expect(service.exportFile(batch, 'export-1')).resolves.toEqual({
fileName: '2026-09-03_移动_主通道_RB20260903090000TEST.xlsx',
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
content: Buffer.from('xlsx-one'),
});
expect(files.getDownload).toHaveBeenCalledWith('object-1');
});
it('packs every channel workbook and brief into one zip', async () => {
const files = {
getDownload: jest.fn((id: string) => Promise.resolve({ content: Buffer.from(`xlsx-${id}`) })),
};
const service = new ReportBatchDownloadService(files as never);
const exported = await service.exportBundle(batch);
const zip = await JSZip.loadAsync(exported.content);
expect(exported.fileName).toBe('2026-09-03_RB20260903090000TEST_报备文件.zip');
expect(Object.keys(zip.files).sort()).toEqual(
[
'2026-09-03_移动_主通道_RB20260903090000TEST.txt',
'2026-09-03_移动_主通道_RB20260903090000TEST.xlsx',
'2026-09-03_联通备用_RB20260903090000TEST.txt',
'2026-09-03_联通备用_RB20260903090000TEST.xlsx',
].sort(),
);
await expect(zip.file('2026-09-03_移动_主通道_RB20260903090000TEST.txt')!.async('string')).resolves.toBe(
'移动通道简报',
);
await expect(zip.file('2026-09-03_联通备用_RB20260903090000TEST.xlsx')!.async('string')).resolves.toBe(
'xlsx-object-2',
);
});
it('rejects an incomplete bundle instead of silently omitting a channel workbook', async () => {
const files = { getDownload: jest.fn() };
const service = new ReportBatchDownloadService(files as never);
await expect(service.exportBundle({ ...batch, exportFiles: [] })).rejects.toThrow('通道“移动/主通道”缺少报备文件');
expect(files.getDownload).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,90 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import JSZip from 'jszip';
import { FilesService } from '../files/files.service';
import { safeFileName } from './report-materials.helpers';
type BatchDownloadSource = {
batchNo: string;
createdAt: Date | string;
exportFiles: Array<{
id: string;
channelId?: string | null;
fileObjectId?: string | null;
}>;
briefs: Array<{
channelId: string;
channelName: string;
fileId: string;
content: string;
}>;
};
type DownloadedBatchArtifact = {
fileName: string;
contentType: string;
content: Buffer;
};
@Injectable()
export class ReportBatchDownloadService {
constructor(private readonly files: FilesService) {}
async exportFile(batch: BatchDownloadSource, fileId: string): Promise<DownloadedBatchArtifact> {
const brief = batch.briefs.find((item) => item.fileId === fileId);
const exportFile = batch.exportFiles.find((item) => item.id === fileId);
if (!brief || !exportFile?.fileObjectId) throw new NotFoundException('批次报备文件不存在');
const { content } = await this.files.getDownload(exportFile.fileObjectId);
return {
fileName: `${this.entryBaseName(batch, brief.channelName)}.xlsx`,
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
content,
};
}
async exportBundle(batch: BatchDownloadSource): Promise<DownloadedBatchArtifact> {
if (!batch.briefs.length) throw new BadRequestException('该批次暂无可导出的通道文件或简报');
if (batch.briefs.length > 100) throw new BadRequestException('单次最多打包100个通道文件');
const exportFileById = new Map(batch.exportFiles.map((item) => [item.id, item]));
const downloaded = await Promise.all(
batch.briefs.map(async (brief) => {
const exportFile = exportFileById.get(brief.fileId);
if (!exportFile?.fileObjectId) throw new BadRequestException(`通道“${brief.channelName}”缺少报备文件`);
const workbook = await this.files.getDownload(exportFile.fileObjectId);
return { brief, workbook };
}),
);
const totalBytes = downloaded.reduce((sum, item) => sum + item.workbook.content.length, 0);
if (totalBytes > 200 * 1024 * 1024) throw new BadRequestException('批次报备文件超过200MB,无法一次打包');
const zip = new JSZip();
for (const { brief, workbook } of downloaded) {
const baseName = this.entryBaseName(batch, brief.channelName);
zip.file(`${baseName}.xlsx`, workbook.content);
zip.file(`${baseName}.txt`, brief.content);
}
return {
fileName: `${this.batchDate(batch.createdAt)}_${safeFileName(batch.batchNo)}_报备文件.zip`,
contentType: 'application/zip',
content: await zip.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
}),
};
}
private entryBaseName(batch: BatchDownloadSource, channelName: string) {
return `${this.batchDate(batch.createdAt)}_${safeFileName(channelName)}_${safeFileName(batch.batchNo)}`;
}
private batchDate(value: Date | string) {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
.formatToParts(new Date(value))
.reduce<Record<string, string>>((result, part) => ({ ...result, [part.type]: part.value }), {});
return `${parts.year}-${parts.month}-${parts.day}`;
}
}
@@ -1,56 +1,24 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { createHash, randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service';
import type {
AnalyzeImportOptions,
CreateImportProfileDto,
CreateReportBatchDto,
EmbeddedImage,
ImportCommitDto,
ImportMapping,
PagedQuery,
ReportBatchInspection,
ReportBatchTarget,
ReviewImportItemsDto,
} from './report-materials.contracts';
import {
profileData,
validateProfile,
loadWorkbook,
assertSafeWorkbook,
safeSpreadsheetText,
readEmbeddedImages,
suggestMappings,
remapProfileColumns,
signatureCoreMapping,
drainageCoreMapping,
normalizeHeader,
normalizeFieldCode,
clamp,
normalizePage,
normalizePageSize,
dateRange,
cellText,
transformValue,
mappedCoreValue,
dynamicValues,
jsonRecord,
hasValue,
isFileRef,
resolveExportValue,
applyExportTransform,
styleHeader,
normalizeImageExtension,
imageContentType,
safeFileName,
normalizeBatchIdempotencyKey,
jsonStringArray,
jsonSafe,
} from './report-materials.helpers';
import { ReportBatchOperationService } from './batch-operation.service';
import { ReportChannelExportService } from './channel-export.service';
@@ -95,10 +63,16 @@ export class ReportBatchGenerationService {
const reportItems = batch.exportFiles.flatMap((file) => file.items);
const reportTotal = reportItems.length;
const successCount = reportItems.filter((item) => item.task.status === 'approved').length;
const reportingCount = reportItems.filter((item) =>
['reporting', 'exporting'].includes(item.task.status),
).length;
const failedCount = reportItems.filter((item) => ['failed', 'rejected'].includes(item.task.status)).length;
return {
...batch,
reportTotal,
reportingCount,
successCount,
failedCount,
successRate: reportTotal ? successCount / reportTotal : 0,
};
}),
@@ -116,9 +90,9 @@ export class ReportBatchGenerationService {
if (!batch) throw new NotFoundException('报备批次不存在');
const tasks = await this.collectBatchTasks(batchId);
const successCount = tasks.filter((task) => task.status === 'approved').length;
const channelIds = batch.exportFiles
.map((file) => file.channelId)
.filter((id): id is string => Boolean(id));
const reportingCount = tasks.filter((task) => ['reporting', 'exporting'].includes(task.status)).length;
const failedCount = tasks.filter((task) => ['failed', 'rejected'].includes(task.status)).length;
const channelIds = batch.exportFiles.map((file) => file.channelId).filter((id): id is string => Boolean(id));
const channels = channelIds.length
? await this.prisma.smsChannel.findMany({
where: { id: { in: channelIds } },
@@ -173,7 +147,9 @@ export class ReportBatchGenerationService {
return {
...batch,
reportTotal: tasks.length,
reportingCount,
successCount,
failedCount,
successRate: tasks.length ? successCount / tasks.length : 0,
briefs,
};
@@ -16,6 +16,7 @@ import { ApiTags } from '@nestjs/swagger';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { ReportMaterialsService } from './report-materials.service';
import { ReportBatchDownloadService } from './batch-download.service';
import {
CreateImportProfileDto,
CreateReportBatchDto,
@@ -30,7 +31,10 @@ type DownloadResponse = { setHeader(name: string, value: string): void; send(con
@ApiTags('report-materials')
@Controller('admin/report-materials')
export class ReportMaterialsController {
constructor(private readonly service: ReportMaterialsService) {}
constructor(
private readonly service: ReportMaterialsService,
private readonly batchDownloads: ReportBatchDownloadService,
) {}
@Get('pending')
listPending(
@@ -156,6 +160,16 @@ export class ReportMaterialsController {
return this.service.listBatches({ keyword, startAt, endAt, page: Number(page), pageSize: Number(pageSize) });
}
@Get('batches/:id/download')
async downloadBatch(@Param('id') id: string, @Res() response: DownloadResponse) {
this.sendDownload(response, await this.batchDownloads.exportBundle(await this.service.getBatch(id)));
}
@Get('batches/:id/files/:fileId/download')
async downloadBatchFile(@Param('id') id: string, @Param('fileId') fileId: string, @Res() response: DownloadResponse) {
this.sendDownload(response, await this.batchDownloads.exportFile(await this.service.getBatch(id), fileId));
}
@Get('batches/:id')
getBatch(@Param('id') id: string) {
return this.service.getBatch(id);
@@ -212,4 +226,13 @@ export class ReportMaterialsController {
response.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(exported.fileName)}`);
response.send(exported.content);
}
private sendDownload(
response: DownloadResponse,
exported: { fileName: string; contentType: string; content: Buffer },
) {
response.setHeader('Content-Type', exported.contentType);
response.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(exported.fileName)}`);
response.send(exported.content);
}
}
@@ -3,11 +3,12 @@ import { FilesModule } from '../files/files.module';
import { SmsConfigModule } from '../sms-config/sms-config.module';
import { ReportMaterialsController } from './report-materials.controller';
import { ReportMaterialsService } from './report-materials.service';
import { ReportBatchDownloadService } from './batch-download.service';
@Module({
imports: [FilesModule, SmsConfigModule],
controllers: [ReportMaterialsController],
providers: [ReportMaterialsService],
providers: [ReportMaterialsService, ReportBatchDownloadService],
exports: [ReportMaterialsService],
})
export class ReportMaterialsModule {}
@@ -1,4 +1,5 @@
import ExcelJS from 'exceljs';
import { createHash } from 'node:crypto';
import { ReportMaterialsService } from './report-materials.service';
import { mappedCorePatchValue } from './report-materials.helpers';
@@ -601,9 +602,17 @@ describe('ReportMaterialsService', () => {
batchNo: 'RB-STATS-1',
exportFiles: [
{
items: [{ task: { id: 'task-1', status: 'approved' } }, { task: { id: 'task-2', status: 'rejected' } }],
items: [
{ task: { id: 'task-1', status: 'approved' } },
{ task: { id: 'task-2', status: 'rejected' } },
],
},
{
items: [
{ task: { id: 'task-3', status: 'reporting' } },
{ task: { id: 'task-4', status: 'exporting' } },
],
},
{ items: [{ task: { id: 'task-3', status: 'approved' } }] },
],
items: [],
},
@@ -614,7 +623,16 @@ describe('ReportMaterialsService', () => {
const service = new ReportMaterialsService(prisma as never, {} as never, {} as never);
await expect(service.listBatches({ keyword: 'RB-STATS', page: 2, pageSize: 10 })).resolves.toMatchObject({
items: [{ id: 'batch-stats-1', reportTotal: 3, successCount: 2, successRate: 2 / 3 }],
items: [
{
id: 'batch-stats-1',
reportTotal: 4,
reportingCount: 2,
successCount: 1,
failedCount: 1,
successRate: 1 / 4,
},
],
total: 1,
page: 2,
pageSize: 10,
@@ -698,7 +716,6 @@ describe('ReportMaterialsService', () => {
});
function createFingerprint(items: Array<{ reportType: string; signatureId: string; materialVersion: number }>) {
const { createHash } = require('node:crypto') as typeof import('node:crypto');
return createHash('sha256')
.update(
JSON.stringify(