feat: add report material workflows and gateway safeguards

This commit is contained in:
hectorzhao
2026-07-15 18:23:48 +08:00
parent cf9f4ce4cd
commit 7091a8bed4
41 changed files with 3606 additions and 71 deletions
@@ -0,0 +1,103 @@
import ExcelJS from 'exceljs';
import { ReportMaterialsService } from './report-materials.service';
describe('ReportMaterialsService', () => {
it('detects WPS-compatible embedded images and source columns during XLSX analysis', async () => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('签名资料');
sheet.addRow(['短信签名', '营业执照']);
sheet.addRow(['测试签名', '']);
const imageId = workbook.addImage({ base64: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZsmAAAAAASUVORK5CYII=', extension: 'png' });
sheet.addImage(imageId, { tl: { col: 1, row: 1 }, ext: { width: 80, height: 60 } });
const buffer = Buffer.from(await workbook.xlsx.writeBuffer());
const prisma = {
reportMaterialImportProfile: { findUnique: jest.fn().mockResolvedValue({ sheetName: '签名资料', columns: [{ sourceHeader: '短信签名', sourceHeaderPath: '短信签名', sourceColumnIndex: 9, targetFieldCode: 'signature_name', targetKind: 'signatureName', fieldType: 'string', required: true, sortOrder: 10 }] }) },
reportMaterialImportBatch: { create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'import-1', ...data })) },
};
const files = { upload: jest.fn().mockResolvedValue({ id: 'source-1', fileName: '签名资料.xlsx', contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) };
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
const result = await service.analyzeImport({ originalname: '签名资料.xlsx', mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: buffer.length, buffer }, { tenantId: 'tenant-1', applicationId: 'app-1', reportType: 'signature', headerRowCount: 1, dataStartRow: 2, profileId: 'profile-1' });
expect(result.imageCount).toBe(1);
expect(result.columns).toEqual(expect.arrayContaining([expect.objectContaining({ sourceHeader: '营业执照', imageCount: 1 })]));
expect(result.rows).toEqual([expect.objectContaining({ rowNumber: 2, imageColumns: [2] })]);
expect(result.suggestedMappings).toEqual([expect.objectContaining({ sourceColumnIndex: 1, targetKind: 'signatureName' })]);
});
it('expands one selected signature to every routed channel and embeds images in each XLSX', async () => {
const uploadedWorkbooks: Buffer[] = [];
let batchItemSequence = 0;
let exportSequence = 0;
const channels = [{ id: 'channel-a', name: '通道A', status: 'active' }, { id: 'channel-b', name: '通道B', status: 'active' }];
const prisma = {
reportMaterialBatch: {
create: jest.fn().mockResolvedValue({ id: 'batch-1', batchNo: 'RB001' }),
update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'batch-1', batchNo: 'RB001', ...data })),
},
smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'signature-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', purpose: '验证码', auditStatus: 'approved', materialVersion: 3, drainageInfo: { signatureReportValues: { license: { fileObjectId: 'image-1', fileName: 'license.png', contentType: 'image/png' } } }, tenant: { name: '测试企业' }, application: { name: '测试应用' } }),
update: jest.fn().mockResolvedValue({}),
},
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: channels.map((channel, index) => ({ priority: index, channel })) } }]) },
reportMaterialBatchItem: { create: jest.fn().mockImplementation(() => Promise.resolve({ id: `batch-item-${++batchItemSequence}` })) },
smsChannel: { findUnique: jest.fn().mockImplementation(({ where }: { where: { id: string } }) => Promise.resolve(channels.find((channel) => channel.id === where.id))) },
channelReportField: { findMany: jest.fn().mockResolvedValue([
{ code: 'sign', name: '短信签名', exportName: '通道签名', required: true, columnWidth: 18, imageWidth: 120, imageHeight: 80, transform: null, defaultValue: null },
{ code: 'license', name: '营业执照', exportName: '营业执照图片', required: true, columnWidth: 24, imageWidth: 120, imageHeight: 80, transform: null, defaultValue: null },
]) },
channelSignatureReportTask: { findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: `task-${String(data.channelId)}`, ...data })), update: jest.fn() },
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({}) },
reportExportFile: { create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: `export-${++exportSequence}`, ...data })) },
reportExportFileItem: { createMany: jest.fn().mockResolvedValue({ count: 1 }) },
};
const files = {
getDownload: jest.fn().mockResolvedValue({ fileObject: { fileName: 'license.png', contentType: 'image/png' }, content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZsmAAAAAASUVORK5CYII=', 'base64') }),
upload: jest.fn().mockImplementation((_options: unknown, file: { originalname: string; mimetype: string; buffer: Buffer }) => {
uploadedWorkbooks.push(file.buffer);
return Promise.resolve({ id: `file-${uploadedWorkbooks.length}`, fileName: file.originalname, contentType: file.mimetype });
}),
};
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
const result = await service.createBatch({ items: [{ reportType: 'signature', signatureId: 'signature-1' }] });
expect(result).toMatchObject({ status: 'completed', channelCount: 2, fileCount: 2 });
expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledTimes(2);
expect(prisma.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'signature-1' }, data: { pendingReport: false } });
expect(uploadedWorkbooks).toHaveLength(2);
for (const buffer of uploadedWorkbooks) {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buffer as never);
const sheet = workbook.getWorksheet('签名报备');
expect(sheet?.getCell('A1').text).toBe('通道签名');
expect(sheet?.getCell('A2').text).toBe('测试签名');
expect(sheet?.getImages()).toHaveLength(1);
}
});
it('keeps material pending and marks the task waiting when the channel has no field configuration', async () => {
const prisma = {
reportMaterialBatch: { create: jest.fn().mockResolvedValue({ id: 'batch-2' }), update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve(data)) },
smsSignature: { findUnique: jest.fn().mockResolvedValue({ id: 'signature-2', tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', auditStatus: 'approved', materialVersion: 1, drainageInfo: {}, tenant: { name: '企业' }, application: { name: '应用' } }), update: jest.fn() },
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: [{ channel: { id: 'channel-a', name: '通道A', status: 'active' } }] } }]) },
reportMaterialBatchItem: { create: jest.fn().mockResolvedValue({ id: 'batch-item-2' }) },
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-a', name: '通道A' }) },
channelReportField: { findMany: jest.fn().mockResolvedValue([]) },
channelSignatureReportTask: { findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'task-2', ...data })) },
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({}) },
reportExportFile: { create: jest.fn().mockResolvedValue({ id: 'export-2' }) },
reportExportFileItem: { createMany: jest.fn() },
};
const files = { upload: jest.fn().mockResolvedValue({ id: 'file-2', fileName: 'empty.xlsx', contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) };
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
const result = await service.createBatch({ items: [{ reportType: 'signature', signatureId: 'signature-2' }] });
expect(result).toMatchObject({ status: 'partial_failed' });
expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'waiting_material', reason: '通道未配置当前资料类型的报备字段' }) });
expect(prisma.smsSignature.update).not.toHaveBeenCalled();
});
});