Files
lislgosms/api/src/report-materials/import-review.service.ts
T

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;
}
}