feat: add phone frequency controls and modularize codebase

This commit is contained in:
hectorzhao
2026-07-31 22:25:23 +08:00
parent 0af671b4ed
commit ca4f591a13
216 changed files with 41579 additions and 23694 deletions
@@ -0,0 +1,342 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { createHash, randomUUID } from 'node:crypto';
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 { 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) {}
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}` }, {
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 = 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 },
},
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 siteName = mappedCoreValue(mappings, values, 'siteName');
const url = mappedCoreValue(mappings, values, 'url');
if (!signatureName || !siteName || !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, 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 = {
applicationId,
name,
purpose: typeof payload.purpose === 'string' ? payload.purpose : undefined,
drainageInfo: jsonRecord(payload.drainageInfo),
};
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 siteName = String(payload.siteName ?? '');
const url = String(payload.url ?? '');
const body = {
siteName,
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: `批量导入审核通过:${siteName || url}` });
return targetId;
}
}