253 lines
19 KiB
TypeScript
253 lines
19 KiB
TypeScript
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,
|
||
};
|
||
}
|
||
}
|