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,252 @@
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 { ReportBatchOperationService } from './batch-operation.service';
import { ReportChannelExportService } from './channel-export.service';
/** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportBatchGenerationService {
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService, private readonly operations: ReportBatchOperationService, private readonly channelExport: ReportChannelExportService) {}
async listBatches(query: PagedQuery = {}) {
const page = normalizePage(query.page);
const pageSize = normalizePageSize(query.pageSize);
const where: Prisma.ReportMaterialBatchWhereInput = {
createdAt: dateRange(query.startAt, query.endAt),
batchNo: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
};
const [batches, total] = await Promise.all([
this.prisma.reportMaterialBatch.findMany({
where,
include: {
exportFiles: {
include: {
items: { include: { task: { select: { id: true, status: true } } } },
},
},
items: true,
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.reportMaterialBatch.count({ where }),
]);
return {
items: batches.map((batch) => {
const reportItems = batch.exportFiles.flatMap((file) => file.items);
const reportTotal = reportItems.length;
const successCount = reportItems.filter((item) => item.task.status === 'approved').length;
return {
...batch,
reportTotal,
successCount,
successRate: reportTotal ? successCount / reportTotal : 0,
};
}),
total,
page,
pageSize,
};
}
async createBatch(data: CreateReportBatchDto) {
if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息');
const idempotencyKey = normalizeBatchIdempotencyKey(data.idempotencyKey);
const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()];
const fingerprint = createHash('sha256').update(JSON.stringify(uniqueItems.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? null, materialVersion: item.materialVersion ?? null })).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))))).digest('hex');
const claimed = await this.operations.claimBatchOperation(idempotencyKey, fingerprint, data.createdById);
if (claimed.replayed) return claimed.result;
let preflight: Awaited<ReturnType<ReportBatchGenerationService['preflightBatch']>>;
try {
preflight = await this.preflightBatch({ items: uniqueItems });
} catch (error) {
await this.operations.failBatchOperation(claimed.operationId, error instanceof Error ? error.message : '报备资格预检失败');
throw error;
}
if (preflight.eligibleTargetCount === 0) {
await this.operations.failBatchOperation(claimed.operationId, '没有可生成的报备目标');
throw new BadRequestException({ code: 'REPORT_BATCH_NOT_ELIGIBLE', message: '所选资料没有可生成的通道,请按资格检查补充后重试', preflight });
}
const eligibleInspections = preflight.items.filter((item) => item.targets.some((target) => target.eligible));
const batch = await this.prisma.reportMaterialBatch.create({
data: { batchNo: `RB${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}${randomUUID().slice(0, 4).toUpperCase()}`, createdById: data.createdById, selectedCount: eligibleInspections.length },
});
try {
const prepared = [];
for (const inspection of eligibleInspections) {
const selected = uniqueItems.find((item) => item.reportType === inspection.reportType && (item.drainageItemId ?? item.signatureId) === (inspection.drainageItemId ?? inspection.signatureId))!;
prepared.push(await this.prepareBatchItem(batch.id, selected, inspection));
}
const channelMap = new Map<string, Array<(typeof prepared)[number]>>();
for (const item of prepared) {
for (const channel of item.channels) {
const current = channelMap.get(channel.id) ?? [];
current.push({ ...item, channels: [channel] });
channelMap.set(channel.id, current);
}
}
const exportedFiles = [];
const incomplete = new Set<string>(prepared.filter((item) => item.channels.length === 0).map((item) => item.batchItem.id));
let failedTargetCount = 0;
for (const [channelId, items] of channelMap) {
const result = await this.channelExport.exportChannelBatch(batch.id, channelId, items);
exportedFiles.push(result.file);
failedTargetCount += result.incompleteBatchItemIds.length;
for (const itemId of result.incompleteBatchItemIds) incomplete.add(itemId);
}
for (const item of prepared) {
if (incomplete.has(item.batchItem.id) || item.channels.length === 0) continue;
if (item.reportType === 'signature') await this.prisma.smsSignature.update({ where: { id: item.signature.id }, data: { pendingReport: false } });
else await this.prisma.smsDrainageInfo.update({ where: { id: item.drainageInfo!.id }, data: { pendingReport: false } });
}
const completed = await this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: incomplete.size ? 'partial_failed' : 'completed', channelCount: channelMap.size, fileCount: exportedFiles.length, completedAt: new Date() }, include: { exportFiles: true, items: true } });
const result = {
...completed,
operationId: claimed.operationId,
replayed: false,
result: {
successCount: preflight.eligibleTargetCount - failedTargetCount,
skippedCount: preflight.skippedTargetCount,
failedCount: failedTargetCount,
items: preflight.items,
},
};
await this.operations.completeBatchOperation(claimed.operationId, batch.id, result);
return result;
} catch (error) {
await this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: 'failed', errorMessage: error instanceof Error ? error.message : '生成报备批次失败', completedAt: new Date() } });
await this.operations.failBatchOperation(claimed.operationId, error instanceof Error ? error.message : '生成报备批次失败', batch.id);
throw error;
}
}
async preflightBatch(data: Pick<CreateReportBatchDto, 'items'>) {
if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息');
for (const item of data.items) {
if (!['signature', 'drainage'].includes(item.reportType) || !item.signatureId) throw new BadRequestException({ code: 'REPORT_BATCH_ITEM_INVALID', message: '每条报备资料必须包含有效的资料类型和签名ID' });
if (item.reportType === 'drainage' && !item.drainageItemId) throw new BadRequestException({ code: 'REPORT_BATCH_ITEM_INVALID', message: '引流资料必须包含引流资料ID' });
}
const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()];
const items = await Promise.all(uniqueItems.map((item) => this.inspectBatchItem(item)));
return {
checkedAt: new Date().toISOString(),
eligible: items.some((item) => item.eligible),
eligibleItemCount: items.filter((item) => item.eligible).length,
blockedItemCount: items.filter((item) => !item.eligible).length,
eligibleTargetCount: items.reduce((sum, item) => sum + item.targets.filter((target) => target.eligible).length, 0),
skippedTargetCount: items.reduce((sum, item) => sum + item.targets.filter((target) => !target.eligible).length, 0),
items,
};
}
async prepareBatchItem(batchId: string, selected: CreateReportBatchDto['items'][number], inspection: ReportBatchInspection) {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } });
if (!signature || signature.auditStatus !== 'approved') throw new BadRequestException('签名不存在或未审核通过');
const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId
? await this.prisma.smsDrainageInfo.findUnique({ where: { id: selected.drainageItemId } }) : null;
if (selected.reportType === 'drainage' && (!drainageInfo || drainageInfo.signatureId !== signature.id || drainageInfo.auditStatus !== 'approved')) throw new BadRequestException('引流信息不存在或未审核通过');
const routes = signature.applicationId ? await this.prisma.channelRouteRule.findMany({
where: { applicationId: signature.applicationId, status: 'active' },
include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } },
orderBy: { priority: 'asc' },
}) : [];
const eligibleChannelIds = new Set(inspection.targets.filter((target) => target.eligible).map((target) => target.id));
const channels = [...new Map(routes.flatMap((route) => route.group.items.map((entry) => entry.channel)).filter((channel) => channel.status === 'active' && eligibleChannelIds.has(channel.id)).map((channel) => [channel.id, channel])).values()];
const snapshot = selected.reportType === 'signature'
? { reportType: 'signature', applicationId: signature.applicationId, businessKeys: inspection.targets.filter((target) => target.eligible).map((target) => target.businessKey), signature: { id: signature.id, name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) }
: { reportType: 'drainage', applicationId: signature.applicationId, businessKeys: inspection.targets.filter((target) => target.eligible).map((target) => target.businessKey), signature: { id: signature.id, name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name }, drainage: { id: drainageInfo!.id, siteName: drainageInfo!.siteName, url: drainageInfo!.url, remark: drainageInfo!.remark }, values: jsonRecord(drainageInfo!.reportValues) };
const materialVersion = selected.reportType === 'signature' ? signature.materialVersion : drainageInfo!.materialVersion;
const batchItem = await this.prisma.reportMaterialBatchItem.create({ data: { batchId, signatureId: signature.id, drainageItemId: drainageInfo?.id, reportType: selected.reportType, materialVersion, snapshot: snapshot as Prisma.InputJsonValue } });
return { batchItem, signature, drainageInfo, reportType: selected.reportType, snapshot, channels };
}
async inspectBatchItem(selected: CreateReportBatchDto['items'][number]): Promise<ReportBatchInspection> {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } });
if (!signature) throw new NotFoundException('签名不存在');
const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId
? await this.prisma.smsDrainageInfo.findUnique({ where: { id: selected.drainageItemId } }) : null;
const materialVersion = selected.reportType === 'signature' ? signature.materialVersion : drainageInfo?.materialVersion ?? 0;
const blockedReasons: string[] = [];
if (signature.auditStatus !== 'approved') blockedReasons.push('签名尚未审核通过');
if (!signature.pendingReport) blockedReasons.push('该签名版本已不在待报备池');
if (!signature.applicationId || !signature.application) blockedReasons.push('未绑定短信应用');
else if (signature.application.status !== 'active') blockedReasons.push('短信应用未启用');
if (selected.materialVersion !== undefined && selected.materialVersion !== materialVersion) blockedReasons.push(`资料版本已变化(当前 V${materialVersion}`);
if (selected.reportType === 'drainage') {
if (!drainageInfo || drainageInfo.signatureId !== signature.id) blockedReasons.push('引流资料不存在或不属于当前签名');
else {
if (drainageInfo.auditStatus !== 'approved') blockedReasons.push('引流资料尚未审核通过');
if (!drainageInfo.pendingReport) blockedReasons.push('该引流资料版本已不在待报备池');
}
}
const snapshot = selected.reportType === 'signature'
? { signature: { name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) }
: { signature: { name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name }, drainage: { siteName: drainageInfo?.siteName, url: drainageInfo?.url, remark: drainageInfo?.remark }, values: jsonRecord(drainageInfo?.reportValues) };
const routes = signature.applicationId ? await this.prisma.channelRouteRule.findMany({
where: { applicationId: signature.applicationId, status: 'active' },
include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } },
orderBy: { priority: 'asc' },
}) : [];
const channelCarriers = new Map<string, { channel: { id: string; name: string; status: string; carrier?: string | null }; carriers: Set<string> }>();
for (const route of routes) {
if (route.group.status !== 'active') continue;
for (const entry of route.group.items) {
if (entry.channel.status !== 'active') continue;
const current = channelCarriers.get(entry.channel.id) ?? { channel: entry.channel, carriers: new Set<string>() };
current.carriers.add(route.carrier || entry.carrier || entry.channel.carrier || 'all');
channelCarriers.set(entry.channel.id, current);
}
}
if (blockedReasons.length === 0 && channelCarriers.size === 0) blockedReasons.push('当前应用没有启用且可路由的通道');
const previous = await this.prisma.reportMaterialBatchItem.findMany({
where: { signatureId: signature.id, drainageItemId: selected.reportType === 'drainage' ? drainageInfo?.id : null, reportType: selected.reportType, materialVersion, batch: { status: { in: ['completed', 'partial_failed'] } } },
select: { batchId: true, snapshot: true, exportItems: { select: { exportFile: { select: { channelId: true } } } } },
orderBy: { createdAt: 'desc' },
});
const priorKeys = new Map<string, string>();
for (const item of previous) {
const exportedChannelIds = new Set(item.exportItems.map((entry) => entry.exportFile.channelId).filter((value): value is string => Boolean(value)));
for (const key of jsonStringArray(jsonRecord(item.snapshot).businessKeys)) {
if ([...exportedChannelIds].some((channelId) => key.includes(`:channel:${channelId}:`)) && !priorKeys.has(key)) priorKeys.set(key, item.batchId);
}
}
const targets: ReportBatchTarget[] = [];
for (const { channel, carriers } of channelCarriers.values()) {
const carrier = [...carriers].sort().join(',');
const businessKey = `${selected.reportType}:${selected.drainageItemId ?? signature.id}:v${materialVersion}:app:${signature.applicationId}:channel:${channel.id}:carrier:${carrier}`;
const targetReasons = [...blockedReasons];
const fields = await this.prisma.channelReportField.findMany({ where: { channelId: channel.id, status: 'active', reportType: { in: [selected.reportType, 'both'] } }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
if (fields.length === 0) targetReasons.push('通道未配置当前资料类型的报备字段');
else {
const missing = fields.filter((field) => field.required && !hasValue(resolveExportValue(snapshot, field.code, field.name) ?? field.defaultValue));
if (missing.length) targetReasons.push(`缺少必填字段:${missing.map((field) => field.exportName || field.name).join('、')}`);
}
const duplicateBatchId = priorKeys.get(businessKey);
if (duplicateBatchId) targetReasons.push(`同一资料版本已在批次 ${duplicateBatchId} 生成`);
targets.push({ id: channel.id, name: channel.name, carrier, businessKey, eligible: targetReasons.length === 0, blockedReasons: targetReasons, duplicateBatchId });
}
return {
id: `${selected.reportType}:${selected.drainageItemId ?? signature.id}`,
reportType: selected.reportType,
signatureId: signature.id,
drainageItemId: drainageInfo?.id,
materialVersion,
name: selected.reportType === 'signature' ? signature.name : drainageInfo?.siteName ?? '引流资料',
tenantName: signature.tenant.name,
applicationId: signature.applicationId ?? undefined,
applicationName: signature.application?.name ?? '未指定应用',
eligible: targets.some((target) => target.eligible),
blockedReasons: targets.length ? [...new Set(targets.flatMap((target) => target.blockedReasons))] : blockedReasons,
targets,
};
}
}