feat: build reporting workbench workflow
This commit is contained in:
@@ -6,334 +6,497 @@ import { extname } from 'node:path';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsConfigService } from '../sms-config/sms-config.service';
|
||||
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
|
||||
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
|
||||
import type {
|
||||
AnalyzeImportOptions,
|
||||
CreateImportProfileDto,
|
||||
CreateReportBatchDto,
|
||||
EmbeddedImage,
|
||||
ImportCommitDto,
|
||||
ImportMapping,
|
||||
PagedQuery,
|
||||
ReportBatchInspection,
|
||||
ReportBatchTarget,
|
||||
ReviewImportItemsDto,
|
||||
} from './report-materials.contracts';
|
||||
import {
|
||||
profileData,
|
||||
validateProfile,
|
||||
loadWorkbook,
|
||||
assertSafeWorkbook,
|
||||
safeSpreadsheetText,
|
||||
readEmbeddedImages,
|
||||
suggestMappings,
|
||||
remapProfileColumns,
|
||||
signatureCoreMapping,
|
||||
drainageCoreMapping,
|
||||
normalizeHeader,
|
||||
normalizeFieldCode,
|
||||
clamp,
|
||||
normalizePage,
|
||||
normalizePageSize,
|
||||
dateRange,
|
||||
cellText,
|
||||
transformValue,
|
||||
mappedCoreValue,
|
||||
mappedCorePatchValue,
|
||||
dynamicValues,
|
||||
jsonRecord,
|
||||
hasValue,
|
||||
isFileRef,
|
||||
resolveExportValue,
|
||||
applyExportTransform,
|
||||
styleHeader,
|
||||
normalizeImageExtension,
|
||||
imageContentType,
|
||||
safeFileName,
|
||||
normalizeBatchIdempotencyKey,
|
||||
jsonStringArray,
|
||||
jsonSafe,
|
||||
} from './report-materials.helpers';
|
||||
import { ReportImportParserService } from './import-parser.service';
|
||||
|
||||
/** 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) {}
|
||||
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('请至少配置一个导入字段映射');
|
||||
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 = await loadWorkbook(content);
|
||||
assertSafeWorkbook(workbook);
|
||||
const worksheet = workbook.getWorksheet(batch.sheetName);
|
||||
if (!worksheet) throw new BadRequestException('导入工作表不存在');
|
||||
const images = readEmbeddedImages(workbook, worksheet);
|
||||
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}` }, {
|
||||
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('请至少配置一个导入字段映射');
|
||||
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 = await loadWorkbook(content);
|
||||
assertSafeWorkbook(workbook);
|
||||
const worksheet = workbook.getWorksheet(batch.sheetName);
|
||||
if (!worksheet) throw new BadRequestException('导入工作表不存在');
|
||||
const images = readEmbeddedImages(workbook, worksheet);
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
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'
|
||||
}
|
||||
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' } } },
|
||||
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,
|
||||
});
|
||||
});
|
||||
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({
|
||||
}
|
||||
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,
|
||||
reviewedById: pendingCount ? undefined : data.reviewerId,
|
||||
reviewedAt: pendingCount ? undefined : new Date(),
|
||||
completedAt: pendingCount ? undefined : new Date(),
|
||||
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' } } },
|
||||
});
|
||||
return { batchId, status, approvedCount, rejectedCount, failedCount: failures.length, failures };
|
||||
}
|
||||
});
|
||||
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 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 = mappedCoreValue(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,
|
||||
drainageInfo: { ...jsonRecord(existing?.drainageInfo), signatureReportValues },
|
||||
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' },
|
||||
},
|
||||
originalSnapshot: existing ? {
|
||||
id: existing.id,
|
||||
applicationId: existing.applicationId,
|
||||
name: existing.name,
|
||||
purpose: existing.purpose,
|
||||
drainageInfo: existing.drainageInfo,
|
||||
auditStatus: existing.auditStatus,
|
||||
updatedAt: existing.updatedAt,
|
||||
} : undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
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 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 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 body = {
|
||||
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 buildBody = (current?: { drainageInfo: Prisma.JsonValue | null }) => {
|
||||
const currentDrainage = jsonRecord(current?.drainageInfo);
|
||||
return {
|
||||
applicationId,
|
||||
name,
|
||||
purpose: typeof payload.purpose === 'string' ? payload.purpose : undefined,
|
||||
drainageInfo: jsonRecord(payload.drainageInfo),
|
||||
...(Object.prototype.hasOwnProperty.call(payload, 'purpose')
|
||||
? { purpose: String(payload.purpose ?? '') }
|
||||
: {}),
|
||||
drainageInfo: {
|
||||
...currentDrainage,
|
||||
signatureReportValues: {
|
||||
...jsonRecord(currentDrainage.signatureReportValues),
|
||||
...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, body, 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, body, batch.tenantId);
|
||||
} else {
|
||||
const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...body });
|
||||
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);
|
||||
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.smsDrainageInfo.findFirst({
|
||||
where: { signatureId, url, auditStatus: { not: 'deleted' } },
|
||||
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.updateDrainageInfo(targetId, body, { initialAuditStatus: 'pending' }, batch.tenantId);
|
||||
await this.smsConfig.updateSignature(targetId, buildBody(duplicate), batch.tenantId);
|
||||
} else {
|
||||
const created = await this.smsConfig.createDrainageInfo(signatureId, body, { initialAuditStatus: 'pending' }, batch.tenantId);
|
||||
const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...buildBody() });
|
||||
targetId = created.id;
|
||||
}
|
||||
}
|
||||
await this.smsConfig.approveDrainageInfo(targetId, { reviewerId, reason: `批量导入审核通过:${url}` });
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user