498 lines
20 KiB
TypeScript
498 lines
20 KiB
TypeScript
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
import { FilesService } from '../files/files.service';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { SmsConfigService } from '../sms-config/sms-config.service';
|
|
import type { ImportCommitDto, ImportMapping, PagedQuery, ReviewImportItemsDto } from './report-materials.contracts';
|
|
import {
|
|
normalizePage,
|
|
normalizePageSize,
|
|
dateRange,
|
|
cellText,
|
|
transformValue,
|
|
mappedCoreValue,
|
|
mappedCorePatchValue,
|
|
dynamicValues,
|
|
signatureIdentityReportValues,
|
|
jsonRecord,
|
|
hasValue,
|
|
normalizeImageExtension,
|
|
imageContentType,
|
|
duplicateCoreMappingKind,
|
|
} from './report-materials.helpers';
|
|
import { ReportImportParserService } from './import-parser.service';
|
|
import { compatibleImages, loadCompatibleWorkbook } from './workbook-compatibility';
|
|
|
|
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
|
export class ReportImportReviewService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly files: FilesService,
|
|
private readonly smsConfig: SmsConfigService,
|
|
private readonly importParser: ReportImportParserService,
|
|
) {}
|
|
|
|
async commitImport(batchId: string, data: ImportCommitDto) {
|
|
const batch = await this.prisma.reportMaterialImportBatch.findUnique({ where: { id: batchId } });
|
|
if (!batch) throw new NotFoundException('导入批次不存在');
|
|
if (batch.status !== 'analyzed') throw new ConflictException('该导入批次已提交审核,不能重复导入');
|
|
if (!data.mappings?.length) throw new BadRequestException('请至少配置一个导入字段映射');
|
|
const duplicateCoreKind = duplicateCoreMappingKind(data.mappings);
|
|
if (duplicateCoreKind) {
|
|
const label = {
|
|
signatureName: '短信签名',
|
|
purpose: '签名用途/依据',
|
|
siteName: '站点名称',
|
|
url: '引流 URL 或号码',
|
|
remark: '备注',
|
|
}[duplicateCoreKind];
|
|
throw new BadRequestException(`目标字段“${label}”只能映射一个源列`);
|
|
}
|
|
if (data.profile)
|
|
await this.importParser.saveImportProfile({
|
|
...data.profile,
|
|
reportType: batch.reportType as 'signature' | 'drainage',
|
|
columns: data.mappings,
|
|
});
|
|
const { content } = await this.files.getDownload(batch.fileObjectId);
|
|
const { workbook, wpsImagesBySheet } = await loadCompatibleWorkbook(content);
|
|
const worksheet = workbook.getWorksheet(batch.sheetName);
|
|
if (!worksheet) throw new BadRequestException('导入工作表不存在');
|
|
const images = compatibleImages(workbook, worksheet, wpsImagesBySheet);
|
|
const imageByCell = new Map(images.map((image) => [`${image.row}:${image.column}`, image]));
|
|
let successCount = 0;
|
|
const failures: Array<{ rowNumber: number; reason: string }> = [];
|
|
const stagedItems: Prisma.ReportMaterialImportItemCreateManyInput[] = [];
|
|
for (let rowNumber = batch.dataStartRow; rowNumber <= worksheet.rowCount; rowNumber += 1) {
|
|
const values: Record<string, unknown> = {};
|
|
try {
|
|
for (const mapping of data.mappings) {
|
|
const image = imageByCell.get(`${rowNumber}:${mapping.sourceColumnIndex}`);
|
|
if (image && mapping.fieldType !== 'string') {
|
|
const uploaded = await this.files.upload(
|
|
{ tenantId: batch.tenantId, purpose: 'report_material', prefix: `report-materials/import-${batch.id}` },
|
|
{
|
|
originalname: `${mapping.targetFieldCode}-row-${rowNumber}.${normalizeImageExtension(image.extension)}`,
|
|
mimetype: imageContentType(image.extension),
|
|
size: image.buffer.length,
|
|
buffer: image.buffer,
|
|
},
|
|
);
|
|
values[mapping.targetFieldCode] = {
|
|
fileObjectId: uploaded.id,
|
|
fileName: uploaded.fileName,
|
|
contentType: uploaded.contentType,
|
|
};
|
|
} else {
|
|
values[mapping.targetFieldCode] = transformValue(
|
|
cellText(worksheet.getCell(rowNumber, mapping.sourceColumnIndex)),
|
|
mapping.transform,
|
|
);
|
|
}
|
|
}
|
|
if (!Object.values(values).some(hasValue)) continue;
|
|
for (const mapping of data.mappings.filter((item) => item.required)) {
|
|
if (!hasValue(values[mapping.targetFieldCode])) throw new Error(`缺少必填字段:${mapping.sourceHeader}`);
|
|
}
|
|
const staged =
|
|
batch.reportType === 'signature'
|
|
? await this.stageSignatureRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values)
|
|
: await this.stageDrainageRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values);
|
|
stagedItems.push({
|
|
batchId,
|
|
rowNumber,
|
|
reportType: batch.reportType,
|
|
operation: staged.operation,
|
|
targetId: staged.targetId,
|
|
status: 'pending_review',
|
|
payload: staged.payload as Prisma.InputJsonValue,
|
|
originalSnapshot: staged.originalSnapshot as Prisma.InputJsonValue | undefined,
|
|
});
|
|
successCount += 1;
|
|
} catch (error) {
|
|
const reason = error instanceof Error ? error.message : '导入失败';
|
|
failures.push({ rowNumber, reason });
|
|
stagedItems.push({
|
|
batchId,
|
|
rowNumber,
|
|
reportType: batch.reportType,
|
|
operation: 'invalid',
|
|
status: 'invalid',
|
|
payload: values as Prisma.InputJsonValue,
|
|
errorMessage: reason,
|
|
});
|
|
}
|
|
}
|
|
const updated = await this.prisma.$transaction(async (tx) => {
|
|
if (stagedItems.length) await tx.reportMaterialImportItem.createMany({ data: stagedItems });
|
|
return tx.reportMaterialImportBatch.update({
|
|
where: { id: batchId },
|
|
data: {
|
|
status: successCount ? 'pending_review' : 'failed',
|
|
mapping: data.mappings as Prisma.InputJsonValue,
|
|
result: { failures } as Prisma.InputJsonValue,
|
|
successCount,
|
|
failedCount: failures.length,
|
|
},
|
|
include: { items: { orderBy: { rowNumber: 'asc' } } },
|
|
});
|
|
});
|
|
await this.prisma.operationLog.create({
|
|
data: {
|
|
tenantId: batch.tenantId,
|
|
userId: data.operatorId,
|
|
action: 'report_material.import_committed',
|
|
resource: 'report_material_import',
|
|
resourceId: batch.id,
|
|
detail: {
|
|
fileName: batch.fileName,
|
|
filters: { applicationId: batch.applicationId, reportType: batch.reportType, sheetName: batch.sheetName },
|
|
successCount,
|
|
failedCount: failures.length,
|
|
} as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
async listImportReviewBatches(query: PagedQuery & { reportType?: 'signature' | 'drainage'; status?: string } = {}) {
|
|
const page = normalizePage(query.page);
|
|
const pageSize = normalizePageSize(query.pageSize);
|
|
const where: Prisma.ReportMaterialImportBatchWhereInput = {
|
|
reportType: query.reportType,
|
|
status: query.status && query.status !== 'all' ? query.status : undefined,
|
|
createdAt: dateRange(query.startAt, query.endAt),
|
|
OR: query.keyword?.trim()
|
|
? [{ fileName: { contains: query.keyword.trim() } }, { id: { contains: query.keyword.trim() } }]
|
|
: undefined,
|
|
};
|
|
const [batches, total] = await Promise.all([
|
|
this.prisma.reportMaterialImportBatch.findMany({
|
|
where,
|
|
include: { items: { orderBy: { rowNumber: 'asc' } } },
|
|
orderBy: { createdAt: 'desc' },
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
}),
|
|
this.prisma.reportMaterialImportBatch.count({ where }),
|
|
]);
|
|
const tenantIds = [...new Set(batches.map((batch) => batch.tenantId))];
|
|
const applicationIds = [
|
|
...new Set(batches.map((batch) => batch.applicationId).filter((id): id is string => Boolean(id))),
|
|
];
|
|
const reviewerIds = [
|
|
...new Set(
|
|
batches
|
|
.flatMap((batch) => [batch.reviewedById, ...batch.items.map((item) => item.reviewedById)])
|
|
.filter((id): id is string => Boolean(id)),
|
|
),
|
|
];
|
|
const [tenants, applications, reviewers] = await Promise.all([
|
|
tenantIds.length
|
|
? this.prisma.tenant.findMany({ where: { id: { in: tenantIds } }, select: { id: true, name: true } })
|
|
: [],
|
|
applicationIds.length
|
|
? this.prisma.smsApplication.findMany({
|
|
where: { id: { in: applicationIds } },
|
|
select: { id: true, name: true },
|
|
})
|
|
: [],
|
|
reviewerIds.length
|
|
? this.prisma.user.findMany({
|
|
where: { id: { in: reviewerIds } },
|
|
select: { id: true, username: true, displayName: true },
|
|
})
|
|
: [],
|
|
]);
|
|
const tenantById = new Map(tenants.map((item) => [item.id, item]));
|
|
const applicationById = new Map(applications.map((item) => [item.id, item]));
|
|
const reviewerById = new Map(reviewers.map((item) => [item.id, item]));
|
|
return {
|
|
items: batches.map((batch) => ({
|
|
...batch,
|
|
tenant: tenantById.get(batch.tenantId) ?? null,
|
|
application: batch.applicationId ? (applicationById.get(batch.applicationId) ?? null) : null,
|
|
reviewer: batch.reviewedById ? (reviewerById.get(batch.reviewedById) ?? null) : null,
|
|
items: batch.items.map((item) => ({
|
|
...item,
|
|
reviewer: item.reviewedById ? (reviewerById.get(item.reviewedById) ?? null) : null,
|
|
})),
|
|
})),
|
|
total,
|
|
page,
|
|
pageSize,
|
|
};
|
|
}
|
|
|
|
async reviewImportItems(batchId: string, data: ReviewImportItemsDto) {
|
|
if (!data.reviewerId) throw new BadRequestException('Reviewer session is required');
|
|
if (!['approve', 'reject'].includes(data.decision))
|
|
throw new BadRequestException('Unsupported import review decision');
|
|
const batch = await this.prisma.reportMaterialImportBatch.findUnique({
|
|
where: { id: batchId },
|
|
include: {
|
|
items: {
|
|
where: { id: data.itemIds?.length ? { in: data.itemIds } : undefined, status: 'pending_review' },
|
|
orderBy: { rowNumber: 'asc' },
|
|
},
|
|
},
|
|
});
|
|
if (!batch) throw new NotFoundException('导入审核批次不存在');
|
|
if (!batch.items.length) throw new BadRequestException('没有可审核的导入明细');
|
|
let approvedCount = 0;
|
|
let rejectedCount = 0;
|
|
const failures: Array<{ itemId: string; rowNumber: number; reason: string }> = [];
|
|
for (const item of batch.items) {
|
|
if (data.decision === 'reject') {
|
|
await this.prisma.reportMaterialImportItem.update({
|
|
where: { id: item.id },
|
|
data: {
|
|
status: 'rejected',
|
|
reviewReason: data.reason?.trim(),
|
|
reviewedById: data.reviewerId,
|
|
reviewedAt: new Date(),
|
|
},
|
|
});
|
|
rejectedCount += 1;
|
|
continue;
|
|
}
|
|
try {
|
|
const targetId = await this.applyImportItem(batch, item, data.reviewerId);
|
|
await this.prisma.reportMaterialImportItem.update({
|
|
where: { id: item.id },
|
|
data: {
|
|
targetId,
|
|
status: 'approved',
|
|
reviewReason: data.reason?.trim(),
|
|
reviewedById: data.reviewerId,
|
|
reviewedAt: new Date(),
|
|
errorMessage: null,
|
|
},
|
|
});
|
|
approvedCount += 1;
|
|
} catch (error) {
|
|
const reason = error instanceof Error ? error.message : '导入审核应用失败';
|
|
failures.push({ itemId: item.id, rowNumber: item.rowNumber, reason });
|
|
await this.prisma.reportMaterialImportItem.update({
|
|
where: { id: item.id },
|
|
data: { status: 'invalid', errorMessage: reason, reviewedById: data.reviewerId, reviewedAt: new Date() },
|
|
});
|
|
}
|
|
}
|
|
const counts = await this.prisma.reportMaterialImportItem.groupBy({
|
|
by: ['status'],
|
|
where: { batchId },
|
|
_count: { _all: true },
|
|
});
|
|
const countByStatus = new Map(counts.map((item) => [item.status, item._count._all]));
|
|
const pendingCount = countByStatus.get('pending_review') ?? 0;
|
|
const totalApproved = countByStatus.get('approved') ?? 0;
|
|
const totalRejected = countByStatus.get('rejected') ?? 0;
|
|
const totalInvalid = countByStatus.get('invalid') ?? 0;
|
|
const status = pendingCount
|
|
? 'partially_reviewed'
|
|
: totalApproved && (totalRejected || totalInvalid)
|
|
? 'partially_approved'
|
|
: totalApproved
|
|
? 'approved'
|
|
: totalRejected
|
|
? 'rejected'
|
|
: 'failed';
|
|
await this.prisma.reportMaterialImportBatch.update({
|
|
where: { id: batchId },
|
|
data: {
|
|
status,
|
|
reviewedById: pendingCount ? undefined : data.reviewerId,
|
|
reviewedAt: pendingCount ? undefined : new Date(),
|
|
completedAt: pendingCount ? undefined : new Date(),
|
|
},
|
|
});
|
|
return { batchId, status, approvedCount, rejectedCount, failedCount: failures.length, failures };
|
|
}
|
|
|
|
async stageSignatureRow(
|
|
tenantId: string,
|
|
applicationId: string | undefined,
|
|
mappings: ImportMapping[],
|
|
values: Record<string, unknown>,
|
|
) {
|
|
const name = mappedCoreValue(mappings, values, 'signatureName');
|
|
if (!name) throw new Error('缺少短信签名');
|
|
const purpose = mappedCorePatchValue(mappings, values, 'purpose');
|
|
const signatureReportValues = dynamicValues(mappings, values);
|
|
const existing = await this.prisma.smsSignature.findFirst({
|
|
where: { tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } },
|
|
});
|
|
return {
|
|
operation: existing ? 'update' : 'create',
|
|
targetId: existing?.id,
|
|
payload: {
|
|
tenantId,
|
|
applicationId,
|
|
name,
|
|
...(purpose !== undefined ? { purpose } : {}),
|
|
drainageInfo: { signatureReportValues },
|
|
},
|
|
originalSnapshot: existing
|
|
? {
|
|
id: existing.id,
|
|
applicationId: existing.applicationId,
|
|
name: existing.name,
|
|
purpose: existing.purpose,
|
|
drainageInfo: existing.drainageInfo,
|
|
auditStatus: existing.auditStatus,
|
|
updatedAt: existing.updatedAt,
|
|
}
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
async stageDrainageRow(
|
|
tenantId: string,
|
|
applicationId: string | undefined,
|
|
mappings: ImportMapping[],
|
|
values: Record<string, unknown>,
|
|
) {
|
|
const signatureName = mappedCoreValue(mappings, values, 'signatureName');
|
|
const url = mappedCoreValue(mappings, values, 'url');
|
|
if (!signatureName || !url) throw new Error('引流信息必须包含短信签名和引流 URL 或号码');
|
|
const signature = await this.prisma.smsSignature.findFirst({
|
|
where: { tenantId, applicationId: applicationId ?? null, name: signatureName, auditStatus: 'approved' },
|
|
});
|
|
if (!signature) throw new Error(`未找到已审核签名:${signatureName}`);
|
|
const remark = mappedCoreValue(mappings, values, 'remark');
|
|
const reportValues = dynamicValues(mappings, values);
|
|
const existing = await this.prisma.smsDrainageInfo.findFirst({
|
|
where: { signatureId: signature.id, url, auditStatus: { not: 'deleted' } },
|
|
});
|
|
return {
|
|
operation: existing ? 'update' : 'create',
|
|
targetId: existing?.id,
|
|
payload: {
|
|
tenantId,
|
|
applicationId,
|
|
signatureId: signature.id,
|
|
signatureName,
|
|
siteName: url,
|
|
url,
|
|
remark,
|
|
reportValues,
|
|
},
|
|
originalSnapshot: existing
|
|
? {
|
|
id: existing.id,
|
|
siteName: existing.siteName,
|
|
url: existing.url,
|
|
remark: existing.remark,
|
|
reportValues: existing.reportValues,
|
|
auditStatus: existing.auditStatus,
|
|
updatedAt: existing.updatedAt,
|
|
}
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
async applyImportItem(
|
|
batch: { tenantId: string; applicationId: string | null; reportType: string },
|
|
item: { reportType: string; targetId: string | null; payload: Prisma.JsonValue },
|
|
reviewerId: string,
|
|
) {
|
|
const payload = jsonRecord(item.payload);
|
|
if (item.reportType === 'signature') {
|
|
const name = String(payload.name ?? '');
|
|
const applicationId = typeof payload.applicationId === 'string' ? payload.applicationId : undefined;
|
|
const importedDrainage = jsonRecord(payload.drainageInfo);
|
|
const importedReportValues = jsonRecord(importedDrainage.signatureReportValues);
|
|
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 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 {
|
|
applicationId,
|
|
name,
|
|
...(Object.prototype.hasOwnProperty.call(payload, 'purpose')
|
|
? { purpose: String(payload.purpose ?? '') }
|
|
: {}),
|
|
drainageInfo: {
|
|
...currentDrainage,
|
|
signatureReportValues: {
|
|
...jsonRecord(currentDrainage.signatureReportValues),
|
|
...identityValues,
|
|
...importedReportValues,
|
|
},
|
|
},
|
|
};
|
|
};
|
|
let targetId = item.targetId;
|
|
if (targetId) {
|
|
const current = await this.prisma.smsSignature.findUnique({ where: { id: targetId } });
|
|
if (!current || current.auditStatus === 'deleted') throw new Error('原签名已删除,不能应用导入修改');
|
|
await this.smsConfig.updateSignature(targetId, buildBody(current), batch.tenantId);
|
|
} else {
|
|
const duplicate = await this.prisma.smsSignature.findFirst({
|
|
where: {
|
|
tenantId: batch.tenantId,
|
|
applicationId: applicationId ?? null,
|
|
name,
|
|
auditStatus: { not: 'deleted' },
|
|
},
|
|
});
|
|
if (duplicate) {
|
|
targetId = duplicate.id;
|
|
await this.smsConfig.updateSignature(targetId, buildBody(duplicate), batch.tenantId);
|
|
} else {
|
|
const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...buildBody() });
|
|
targetId = created.id;
|
|
}
|
|
}
|
|
await this.smsConfig.approveSignature(targetId, { reviewerId, reason: `批量导入审核通过:${name}` });
|
|
return targetId;
|
|
}
|
|
const signatureId = String(payload.signatureId ?? '');
|
|
const url = String(payload.url ?? '');
|
|
const body = {
|
|
url,
|
|
remark: typeof payload.remark === 'string' ? payload.remark : undefined,
|
|
reportValues: jsonRecord(payload.reportValues),
|
|
};
|
|
let targetId = item.targetId;
|
|
if (targetId) {
|
|
const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: targetId } });
|
|
if (!current || current.auditStatus === 'deleted') throw new Error('原引流信息已删除,不能应用导入修改');
|
|
await this.smsConfig.updateDrainageInfo(targetId, body, { initialAuditStatus: 'pending' }, batch.tenantId);
|
|
} else {
|
|
const duplicate = await this.prisma.smsDrainageInfo.findFirst({
|
|
where: { signatureId, url, auditStatus: { not: 'deleted' } },
|
|
});
|
|
if (duplicate) {
|
|
targetId = duplicate.id;
|
|
await this.smsConfig.updateDrainageInfo(targetId, body, { initialAuditStatus: 'pending' }, batch.tenantId);
|
|
} else {
|
|
const created = await this.smsConfig.createDrainageInfo(
|
|
signatureId,
|
|
body,
|
|
{ initialAuditStatus: 'pending' },
|
|
batch.tenantId,
|
|
);
|
|
targetId = created.id;
|
|
}
|
|
}
|
|
await this.smsConfig.approveDrainageInfo(targetId, { reviewerId, reason: `批量导入审核通过:${url}` });
|
|
return targetId;
|
|
}
|
|
}
|