import ExcelJS from 'exceljs'; import { ReportMaterialsService } from './report-materials.service'; describe('ReportMaterialsService', () => { it('builds an official XLSX import template with documented signature columns', async () => { const operationLog = { create: jest.fn().mockResolvedValue({ id: 'log-template' }) }; const service = new ReportMaterialsService({ operationLog } as never, {} as never, {} as never); const exported = await service.buildOfficialTemplate('signature', 'operator-1'); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(exported.content as never); expect(exported.fileName).toContain('签名报备资料官方模板'); expect(workbook.worksheets[0].getRow(1).values).toEqual(expect.arrayContaining(['短信签名', '用途说明'])); expect(operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ userId: 'operator-1' }) }); }); it('builds a drainage template without a separate site-name column', async () => { const operationLog = { create: jest.fn().mockResolvedValue({ id: 'log-drainage-template' }) }; const service = new ReportMaterialsService({ operationLog } as never, {} as never, {} as never); const exported = await service.buildOfficialTemplate('drainage', 'operator-1'); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(exported.content as never); const headers = workbook.worksheets[0].getRow(1).values; expect(headers).toEqual(expect.arrayContaining(['短信签名', '引流 URL 或号码', '备注', '主体证明'])); expect(headers).not.toEqual(expect.arrayContaining(['站点名称'])); }); it('rejects formula cells before storing or importing a workbook', async () => { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('签名资料'); sheet.addRow(['短信签名']); sheet.getCell('A2').value = { formula: 'HYPERLINK("https://invalid.example","click")', result: 'click' }; const buffer = Buffer.from(await workbook.xlsx.writeBuffer()); const files = { upload: jest.fn() }; const service = new ReportMaterialsService({ reportMaterialImportProfile: { findUnique: jest.fn() } } as never, files as never, {} as never); await expect(service.analyzeImport( { originalname: 'unsafe.xlsx', mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: buffer.length, buffer }, { tenantId: 'tenant-1', reportType: 'signature', headerRowCount: 1, dataStartRow: 2 }, )).rejects.toThrow('公式或可执行单元格'); expect(files.upload).not.toHaveBeenCalled(); }); 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 }) => Promise.resolve({ id: 'import-1', ...data })) }, operationLog: { create: jest.fn().mockResolvedValue({ id: 'log-1' }) }, }; 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 = { $transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)), $executeRaw: jest.fn().mockResolvedValue(1), operationLog: { findFirst: jest.fn().mockResolvedValue(null), findUnique: jest.fn().mockResolvedValue({ detail: { fingerprint: 'fingerprint' } }), create: jest.fn().mockResolvedValue({ id: 'operation-1' }), update: jest.fn().mockResolvedValue({}) }, reportMaterialBatch: { create: jest.fn().mockResolvedValue({ id: 'batch-1', batchNo: 'RB001' }), update: jest.fn().mockImplementation(({ data }: { data: Record }) => 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', pendingReport: true, materialVersion: 3, drainageInfo: { signatureReportValues: { license: { fileObjectId: 'image-1', fileName: 'license.png', contentType: 'image/png' } } }, tenant: { name: '测试企业' }, application: { name: '测试应用', status: 'active' } }), update: jest.fn().mockResolvedValue({}), }, smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() }, channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ carrier: 'mobile', group: { status: 'active', items: channels.map((channel, index) => ({ priority: index, carrier: 'mobile', channel })) } }]) }, reportMaterialBatchItem: { findMany: jest.fn().mockResolvedValue([]), 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 }) => Promise.resolve({ id: `task-${String(data.channelId)}`, ...data })), update: jest.fn() }, channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({}) }, reportExportFile: { create: jest.fn().mockImplementation(({ data }: { data: Record }) => 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({ idempotencyKey: 'report-batch:test-1', items: [{ reportType: 'signature', signatureId: 'signature-1', materialVersion: 3 }] }); 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 = { $transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)), $executeRaw: jest.fn().mockResolvedValue(1), operationLog: { findFirst: jest.fn().mockResolvedValue(null), findUnique: jest.fn().mockResolvedValue({ detail: { fingerprint: 'fingerprint' } }), create: jest.fn().mockResolvedValue({ id: 'operation-2' }), update: jest.fn().mockResolvedValue({}) }, reportMaterialBatch: { create: jest.fn().mockResolvedValue({ id: 'batch-2' }), update: jest.fn().mockImplementation(({ data }: { data: Record }) => Promise.resolve(data)) }, smsSignature: { findUnique: jest.fn().mockResolvedValue({ id: 'signature-2', tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', auditStatus: 'approved', pendingReport: true, materialVersion: 1, drainageInfo: {}, tenant: { name: '企业' }, application: { name: '应用', status: 'active' } }), update: jest.fn() }, smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() }, channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ carrier: 'mobile', group: { status: 'active', items: [{ carrier: 'mobile', channel: { id: 'channel-a', name: '通道A', status: 'active' } }] } }]) }, reportMaterialBatchItem: { findMany: jest.fn().mockResolvedValue([]), 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 }) => 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); await expect(service.createBatch({ idempotencyKey: 'report-batch:test-2', items: [{ reportType: 'signature', signatureId: 'signature-2', materialVersion: 1 }] })) .rejects.toMatchObject({ response: expect.objectContaining({ code: 'REPORT_BATCH_NOT_ELIGIBLE' }) }); expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled(); expect(prisma.smsSignature.update).not.toHaveBeenCalled(); }); it('reuses a completed operation for the same idempotency key without generating a second batch', async () => { const prisma = { $transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)), $executeRaw: jest.fn().mockResolvedValue(1), operationLog: { findFirst: jest.fn().mockResolvedValue({ id: 'operation-existing', detail: { status: 'completed', fingerprint: expect.anything(), result: { id: 'batch-existing', batchNo: 'RB-EXISTING', status: 'completed', result: { successCount: 1, skippedCount: 0, failedCount: 0, items: [] } } } }) }, reportMaterialBatch: { create: jest.fn() }, }; const service = new ReportMaterialsService(prisma as never, {} as never, {} as never); const items = [{ reportType: 'signature' as const, signatureId: 'signature-1', materialVersion: 3 }]; const fingerprint = createFingerprint(items); prisma.operationLog.findFirst.mockResolvedValueOnce({ id: 'operation-existing', detail: { status: 'completed', fingerprint, result: { id: 'batch-existing', batchNo: 'RB-EXISTING', status: 'completed', result: { successCount: 1, skippedCount: 0, failedCount: 0, items: [] } } } }); await expect(service.createBatch({ idempotencyKey: 'report-batch:replay', items })).resolves.toMatchObject({ id: 'batch-existing', replayed: true, operationId: 'operation-existing' }); expect(prisma.reportMaterialBatch.create).not.toHaveBeenCalled(); }); it('stages imported signatures for review without changing or approving business data', async () => { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('签名资料'); sheet.addRow(['短信签名', '用途说明']); sheet.addRow(['待审签名', '验证码']); const content = Buffer.from(await workbook.xlsx.writeBuffer()); const stagedRows: Array> = []; const prisma = { reportMaterialImportBatch: { findUnique: jest.fn().mockResolvedValue({ id: 'import-review-1', tenantId: 'tenant-1', applicationId: 'app-1', fileObjectId: 'source-1', fileName: '签名资料.xlsx', reportType: 'signature', status: 'analyzed', sheetName: '签名资料', dataStartRow: 2, }), update: jest.fn().mockImplementation(({ data }: { data: Record }) => Promise.resolve({ id: 'import-review-1', ...data, items: stagedRows })), }, reportMaterialImportItem: { createMany: jest.fn().mockImplementation(({ data }: { data: Array> }) => { stagedRows.push(...data); return Promise.resolve({ count: data.length }); }), }, smsSignature: { findFirst: jest.fn().mockResolvedValue(null) }, operationLog: { create: jest.fn().mockResolvedValue({ id: 'log-import-review' }) }, $transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)), }; const files = { getDownload: jest.fn().mockResolvedValue({ content }) }; const smsConfig = { createSignature: jest.fn(), updateSignature: jest.fn(), approveSignature: jest.fn(), }; const service = new ReportMaterialsService(prisma as never, files as never, smsConfig as never); const result = await service.commitImport('import-review-1', { operatorId: 'operator-1', mappings: [ { sourceHeader: '短信签名', sourceColumnIndex: 1, targetFieldCode: 'signature_name', targetKind: 'signatureName', fieldType: 'string', required: true }, { sourceHeader: '用途说明', sourceColumnIndex: 2, targetFieldCode: 'purpose', targetKind: 'purpose', fieldType: 'string' }, ], }); expect(result).toMatchObject({ status: 'pending_review', successCount: 1, failedCount: 0 }); expect(stagedRows).toEqual([expect.objectContaining({ rowNumber: 2, reportType: 'signature', operation: 'create', status: 'pending_review', payload: expect.objectContaining({ name: '待审签名', purpose: '验证码' }), })]); expect(smsConfig.createSignature).not.toHaveBeenCalled(); expect(smsConfig.updateSignature).not.toHaveBeenCalled(); expect(smsConfig.approveSignature).not.toHaveBeenCalled(); }); it('allows rejecting selected imported rows without requiring a reason', async () => { const prisma = { reportMaterialImportBatch: { findUnique: jest.fn().mockResolvedValue({ id: 'import-review-2', items: [{ id: 'item-1', rowNumber: 2, status: 'pending_review' }], }), update: jest.fn().mockResolvedValue({}), }, reportMaterialImportItem: { update: jest.fn().mockResolvedValue({}), groupBy: jest.fn().mockResolvedValue([{ status: 'rejected', _count: { _all: 1 } }]), }, }; const service = new ReportMaterialsService(prisma as never, {} as never, {} as never); await expect(service.reviewImportItems('import-review-2', { decision: 'reject', itemIds: ['item-1'], reviewerId: 'reviewer-1', })).resolves.toMatchObject({ status: 'rejected', rejectedCount: 1, failedCount: 0 }); expect(prisma.reportMaterialImportItem.update).toHaveBeenCalledWith({ where: { id: 'item-1' }, data: expect.objectContaining({ status: 'rejected', reviewReason: undefined, reviewedById: 'reviewer-1' }), }); }); it('calculates generated batch totals and success rate from per-channel report tasks', async () => { const prisma = { reportMaterialBatch: { findMany: jest.fn().mockResolvedValue([{ id: 'batch-stats-1', batchNo: 'RB-STATS-1', exportFiles: [ { items: [{ task: { id: 'task-1', status: 'approved' } }, { task: { id: 'task-2', status: 'rejected' } }] }, { items: [{ task: { id: 'task-3', status: 'approved' } }] }, ], items: [], }]), count: jest.fn().mockResolvedValue(1), }, }; 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 }], total: 1, page: 2, pageSize: 10, }); expect(prisma.reportMaterialBatch.findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 10, take: 10 })); }); it('rejects malformed preflight items as a readable 400 before Prisma is called', async () => { const prisma = { smsSignature: { findUnique: jest.fn() } }; const service = new ReportMaterialsService(prisma as never, {} as never, {} as never); await expect(service.preflightBatch({ items: [{ reportType: 'signature', signatureId: '' }] })).rejects.toMatchObject({ response: expect.objectContaining({ code: 'REPORT_BATCH_ITEM_INVALID' }) }); expect(prisma.smsSignature.findUnique).not.toHaveBeenCalled(); }); }); 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(items.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: null, materialVersion: item.materialVersion })).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))))).digest('hex'); }