feat: redesign report batch workflow
This commit is contained in:
Generated
+1
@@ -21,6 +21,7 @@
|
||||
"class-validator": "^0.14.3",
|
||||
"exceljs": "^4.4.0",
|
||||
"ioredis": "^5.11.1",
|
||||
"jszip": "^3.10.1",
|
||||
"minio": "^8.0.7",
|
||||
"pg": "^8.22.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"brace-expansion": "file:vendor/brace-expansion-compat",
|
||||
"exceljs": "^4.4.0",
|
||||
"ioredis": "^5.11.1",
|
||||
"jszip": "^3.10.1",
|
||||
"minio": "^8.0.7",
|
||||
"pg": "^8.22.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user