fix: approve imported signature identity fields
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
|||||||
mappedCoreValue,
|
mappedCoreValue,
|
||||||
mappedCorePatchValue,
|
mappedCorePatchValue,
|
||||||
dynamicValues,
|
dynamicValues,
|
||||||
|
signatureIdentityReportValues,
|
||||||
jsonRecord,
|
jsonRecord,
|
||||||
hasValue,
|
hasValue,
|
||||||
normalizeImageExtension,
|
normalizeImageExtension,
|
||||||
@@ -390,8 +391,24 @@ export class ReportImportReviewService {
|
|||||||
const applicationId = typeof payload.applicationId === 'string' ? payload.applicationId : undefined;
|
const applicationId = typeof payload.applicationId === 'string' ? payload.applicationId : undefined;
|
||||||
const importedDrainage = jsonRecord(payload.drainageInfo);
|
const importedDrainage = jsonRecord(payload.drainageInfo);
|
||||||
const importedReportValues = jsonRecord(importedDrainage.signatureReportValues);
|
const importedReportValues = jsonRecord(importedDrainage.signatureReportValues);
|
||||||
const buildBody = (current?: { drainageInfo: Prisma.JsonValue | null }) => {
|
const [reportFields, tenant, application] = await Promise.all([
|
||||||
|
this.smsConfig.getApplicationReportFields(applicationId, 'signature'),
|
||||||
|
this.prisma.tenant.findUnique({ where: { id: batch.tenantId }, select: { name: true } }),
|
||||||
|
applicationId
|
||||||
|
? this.prisma.smsApplication.findUnique({ where: { id: applicationId }, select: { name: true } })
|
||||||
|
: Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
const buildBody = (current?: { drainageInfo: Prisma.JsonValue | null; purpose?: string | null }) => {
|
||||||
const currentDrainage = jsonRecord(current?.drainageInfo);
|
const currentDrainage = jsonRecord(current?.drainageInfo);
|
||||||
|
const purpose = Object.prototype.hasOwnProperty.call(payload, 'purpose')
|
||||||
|
? String(payload.purpose ?? '')
|
||||||
|
: current?.purpose;
|
||||||
|
const identityValues = signatureIdentityReportValues(reportFields, {
|
||||||
|
signatureName: name,
|
||||||
|
purpose,
|
||||||
|
enterpriseName: tenant?.name,
|
||||||
|
applicationName: application?.name,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
applicationId,
|
applicationId,
|
||||||
name,
|
name,
|
||||||
@@ -402,6 +419,7 @@ export class ReportImportReviewService {
|
|||||||
...currentDrainage,
|
...currentDrainage,
|
||||||
signatureReportValues: {
|
signatureReportValues: {
|
||||||
...jsonRecord(currentDrainage.signatureReportValues),
|
...jsonRecord(currentDrainage.signatureReportValues),
|
||||||
|
...identityValues,
|
||||||
...importedReportValues,
|
...importedReportValues,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -237,6 +237,33 @@ export function dynamicValues(mappings: ImportMapping[], values: Record<string,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function signatureIdentityReportValues(
|
||||||
|
fields: Array<{ code: string; name: string; fieldType?: string }>,
|
||||||
|
identity: {
|
||||||
|
signatureName?: string;
|
||||||
|
purpose?: string | null;
|
||||||
|
enterpriseName?: string;
|
||||||
|
applicationName?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
fields.flatMap((field) => {
|
||||||
|
if (field.fieldType && field.fieldType !== 'string') return [];
|
||||||
|
const semantic = normalizeHeader(`${field.code}/${field.name}`);
|
||||||
|
const value = /短信签名|签名名称|signaturename|sms(?:signature|sign)|^sign$/.test(semantic)
|
||||||
|
? identity.signatureName
|
||||||
|
: /签名用途|签名依据|purpose/.test(semantic)
|
||||||
|
? identity.purpose
|
||||||
|
: /企业名称|公司名称|enterprisename|companyname/.test(semantic)
|
||||||
|
? identity.enterpriseName
|
||||||
|
: /应用名称|applicationname|appname/.test(semantic)
|
||||||
|
? identity.applicationName
|
||||||
|
: undefined;
|
||||||
|
return hasValue(value) ? [[field.code, value]] : [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function jsonRecord(value: unknown): Record<string, unknown> {
|
export function jsonRecord(value: unknown): Record<string, unknown> {
|
||||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import ExcelJS from 'exceljs';
|
import ExcelJS from 'exceljs';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { ReportMaterialsService } from './report-materials.service';
|
import { ReportMaterialsService } from './report-materials.service';
|
||||||
import { mappedCorePatchValue } from './report-materials.helpers';
|
import { mappedCorePatchValue, signatureIdentityReportValues } from './report-materials.helpers';
|
||||||
|
|
||||||
describe('ReportMaterialsService', () => {
|
describe('ReportMaterialsService', () => {
|
||||||
it('requires an enterprise application before parsing an import workbook', async () => {
|
it('requires an enterprise application before parsing an import workbook', async () => {
|
||||||
@@ -43,6 +43,27 @@ describe('ReportMaterialsService', () => {
|
|||||||
),
|
),
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('materializes system identity fields without inventing a missing signature purpose', () => {
|
||||||
|
const fields = [
|
||||||
|
{ code: 'qaSignatureName', name: '短信签名', fieldType: 'string' },
|
||||||
|
{ code: 'qaSignaturePurpose', name: '签名用途', fieldType: 'string' },
|
||||||
|
{ code: 'qaEnterpriseName', name: '企业名称', fieldType: 'string' },
|
||||||
|
{ code: 'qaApplicationName', name: '应用名称', fieldType: 'string' },
|
||||||
|
{ code: 'qaLicenseImage', name: '营业执照图片', fieldType: 'image' },
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
signatureIdentityReportValues(fields, {
|
||||||
|
signatureName: '【导入测试】',
|
||||||
|
enterpriseName: '测试企业',
|
||||||
|
applicationName: '测试应用',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
qaSignatureName: '【导入测试】',
|
||||||
|
qaEnterpriseName: '测试企业',
|
||||||
|
qaApplicationName: '测试应用',
|
||||||
|
});
|
||||||
|
});
|
||||||
it('builds an official XLSX import template with documented signature columns', async () => {
|
it('builds an official XLSX import template with documented signature columns', async () => {
|
||||||
const operationLog = { create: jest.fn().mockResolvedValue({ id: 'log-template' }) };
|
const operationLog = { create: jest.fn().mockResolvedValue({ id: 'log-template' }) };
|
||||||
const service = new ReportMaterialsService({ operationLog } as never, {} as never, {} as never);
|
const service = new ReportMaterialsService({ operationLog } as never, {} as never, {} as never);
|
||||||
@@ -602,6 +623,79 @@ describe('ReportMaterialsService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('approves an imported signature by supplying configured identity fields from real entities', async () => {
|
||||||
|
const item = {
|
||||||
|
id: 'item-identity-1',
|
||||||
|
rowNumber: 2,
|
||||||
|
reportType: 'signature',
|
||||||
|
targetId: null,
|
||||||
|
status: 'pending_review',
|
||||||
|
payload: {
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
applicationId: 'app-1',
|
||||||
|
name: '【导入测试】',
|
||||||
|
purpose: '验证码通知',
|
||||||
|
drainageInfo: {
|
||||||
|
signatureReportValues: {
|
||||||
|
qaLicenseImage: { fileObjectId: 'image-1', fileName: 'license.png', contentType: 'image/png' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const prisma = {
|
||||||
|
reportMaterialImportBatch: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
|
id: 'batch-identity-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
applicationId: 'app-1',
|
||||||
|
reportType: 'signature',
|
||||||
|
items: [item],
|
||||||
|
}),
|
||||||
|
update: jest.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
reportMaterialImportItem: {
|
||||||
|
update: jest.fn().mockResolvedValue({}),
|
||||||
|
groupBy: jest.fn().mockResolvedValue([{ status: 'approved', _count: { _all: 1 } }]),
|
||||||
|
},
|
||||||
|
smsSignature: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue(null),
|
||||||
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
tenant: { findUnique: jest.fn().mockResolvedValue({ name: '测试企业' }) },
|
||||||
|
smsApplication: { findUnique: jest.fn().mockResolvedValue({ name: '测试应用' }) },
|
||||||
|
};
|
||||||
|
const smsConfig = {
|
||||||
|
getApplicationReportFields: jest.fn().mockResolvedValue([
|
||||||
|
{ code: 'qaSignatureName', name: '短信签名', fieldType: 'string' },
|
||||||
|
{ code: 'qaSignaturePurpose', name: '签名用途', fieldType: 'string' },
|
||||||
|
{ code: 'qaEnterpriseName', name: '企业名称', fieldType: 'string' },
|
||||||
|
{ code: 'qaApplicationName', name: '应用名称', fieldType: 'string' },
|
||||||
|
{ code: 'qaLicenseImage', name: '营业执照图片', fieldType: 'image' },
|
||||||
|
]),
|
||||||
|
createSignature: jest.fn().mockImplementation(({ drainageInfo }: { drainageInfo: { signatureReportValues: Record<string, unknown> } }) => {
|
||||||
|
expect(drainageInfo.signatureReportValues).toEqual(expect.objectContaining({
|
||||||
|
qaSignatureName: '【导入测试】',
|
||||||
|
qaSignaturePurpose: '验证码通知',
|
||||||
|
qaEnterpriseName: '测试企业',
|
||||||
|
qaApplicationName: '测试应用',
|
||||||
|
qaLicenseImage: expect.objectContaining({ fileObjectId: 'image-1' }),
|
||||||
|
}));
|
||||||
|
return Promise.resolve({ id: 'signature-created-1' });
|
||||||
|
}),
|
||||||
|
approveSignature: jest.fn().mockResolvedValue({ id: 'signature-created-1', auditStatus: 'approved' }),
|
||||||
|
};
|
||||||
|
const service = new ReportMaterialsService(prisma as never, {} as never, smsConfig as never);
|
||||||
|
|
||||||
|
await expect(service.reviewImportItems('batch-identity-1', {
|
||||||
|
decision: 'approve',
|
||||||
|
itemIds: [item.id],
|
||||||
|
reviewerId: 'reviewer-1',
|
||||||
|
})).resolves.toMatchObject({ status: 'approved', approvedCount: 1, failedCount: 0 });
|
||||||
|
expect(smsConfig.approveSignature).toHaveBeenCalledWith('signature-created-1', expect.objectContaining({
|
||||||
|
reviewerId: 'reviewer-1',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it('calculates generated batch totals and success rate from per-channel report tasks', async () => {
|
it('calculates generated batch totals and success rate from per-channel report tasks', async () => {
|
||||||
const prisma = {
|
const prisma = {
|
||||||
reportMaterialBatch: {
|
reportMaterialBatch: {
|
||||||
|
|||||||
@@ -3631,8 +3631,9 @@ npm run verify:phase8
|
|||||||
| TC-REPORT-MATERIAL-IMPORT-001 | 将含两行表头、文本列和营业执照/身份证等内嵌图片的 WPS 在线表格另存为 `.xlsx`,选择企业、应用和签名资料后解析。 | NestJS 读取真实工作表及图片锚点,返回列、组合表头、前十行和图片数预览;原文件写 MinIO,导入批次写 PostgreSQL;解析和提交审核均不直接修改签名、不建通道任务。 |
|
| TC-REPORT-MATERIAL-IMPORT-001 | 将含两行表头、文本列和营业执照/身份证等内嵌图片的 WPS 在线表格另存为 `.xlsx`,选择企业、应用和签名资料后解析。 | NestJS 读取真实工作表及图片锚点,返回列、组合表头、前十行和图片数预览;原文件写 MinIO,导入批次写 PostgreSQL;解析和提交审核均不直接修改签名、不建通道任务。 |
|
||||||
| TC-REPORT-MATERIAL-IMPORT-002 | 将源列映射到签名及动态报备字段后提交导入,再进入短信签名审核的“导入批次审核”页签查看100行数据并一次通过其中勾选的多行。 | 每行先以新增/修改/无效状态落待审核明细;只有通过行才创建或修改真实签名并写审核人、审核时间,随后进入待生成资料池;未选行保持待审核,页面不要求逐行打开确认。 |
|
| TC-REPORT-MATERIAL-IMPORT-002 | 将源列映射到签名及动态报备字段后提交导入,再进入短信签名审核的“导入批次审核”页签查看100行数据并一次通过其中勾选的多行。 | 每行先以新增/修改/无效状态落待审核明细;只有通过行才创建或修改真实签名并写审核人、审核时间,随后进入待生成资料池;未选行保持待审核,页面不要求逐行打开确认。 |
|
||||||
| TC-REPORT-MATERIAL-IMPORT-003 | 导入引流资料,其中一行引用不存在或未审核签名;在引流审核页批量通过合法行并驳回部分行,不填写驳回原因。 | 合法行审核通过后创建/更新真实 `SmsDrainageInfo` 并进入待生成池;非法行保留行号和原因;空驳回原因可正常提交,同批其他行不受影响,也不自动创建通道报备任务。 |
|
| TC-REPORT-MATERIAL-IMPORT-003 | 导入引流资料,其中一行引用不存在或未审核签名;在引流审核页批量通过合法行并驳回部分行,不填写驳回原因。 | 合法行审核通过后创建/更新真实 `SmsDrainageInfo` 并进入待生成池;非法行保留行号和原因;空驳回原因可正常提交,同批其他行不受影响,也不自动创建通道报备任务。 |
|
||||||
| TC-REPORT-MATERIAL-IMPORT-004 | 导入文件中同时包含已存在对象的修改和不存在对象的新增,提交审核前后分别读取业务表。 | 提交审核前业务表完全不变;审核页展示新增/修改及原数据快照;通过后才应用变更,重复点击已处理行不会再次递增材料版本或重复创建对象。 |
|
| TC-REPORT-MATERIAL-IMPORT-004 | 应用将短信签名、签名用途、企业名称和应用名称配置为必填报备字段;导入一份包含签名、用途和标准 Drawing 图片的 XLSX,审核通过后生成并导出报备资料。 | 导入审核从签名、企业、应用实体物化系统身份字段,显式映射值优先;真实缺失值仍被拦截。签名审核通过并进入待生成池,报备批次及导出文件使用真实 PostgreSQL/对象存储数据且图片可回读。 |
|
||||||
| TC-REPORT-MATERIAL-IMPORT-005 | 分别在签名和引流审核页面按文件名、状态、时间筛选导入批次,翻页后选择整批或部分明细审核。 | 查询、总数和分页来自真实后端;批次汇总待审、通过、驳回、无效数量,刷新后保持一致。 |
|
| TC-REPORT-MATERIAL-IMPORT-005 | 导入文件中同时包含已存在对象的修改和不存在对象的新增,提交审核前后分别读取业务表。 | 提交审核前业务表完全不变;审核页展示新增/修改及原数据快照;通过后才应用变更,重复点击已处理行不会再次递增材料版本或重复创建对象。 |
|
||||||
|
| TC-REPORT-MATERIAL-IMPORT-006 | 分别在签名和引流审核页面按文件名、状态、时间筛选导入批次,翻页后选择整批或部分明细审核。 | 查询、总数和分页来自真实后端;批次汇总待审、通过、驳回、无效数量,刷新后保持一致。 |
|
||||||
| TC-REPORT-CHANNEL-FIELD-001 | 在同一通道分别打开签名和引流字段配置,添加字段、修改通道表头、上下排序、设置必填/列宽/图片宽高后保存并刷新。 | 两类配置相互独立且完整持久化;刷新后字段池、映射表头和顺序一致;重复字段、停用字段和非法尺寸由 API 拒绝或归一化。 |
|
| TC-REPORT-CHANNEL-FIELD-001 | 在同一通道分别打开签名和引流字段配置,添加字段、修改通道表头、上下排序、设置必填/列宽/图片宽高后保存并刷新。 | 两类配置相互独立且完整持久化;刷新后字段池、映射表头和顺序一致;重复字段、停用字段和非法尺寸由 API 拒绝或归一化。 |
|
||||||
| TC-REPORT-BATCH-001 | 一个应用配置两个生效通道,选择一个待报备签名创建统一批次。 | 系统从真实应用路由展开两个通道,生成两个独立通道任务和两个 `.xlsx`;每个文件表头名称、列顺序和列宽均来自对应通道配置,批次可下载两份文件。 |
|
| TC-REPORT-BATCH-001 | 一个应用配置两个生效通道,选择一个待报备签名创建统一批次。 | 系统从真实应用路由展开两个通道,生成两个独立通道任务和两个 `.xlsx`;每个文件表头名称、列顺序和列宽均来自对应通道配置,批次可下载两份文件。 |
|
||||||
| TC-REPORT-BATCH-002 | 两个通道对同一标准字段配置不同表头和顺序,并包含图片列,生成批次后分别用 WPS 打开。 | 两份工作簿各自使用对应通道映射,图片直接显示在数据行内且尺寸按通道配置;文件不是 URL 清单,文本与图片属于同一材料快照。 |
|
| TC-REPORT-BATCH-002 | 两个通道对同一标准字段配置不同表头和顺序,并包含图片列,生成批次后分别用 WPS 打开。 | 两份工作簿各自使用对应通道映射,图片直接显示在数据行内且尺寸按通道配置;文件不是 URL 清单,文本与图片属于同一材料快照。 |
|
||||||
|
|||||||
Reference in New Issue
Block a user