feat: build reporting workbench workflow

This commit is contained in:
hectorzhao
2026-09-02 14:30:15 +08:00
parent 53d3668306
commit 9b8196ecab
31 changed files with 7651 additions and 1973 deletions
+488 -112
View File
@@ -1,13 +1,99 @@
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { assertMoneyUnits, moneyToNumber } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, normalizeChannelCarriers, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
import type {
CreateChannelDto,
UpdateChannelDto,
CreateChannelGroupDto,
CreateChannelGroupItemDto,
UpdateChannelGroupDto,
CreateRouteRuleDto,
CreateReportFieldDto,
ReplaceReportFieldsDto,
CreateReportMaterialDto,
CreateReportTaskDto,
ChangeReportTaskStatusesDto,
CreateReportExportDto,
CreateReceiptImportDto,
UpsertConnectionStateDto,
ChangeChannelStatusDto,
CopyChannelDto,
TestChannelDto,
} from './channels.contracts';
import {
GATEWAY_CONNECTION_QUEUE,
GATEWAY_SUBMIT_QUEUE,
GATEWAY_SUBMIT_STREAM,
DEFAULT_GATEWAY_CONTROL_URL,
DEFAULT_CHANNEL_CONNECTION_ID,
DEFAULT_CONNECTING_TIMEOUT_MS,
DEFAULT_CONNECTING_TIMEOUT_SCAN_MS,
DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS,
DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS,
DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS,
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
HEARTBEAT_AUDIT_INTERVAL_MS,
CONNECTING_TIMEOUT_ERROR,
DEFAULT_CMPP_VERSION,
normalizeTestPhones,
normalizeTestContent,
calculateBillingUnits,
buildChannelTestSubmitCommand,
getConfigValue,
getStringConfigValue,
normalizeConnectionAction,
normalizeCmppVersion,
normalizeGatewayConnectionStatus,
defaultChannelConnectionId,
getDesiredConnections,
ChannelConnectionSettings,
getRuntimeConfigInteger,
channelConnectionSettingsChanged,
channelGroupAuditSnapshot,
normalizeChannelRuntimeConfig,
normalizeCmppServiceId,
normalizeChannelRateLimit,
normalizeExtensionDigits,
getPositiveRuntimeInteger,
bullmqConnection,
getPositiveIntegerEnv,
parseReceiptContent,
splitReceiptLine,
stripReceiptCell,
findReceiptStatusIndex,
normalizeReceiptStatus,
deriveReceiptStatus,
ChannelReportDeliveryRow,
summarizeChannelReportDelivery,
sumReportDelivery,
percentage,
latestDate,
currentShanghaiDayRange,
normalizeRetryTimeLimitMinutes,
normalizeSpreadsheetSize,
normalizeBusinessCarrier,
normalizeChannelCarrier,
normalizeChannelCarriers,
isChannelCarrierCompatible,
normalizeRegion,
isRegionCompatible,
validateGroupItems,
normalizeReportType,
summarizeReportStatuses,
normalizeLinkEvent,
} from './channels.helpers';
/** R5 channel domain service composed behind ChannelsService. */
export class ChannelReportingService {
@@ -94,7 +180,11 @@ export class ChannelReportingService {
},
});
}
return tx.channelReportField.findMany({ where: { channelId, reportType }, include: { drainageField: true }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
return tx.channelReportField.findMany({
where: { channelId, reportType },
include: { drainageField: true },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
});
});
}
@@ -245,11 +335,12 @@ export class ChannelReportingService {
`);
return tasks.map((task) => {
const taskRows = rows.filter((row) => (
row.channelId === task.channelId
&& row.signatureId === task.signatureId
&& ((task.reportType ?? 'signature') === 'signature' || row.drainageInfoId === task.drainageItemId)
));
const taskRows = rows.filter(
(row) =>
row.channelId === task.channelId &&
row.signatureId === task.signatureId &&
((task.reportType ?? 'signature') === 'signature' || row.drainageInfoId === task.drainageItemId),
);
const deliveryStats = summarizeChannelReportDelivery(taskRows);
return {
...task,
@@ -261,10 +352,65 @@ export class ChannelReportingService {
async listReportTasksPage(query: {
tenantId?: string;
applicationId?: string;
status?: string;
channelId?: string;
reportType?: string;
keyword?: string;
carrier?: string;
todaySendMin?: number;
todaySendMax?: number;
sort?: string;
createdAtFrom?: string;
createdAtTo?: string;
page?: number;
pageSize?: number;
}) {
const page = Math.max(1, Math.floor(Number(query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
const keyword = query.keyword?.trim();
const from = query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined;
const to = query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined;
const all = await this.listReportTasks(query.tenantId, query.status, query.channelId, query.reportType);
const filtered = all.filter((task) => {
const createdAt = task.createdAt instanceof Date ? task.createdAt : new Date(task.createdAt);
const total = (task as typeof task & { deliveryStats?: { total: number } }).deliveryStats?.total ?? 0;
if (query.applicationId && task.signature.applicationId !== query.applicationId) return false;
if (query.carrier && task.carrier !== query.carrier) return false;
if (from && createdAt < from) return false;
if (to && createdAt > to) return false;
if (Number.isFinite(query.todaySendMin) && total < Number(query.todaySendMin)) return false;
if (Number.isFinite(query.todaySendMax) && total > Number(query.todaySendMax)) return false;
if (!keyword) return true;
return [
task.id,
task.channel.name,
task.signature.name,
task.signature.tenant.name,
task.signature.application?.name,
task.drainageInfo?.siteName,
task.drainageInfo?.url,
].some((value) => String(value ?? '').includes(keyword));
});
filtered.sort((left, right) =>
query.sort === 'todaySendDesc'
? ((right as typeof right & { deliveryStats?: { total: number } }).deliveryStats?.total ?? 0) -
((left as typeof left & { deliveryStats?: { total: number } }).deliveryStats?.total ?? 0) ||
right.updatedAt.getTime() - left.updatedAt.getTime()
: right.createdAt.getTime() - left.createdAt.getTime(),
);
return { items: filtered.slice((page - 1) * pageSize, page * pageSize), total: filtered.length, page, pageSize };
}
async listReportDetailsPage(query: {
tenantId?: string;
applicationId?: string;
signatureId?: string;
channelId?: string;
carrier?: string;
status?: string;
reportType?: string;
keyword?: string;
createdAtFrom?: string;
createdAtTo?: string;
page?: number;
@@ -272,48 +418,123 @@ export class ChannelReportingService {
}) {
const page = Math.max(1, Math.floor(Number(query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
const keyword = query.keyword?.trim();
const where: Prisma.ChannelSignatureReportTaskWhereInput = {
tenantId: query.tenantId,
status: query.status,
channelId: query.channelId,
reportType: query.reportType,
signature: { auditStatus: { not: 'deleted' } },
createdAt: query.createdAtFrom || query.createdAtTo ? {
gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined,
lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined,
} : undefined,
OR: keyword ? [
{ id: { contains: keyword } },
{ channel: { name: { contains: keyword } } },
{ signature: { name: { contains: keyword } } },
{ signature: { tenant: { name: { contains: keyword } } } },
{ signature: { application: { name: { contains: keyword } } } },
{ drainageInfo: { siteName: { contains: keyword } } },
{ drainageInfo: { url: { contains: keyword } } },
] : undefined,
};
const [items, total] = await Promise.all([
this.prisma.channelSignatureReportTask.findMany({
where,
include: {
signature: { include: { tenant: true, application: true } },
channel: true,
drainageInfo: true,
exportItems: {
include: { exportFile: true, batchItem: { include: { batch: true } } },
orderBy: { id: 'desc' },
take: 1,
const signatures = await this.prisma.smsSignature.findMany({
where: {
id: query.signatureId,
tenantId: query.tenantId,
applicationId: query.applicationId,
auditStatus: 'approved',
},
include: {
tenant: true,
application: true,
drainageItems: { where: { auditStatus: 'approved' } },
reportTasks: {
include: {
channel: true,
drainageInfo: true,
exportItems: {
include: { exportFile: true, batchItem: { include: { batch: true } } },
orderBy: { id: 'desc' },
take: 1,
},
records: { orderBy: { createdAt: 'desc' }, take: 20 },
},
records: { orderBy: { createdAt: 'desc' }, take: 20 },
},
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.channelSignatureReportTask.count({ where }),
]);
return { items, total, page, pageSize };
},
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
});
const applicationIds = [
...new Set(signatures.map((item) => item.applicationId).filter((id): id is string => Boolean(id))),
];
const routes = applicationIds.length
? await this.prisma.channelRouteRule.findMany({
where: { applicationId: { in: applicationIds }, status: 'active' },
include: { group: { include: { items: { include: { channel: true } } } } },
})
: [];
const details = signatures
.flatMap((signature) => {
const channels = [
...new Map(
routes
.filter((route) => route.applicationId === signature.applicationId && route.group.status === 'active')
.flatMap((route) => route.group.items.map((item) => item.channel))
.filter((channel) => channel.status === 'active')
.map((channel) => [channel.id, channel]),
).values(),
];
const signatureDetails = channels.flatMap((channel) =>
normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => {
const existing = signature.reportTasks.find(
(task) =>
task.reportType === 'signature' &&
task.channelId === channel.id &&
(task.carrier === carrier || (!task.carrier && task.approvalScope === 'legacy_channel')),
);
return existing
? { ...existing, signature }
: {
id: `virtual:${signature.id}:${channel.id}:${carrier}`,
tenantId: signature.tenantId,
signatureId: signature.id,
channelId: channel.id,
carrier,
approvalScope: 'carrier_specific',
reportType: 'signature',
drainageItemId: null,
status: 'pending',
reason: null,
approvedAt: null,
createdAt: signature.reportChangedAt ?? signature.updatedAt,
updatedAt: signature.reportChangedAt ?? signature.updatedAt,
createdById: null,
signature,
channel,
drainageInfo: null,
exportItems: [],
records: [],
virtual: true,
};
}),
);
const drainageDetails = signature.drainageItems.flatMap((drainageInfo) =>
channels
.map((channel) => {
const existing = signature.reportTasks.find(
(task) =>
task.reportType === 'drainage' &&
task.channelId === channel.id &&
task.drainageItemId === drainageInfo.id,
);
return existing ? { ...existing, signature } : undefined;
})
.filter(Boolean),
);
return [...signatureDetails, ...drainageDetails];
})
.filter((task) => {
if (!task) return false;
if (query.channelId && task.channelId !== query.channelId) return false;
if (query.carrier && task.carrier !== query.carrier) return false;
if (query.status && task.status !== query.status) return false;
if (query.reportType && task.reportType !== query.reportType) return false;
const changedAt = new Date(task.updatedAt);
if (query.createdAtFrom && changedAt < new Date(`${query.createdAtFrom}T00:00:00+08:00`)) return false;
if (query.createdAtTo && changedAt > new Date(`${query.createdAtTo}T23:59:59.999+08:00`)) return false;
if (!query.keyword?.trim()) return true;
const keyword = query.keyword.trim();
return [
task.id,
task.signature.name,
task.signature.tenant.name,
task.signature.application?.name,
task.channel.name,
task.drainageInfo?.siteName,
task.drainageInfo?.url,
].some((value) => String(value ?? '').includes(keyword));
});
return { items: details.slice((page - 1) * pageSize, page * pageSize), total: details.length, page, pageSize };
}
async createReportTask(data: CreateReportTaskDto) {
@@ -321,7 +542,8 @@ export class ChannelReportingService {
if (reportType === 'drainage' && !data.drainageItemId) throw new BadRequestException('drainageItemId is required');
if (reportType === 'drainage') {
const drainageInfo = await this.prisma.smsDrainageInfo.findUnique({ where: { id: data.drainageItemId! } });
if (!drainageInfo || drainageInfo.signatureId !== data.signatureId) throw new NotFoundException('Drainage info not found');
if (!drainageInfo || drainageInfo.signatureId !== data.signatureId)
throw new NotFoundException('Drainage info not found');
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
throw new BadRequestException('引流信息通道报备任务由运营审核通过后按应用路由自动生成');
}
@@ -333,7 +555,13 @@ export class ChannelReportingService {
throw new BadRequestException('报备运营商不在通道支持范围内');
}
const existing = await this.prisma.channelSignatureReportTask.findFirst({
where: { signatureId: data.signatureId, channelId: data.channelId, carrier, reportType: 'signature', drainageItemId: null },
where: {
signatureId: data.signatureId,
channelId: data.channelId,
carrier,
reportType: 'signature',
drainageItemId: null,
},
});
if (existing) throw new BadRequestException('该签名在当前通道和运营商下已存在报备任务');
const task = await this.prisma.channelSignatureReportTask.create({
@@ -355,7 +583,15 @@ export class ChannelReportingService {
async changeReportTaskStatuses(data: ChangeReportTaskStatusesDto) {
if (!data.items.length) throw new BadRequestException('items is required');
const allowed = new Set(['pending', 'waiting_material', 'reporting', 'approved', 'failed', 'rejected', 'abandoned']);
const allowed = new Set([
'pending',
'waiting_material',
'reporting',
'approved',
'failed',
'rejected',
'abandoned',
]);
for (const item of data.items) {
if (!allowed.has(item.status)) throw new BadRequestException('unsupported report task status');
}
@@ -364,43 +600,99 @@ export class ChannelReportingService {
throw new BadRequestException('unsupported report task source entry');
}
return this.prisma.$transaction(async (tx) => {
const signatureIds = [...new Set(data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId))];
const drainageResults: Array<{ signatureId: string; reportType: 'drainage'; drainageItemId: string; channelId: string; status: string }> = [];
const signatureIds = [
...new Set(
data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId),
),
];
const drainageResults: Array<{
signatureId: string;
reportType: 'drainage';
drainageItemId: string;
channelId: string;
status: string;
}> = [];
for (const item of data.items) {
const reportType = item.reportType ?? 'signature';
if (reportType === 'drainage' && !item.drainageItemId) throw new BadRequestException('drainageItemId is required');
if (reportType === 'drainage' && !item.drainageItemId)
throw new BadRequestException('drainageItemId is required');
const signature = await tx.smsSignature.findUnique({ where: { id: item.signatureId } });
const channel = await tx.smsChannel.findUnique({ where: { id: item.channelId } });
if (!signature || !channel) throw new NotFoundException('Signature or channel not found');
if (reportType === 'drainage') {
const drainageInfo = await tx.smsDrainageInfo.findUnique({ where: { id: item.drainageItemId! } });
if (!drainageInfo || drainageInfo.signatureId !== item.signatureId) throw new NotFoundException('Drainage info not found');
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能修改通道报备状态');
if (!drainageInfo || drainageInfo.signatureId !== item.signatureId)
throw new NotFoundException('Drainage info not found');
if (drainageInfo.auditStatus !== 'approved')
throw new BadRequestException('引流信息审核通过后才能修改通道报备状态');
}
const carrier = reportType === 'signature' && item.carrier ? normalizeBusinessCarrier(item.carrier) : null;
if (carrier && !normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)) {
throw new BadRequestException('报备运营商不在通道支持范围内');
}
const existing = await tx.channelSignatureReportTask.findFirst({ where: {
signatureId: item.signatureId,
channelId: item.channelId,
reportType,
drainageItemId: reportType === 'drainage' ? item.drainageItemId : null,
carrier: reportType === 'signature' ? carrier : null,
} });
if (reportType === 'drainage' && !existing) throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核');
if (reportType === 'signature' && !carrier && !existing) throw new BadRequestException('签名报备状态必须指定运营商');
const approvedAt = item.status === 'approved'
? existing?.status === 'approved' ? existing.approvedAt ?? new Date() : new Date()
: null;
const existing = await tx.channelSignatureReportTask.findFirst({
where: {
signatureId: item.signatureId,
channelId: item.channelId,
reportType,
drainageItemId: reportType === 'drainage' ? item.drainageItemId : null,
carrier: reportType === 'signature' ? carrier : null,
},
});
if (reportType === 'drainage' && !existing)
throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核');
if (reportType === 'signature' && !carrier && !existing)
throw new BadRequestException('签名报备状态必须指定运营商');
const approvedAt =
item.status === 'approved'
? existing?.status === 'approved'
? (existing.approvedAt ?? new Date())
: new Date()
: null;
const task = existing
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: item.status, reason: data.reason, ...(reportType === 'signature' ? { approvedAt } : {}) } })
: await tx.channelSignatureReportTask.create({ data: { tenantId: signature.tenantId, signatureId: item.signatureId, channelId: item.channelId, carrier, approvalScope: 'carrier_specific', approvedAt, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : undefined, status: item.status, reason: data.reason, createdById: data.operatorId } });
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: item.channelId, action: 'manual_status_change', statusBefore: existing?.status, statusAfter: item.status, reason: data.reason, operatorId: data.operatorId, sourceEntry } });
if (reportType === 'drainage') drainageResults.push({ signatureId: item.signatureId, reportType, drainageItemId: item.drainageItemId!, channelId: item.channelId, status: item.status });
? await tx.channelSignatureReportTask.update({
where: { id: existing.id },
data: { status: item.status, reason: data.reason, ...(reportType === 'signature' ? { approvedAt } : {}) },
})
: await tx.channelSignatureReportTask.create({
data: {
tenantId: signature.tenantId,
signatureId: item.signatureId,
channelId: item.channelId,
carrier,
approvalScope: 'carrier_specific',
approvedAt,
reportType,
drainageItemId: reportType === 'drainage' ? item.drainageItemId : undefined,
status: item.status,
reason: data.reason,
createdById: data.operatorId,
},
});
await tx.channelSignatureReportRecord.create({
data: {
taskId: task.id,
channelId: item.channelId,
action: 'manual_status_change',
statusBefore: existing?.status,
statusAfter: item.status,
reason: data.reason,
operatorId: data.operatorId,
sourceEntry,
},
});
if (reportType === 'drainage')
drainageResults.push({
signatureId: item.signatureId,
reportType,
drainageItemId: item.drainageItemId!,
channelId: item.channelId,
status: item.status,
});
}
const summaries = [];
for (const signatureId of signatureIds) summaries.push(await this.recomputeSignatureReportSummary(tx, signatureId));
for (const signatureId of signatureIds)
summaries.push(await this.recomputeSignatureReportSummary(tx, signatureId));
return [...summaries, ...drainageResults];
});
}
@@ -408,28 +700,55 @@ export class ChannelReportingService {
async recomputeSignatureReportSummary(tx: Prisma.TransactionClient, signatureId: string) {
const signature = await tx.smsSignature.findUnique({ where: { id: signatureId } });
if (!signature) throw new NotFoundException('Signature not found');
const routes = signature.applicationId ? await tx.channelRouteRule.findMany({
where: { applicationId: signature.applicationId, status: 'active' },
include: { group: { include: { items: { include: { channel: true } } } } },
}) : [];
const configuredChannels = routes.flatMap((route) => route.group.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted');
const tasks = await tx.channelSignatureReportTask.findMany({ where: { signatureId, reportType: 'signature' }, include: { channel: true } });
const routes = signature.applicationId
? await tx.channelRouteRule.findMany({
where: { applicationId: signature.applicationId, status: 'active' },
include: { group: { include: { items: { include: { channel: true } } } } },
})
: [];
const configuredChannels = routes
.flatMap((route) => route.group.items.map((item) => item.channel))
.filter((channel) => channel.status !== 'deleted');
const tasks = await tx.channelSignatureReportTask.findMany({
where: { signatureId, reportType: 'signature' },
include: { channel: true },
});
const channels = configuredChannels.length ? configuredChannels : tasks.map((task) => task.channel);
const uniqueChannels = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
const carrierReportSummary = Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
const targets = uniqueChannels.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier));
const statuses = targets.map((channel) => {
const task = tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier)
?? tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === null && candidate.approvalScope === 'legacy_channel');
return task?.status ?? 'pending';
});
return [carrier, summarizeReportStatuses(statuses)];
}));
const carrierReportSummary = Object.fromEntries(
['mobile', 'unicom', 'telecom'].map((carrier) => {
const targets = uniqueChannels.filter((channel) =>
normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier),
);
const statuses = targets.map((channel) => {
const task =
tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier) ??
tasks.find(
(candidate) =>
candidate.channelId === channel.id &&
candidate.carrier === null &&
candidate.approvalScope === 'legacy_channel',
);
return task?.status ?? 'pending';
});
return [carrier, summarizeReportStatuses(statuses)];
}),
);
const allStatuses = ['mobile', 'unicom', 'telecom'].flatMap((carrier) => {
const targets = uniqueChannels.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier));
return targets.map((channel) => tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier)?.status
?? tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === null && candidate.approvalScope === 'legacy_channel')?.status
?? 'pending');
const targets = uniqueChannels.filter((channel) =>
normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier),
);
return targets.map(
(channel) =>
tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier)?.status ??
tasks.find(
(candidate) =>
candidate.channelId === channel.id &&
candidate.carrier === null &&
candidate.approvalScope === 'legacy_channel',
)?.status ??
'pending',
);
});
const reportStatus = summarizeReportStatuses(allStatuses).status;
await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } });
@@ -487,6 +806,11 @@ export class ChannelReportingService {
async listReportRecordsPage(query: {
taskId?: string;
channelId?: string;
batchNo?: string;
statusAfter?: string;
action?: string;
sourceEntry?: string;
operatorKeyword?: string;
keyword?: string;
reportType?: string;
createdAtFrom?: string;
@@ -497,23 +821,51 @@ export class ChannelReportingService {
const page = Math.max(1, Math.floor(Number(query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
const keyword = query.keyword?.trim();
const operatorKeyword = query.operatorKeyword?.trim();
const operatorIds = operatorKeyword
? (
await this.prisma.user.findMany({
where: {
OR: [{ username: { contains: operatorKeyword } }, { displayName: { contains: operatorKeyword } }],
},
select: { id: true },
})
).map((item) => item.id)
: undefined;
const where: Prisma.ChannelSignatureReportRecordWhereInput = {
taskId: query.taskId,
channelId: query.channelId,
task: query.reportType ? { reportType: query.reportType } : undefined,
createdAt: query.createdAtFrom || query.createdAtTo ? {
gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined,
lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined,
} : undefined,
OR: keyword ? [
{ taskId: { contains: keyword } },
{ action: { contains: keyword } },
{ reason: { contains: keyword } },
{ channel: { name: { contains: keyword } } },
{ task: { signature: { name: { contains: keyword } } } },
{ task: { drainageInfo: { siteName: { contains: keyword } } } },
{ task: { drainageInfo: { url: { contains: keyword } } } },
] : undefined,
statusAfter: query.statusAfter,
action: query.action,
sourceEntry: query.sourceEntry,
operatorId: operatorIds ? { in: operatorIds } : undefined,
task:
query.reportType || query.batchNo
? {
reportType: query.reportType,
exportItems: query.batchNo
? { some: { batchItem: { batch: { batchNo: { contains: query.batchNo.trim() } } } } }
: undefined,
}
: undefined,
createdAt:
query.createdAtFrom || query.createdAtTo
? {
gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined,
lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined,
}
: undefined,
OR: keyword
? [
{ taskId: { contains: keyword } },
{ action: { contains: keyword } },
{ reason: { contains: keyword } },
{ channel: { name: { contains: keyword } } },
{ task: { signature: { name: { contains: keyword } } } },
{ task: { drainageInfo: { siteName: { contains: keyword } } } },
{ task: { drainageInfo: { url: { contains: keyword } } } },
]
: undefined,
};
const [items, total] = await Promise.all([
this.prisma.channelSignatureReportRecord.findMany({
@@ -525,11 +877,30 @@ export class ChannelReportingService {
}),
this.prisma.channelSignatureReportRecord.count({ where }),
]);
return { items, total, page, pageSize };
const userIds = [...new Set(items.map((item) => item.operatorId).filter((id): id is string => Boolean(id)))];
const operators = userIds.length
? await this.prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, username: true, displayName: true },
})
: [];
const operatorMap = new Map(operators.map((item) => [item.id, item]));
return {
items: items.map((item) => ({
...item,
operator: item.operatorId ? operatorMap.get(item.operatorId) : undefined,
})),
total,
page,
pageSize,
};
}
async getReportTaskOrThrow(taskId: string) {
const task = await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId }, include: { drainageInfo: true } });
const task = await this.prisma.channelSignatureReportTask.findUnique({
where: { id: taskId },
include: { drainageInfo: true },
});
if (!task) {
throw new NotFoundException('Report task not found');
}
@@ -552,8 +923,13 @@ export class ChannelReportingService {
data: {
status: statusAfter,
reason,
...((await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId }, select: { reportType: true, status: true, approvedAt: true } }))?.reportType === 'signature'
? { approvedAt: statusAfter === 'approved' ? statusBefore === 'approved' ? undefined : new Date() : null }
...((
await this.prisma.channelSignatureReportTask.findUnique({
where: { id: taskId },
select: { reportType: true, status: true, approvedAt: true },
})
)?.reportType === 'signature'
? { approvedAt: statusAfter === 'approved' ? (statusBefore === 'approved' ? undefined : new Date()) : null }
: {}),
},
});
+135 -10
View File
@@ -27,10 +27,19 @@ import { ChannelsService } from './channels.service';
@ApiTags('channels')
@Controller('admin')
export class ChannelsController {
constructor(private readonly channels: ChannelsService, private readonly deletions: DeletionGovernanceService) {}
constructor(
private readonly channels: ChannelsService,
private readonly deletions: DeletionGovernanceService,
) {}
@Get('channels')
listChannels(@Query('keyword') keyword?: string, @Query('carrier') carrier?: string, @Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
listChannels(
@Query('keyword') keyword?: string,
@Query('carrier') carrier?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return page || pageSize
? this.channels.listChannelsPage({ keyword, carrier, status, page: Number(page), pageSize: Number(pageSize) })
: this.channels.listChannels();
@@ -68,7 +77,11 @@ export class ChannelsController {
@Delete('channels/:id')
@RequireRecentAuthentication()
deleteChannel(@Param('id') channelId: string, @Body() body: DeleteTargetDto, @CurrentSessionUserId() operatorId?: string) {
deleteChannel(
@Param('id') channelId: string,
@Body() body: DeleteTargetDto,
@CurrentSessionUserId() operatorId?: string,
) {
return this.deletions.delete('channel', channelId, { ...body, operatorId });
}
@@ -159,7 +172,11 @@ export class ChannelsController {
@Put('channels/:channelId/report-fields/:reportType')
@RequireRecentAuthentication()
replaceReportFields(@Param('channelId') channelId: string, @Param('reportType') reportType: 'signature' | 'drainage', @Body() body: ReplaceReportFieldsDto) {
replaceReportFields(
@Param('channelId') channelId: string,
@Param('reportType') reportType: 'signature' | 'drainage',
@Body() body: ReplaceReportFieldsDto,
) {
return this.channels.replaceReportFields(channelId, reportType, body);
}
@@ -174,12 +191,82 @@ export class ChannelsController {
}
@Get('report-tasks')
listReportTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('channelId') channelId?: string, @Query('reportType') reportType?: string, @Query('keyword') keyword?: string, @Query('createdAtFrom') createdAtFrom?: string, @Query('createdAtTo') createdAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return page || pageSize || keyword || createdAtFrom || createdAtTo
? this.channels.listReportTasksPage({ tenantId, status, channelId, reportType, keyword, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) })
listReportTasks(
@Query('tenantId') tenantId?: string,
@Query('applicationId') applicationId?: string,
@Query('status') status?: string,
@Query('channelId') channelId?: string,
@Query('reportType') reportType?: string,
@Query('keyword') keyword?: string,
@Query('carrier') carrier?: string,
@Query('todaySendMin') todaySendMin?: string,
@Query('todaySendMax') todaySendMax?: string,
@Query('sort') sort?: string,
@Query('createdAtFrom') createdAtFrom?: string,
@Query('createdAtTo') createdAtTo?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return page ||
pageSize ||
keyword ||
applicationId ||
carrier ||
todaySendMin ||
todaySendMax ||
sort ||
createdAtFrom ||
createdAtTo
? this.channels.listReportTasksPage({
tenantId,
applicationId,
status,
channelId,
reportType,
keyword,
carrier,
todaySendMin: Number(todaySendMin),
todaySendMax: Number(todaySendMax),
sort,
createdAtFrom,
createdAtTo,
page: Number(page),
pageSize: Number(pageSize),
})
: this.channels.listReportTasks(tenantId, status, channelId, reportType);
}
@Get('report-details')
listReportDetails(
@Query('tenantId') tenantId?: string,
@Query('applicationId') applicationId?: string,
@Query('signatureId') signatureId?: string,
@Query('channelId') channelId?: string,
@Query('carrier') carrier?: string,
@Query('status') status?: string,
@Query('reportType') reportType?: string,
@Query('keyword') keyword?: string,
@Query('createdAtFrom') createdAtFrom?: string,
@Query('createdAtTo') createdAtTo?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.channels.listReportDetailsPage({
tenantId,
applicationId,
signatureId,
channelId,
carrier,
status,
reportType,
keyword,
createdAtFrom,
createdAtTo,
page: Number(page),
pageSize: Number(pageSize),
});
}
@Post('report-tasks/generate')
createReportTask(@Body() body: CreateReportTaskDto) {
return this.channels.createReportTask(body);
@@ -202,9 +289,47 @@ export class ChannelsController {
}
@Get('report-records')
listReportRecords(@Query('taskId') taskId?: string, @Query('channelId') channelId?: string, @Query('keyword') keyword?: string, @Query('reportType') reportType?: string, @Query('createdAtFrom') createdAtFrom?: string, @Query('createdAtTo') createdAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return page || pageSize || keyword || reportType || createdAtFrom || createdAtTo
? this.channels.listReportRecordsPage({ taskId, channelId, keyword, reportType, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) })
listReportRecords(
@Query('taskId') taskId?: string,
@Query('channelId') channelId?: string,
@Query('batchNo') batchNo?: string,
@Query('statusAfter') statusAfter?: string,
@Query('action') action?: string,
@Query('sourceEntry') sourceEntry?: string,
@Query('operatorKeyword') operatorKeyword?: string,
@Query('keyword') keyword?: string,
@Query('reportType') reportType?: string,
@Query('createdAtFrom') createdAtFrom?: string,
@Query('createdAtTo') createdAtTo?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return page ||
pageSize ||
keyword ||
batchNo ||
statusAfter ||
action ||
sourceEntry ||
operatorKeyword ||
reportType ||
createdAtFrom ||
createdAtTo
? this.channels.listReportRecordsPage({
taskId,
channelId,
batchNo,
statusAfter,
action,
sourceEntry,
operatorKeyword,
keyword,
reportType,
createdAtFrom,
createdAtTo,
page: Number(page),
pageSize: Number(pageSize),
})
: this.channels.listReportRecords(taskId, channelId);
}
}
+54 -3
View File
@@ -1,6 +1,24 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
import type {
CreateChannelDto,
UpdateChannelDto,
CreateChannelGroupDto,
CreateChannelGroupItemDto,
UpdateChannelGroupDto,
CreateRouteRuleDto,
CreateReportFieldDto,
ReplaceReportFieldsDto,
CreateReportMaterialDto,
CreateReportTaskDto,
ChangeReportTaskStatusesDto,
CreateReportExportDto,
CreateReceiptImportDto,
UpsertConnectionStateDto,
ChangeChannelStatusDto,
CopyChannelDto,
TestChannelDto,
} from './channels.contracts';
import { ChannelConfigurationService } from './channel-configuration.service';
import { ChannelConnectionService } from './channel-connection.service';
import { ChannelCopyService } from './channel-copy.service';
@@ -42,7 +60,13 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
return this.configuration.listChannels();
}
async listChannelsPage(query: { keyword?: string; carrier?: string; status?: string; page?: number; pageSize?: number }) {
async listChannelsPage(query: {
keyword?: string;
carrier?: string;
status?: string;
page?: number;
pageSize?: number;
}) {
return this.configuration.listChannelsPage(query);
}
@@ -152,16 +176,38 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
async listReportTasksPage(query: {
tenantId?: string;
applicationId?: string;
status?: string;
channelId?: string;
reportType?: string;
keyword?: string;
carrier?: string;
todaySendMin?: number;
todaySendMax?: number;
sort?: string;
createdAtFrom?: string;
createdAtTo?: string;
page?: number;
pageSize?: number;
}) {
return this.reporting.listReportTasksPage(query);
}
async listReportDetailsPage(query: {
tenantId?: string;
applicationId?: string;
signatureId?: string;
channelId?: string;
carrier?: string;
status?: string;
reportType?: string;
keyword?: string;
createdAtFrom?: string;
createdAtTo?: string;
page?: number;
pageSize?: number;
}) {
return this.reporting.listReportTasksPage(query);
return this.reporting.listReportDetailsPage(query);
}
async createReportTask(data: CreateReportTaskDto) {
@@ -187,6 +233,11 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
async listReportRecordsPage(query: {
taskId?: string;
channelId?: string;
batchNo?: string;
statusAfter?: string;
action?: string;
sourceEntry?: string;
operatorKeyword?: string;
keyword?: string;
reportType?: string;
createdAtFrom?: string;
@@ -6,247 +6,662 @@ 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,
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';
import { normalizeChannelCarriers } from '../channels/channels.helpers';
/** 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) {}
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 } } } },
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 getBatch(batchId: string) {
const batch = await this.prisma.reportMaterialBatch.findUnique({
where: { id: batchId },
include: { exportFiles: true, items: true },
});
if (!batch) throw new NotFoundException('报备批次不存在');
const tasks = await this.collectBatchTasks(batchId);
const successCount = tasks.filter((task) => task.status === 'approved').length;
return {
...batch,
reportTotal: tasks.length,
successCount,
successRate: tasks.length ? successCount / tasks.length : 0,
};
}
async listBatchTasks(
batchId: string,
query: PagedQuery & { reportType?: string; status?: string; channelId?: string } = {},
) {
const page = normalizePage(query.page);
const pageSize = normalizePageSize(query.pageSize);
const keyword = query.keyword?.trim();
const tasks = (await this.collectBatchTasks(batchId)).filter((task) => {
if (query.reportType && task.reportType !== query.reportType) return false;
if (query.status && task.status !== query.status) return false;
if (query.channelId && task.channelId !== query.channelId) return false;
if (!keyword) return true;
return [
task.id,
task.signature?.name,
task.signature?.tenant?.name,
task.signature?.application?.name,
task.drainageInfo?.url,
task.channel?.name,
].some((value) => String(value ?? '').includes(keyword));
});
return { items: tasks.slice((page - 1) * pageSize, page * pageSize), total: tasks.length, page, pageSize };
}
private async collectBatchTasks(batchId: string) {
const batch = await this.prisma.reportMaterialBatch.findUnique({
where: { id: batchId },
include: {
items: true,
exportFiles: { include: { items: true } },
},
});
if (!batch) throw new NotFoundException('报备批次不存在');
const batchItemById = new Map(batch.items.map((item) => [item.id, item]));
const scopes = batch.exportFiles
.flatMap((file) =>
file.items.map((entry) => {
const batchItem = batchItemById.get(entry.batchItemId);
return batchItem && file.channelId ? { batchItem, file, rowNumber: entry.rowNumber } : null;
}),
)
.filter((scope): scope is NonNullable<typeof scope> => Boolean(scope));
if (!scopes.length) return [];
const signatureIds = [...new Set(scopes.map((scope) => scope.batchItem.signatureId))];
const channelIds = [...new Set(scopes.map((scope) => scope.file.channelId!))];
const tasks = await this.prisma.channelSignatureReportTask.findMany({
where: { signatureId: { in: signatureIds }, channelId: { in: channelIds } },
include: {
signature: { include: { tenant: true, application: true } },
channel: true,
drainageInfo: true,
records: { orderBy: { createdAt: 'desc' }, take: 20 },
},
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
});
const result = [];
const seen = new Set<string>();
for (const scope of scopes) {
const snapshot = jsonRecord(scope.batchItem.snapshot);
const businessKeys = jsonStringArray(snapshot.businessKeys).filter((key) =>
key.includes(`:channel:${scope.file.channelId}:`),
);
const carriers = new Set(
businessKeys.flatMap(
(key) =>
key
.match(/:carrier:([^:]+)$/)?.[1]
.split(',')
.map((carrier) => carrier.trim()) ?? [],
),
);
for (const task of tasks) {
if (
task.signatureId !== scope.batchItem.signatureId ||
task.channelId !== scope.file.channelId ||
task.reportType !== scope.batchItem.reportType
)
continue;
if (scope.batchItem.reportType === 'drainage' && task.drainageItemId !== scope.batchItem.drainageItemId)
continue;
if (scope.batchItem.reportType === 'signature' && carriers.size && task.carrier && !carriers.has(task.carrier))
continue;
const key = `${scope.batchItem.id}:${task.id}`;
if (seen.has(key)) continue;
seen.add(key);
result.push({
...task,
exportItems: [
{
id: `${scope.file.id}:${scope.batchItem.id}:${task.id}`,
rowNumber: scope.rowNumber,
exportFile: {
id: scope.file.id,
fileObjectId: scope.file.fileObjectId,
fileName: scope.file.fileName,
rowCount: scope.file.rowCount,
batchId,
},
batchItem: {
id: scope.batchItem.id,
materialVersion: scope.batchItem.materialVersion,
batch: { id: batch.id, batchNo: batch.batchNo, createdAt: batch.createdAt },
},
},
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,
};
],
});
}
}
return result;
}
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;
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;
}
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,
};
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,
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.channelId),
);
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,
name: selected.reportType === 'signature' ? signature.name : drainageInfo?.url ?? '引流资料',
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,
};
snapshot: snapshot as Prisma.InputJsonValue,
},
});
return {
batchItem,
signature,
drainageInfo,
reportType: selected.reportType,
snapshot,
channels,
eligibleTargets: inspection.targets.filter((target) => target.eligible),
};
}
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: string[] };
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>(),
};
const routeCarrier = route.carrier || entry.carrier;
const supported = normalizeChannelCarriers(entry.channel.carriers, entry.channel.carrier);
if (routeCarrier && ['mobile', 'unicom', 'telecom'].includes(routeCarrier)) {
if (supported.includes(routeCarrier as 'mobile' | 'unicom' | 'telecom')) current.carriers.add(routeCarrier);
} else {
for (const carrier of supported) current.carriers.add(carrier);
}
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}:`))) continue;
const match = key.match(/^(.*:carrier:)([^:]+)$/);
const expandedKeys = match ? match[2].split(',').map((carrier) => `${match[1]}${carrier.trim()}`) : [key];
for (const expandedKey of expandedKeys)
if (expandedKey && !priorKeys.has(expandedKey)) priorKeys.set(expandedKey, item.batchId);
}
}
const currentTasks = await this.prisma.channelSignatureReportTask.findMany({
where: {
signatureId: signature.id,
reportType: selected.reportType,
drainageItemId: selected.reportType === 'drainage' ? drainageInfo?.id : null,
},
select: { channelId: true, carrier: true, status: true },
});
const targets: ReportBatchTarget[] = [];
for (const { channel, carriers } of channelCarriers.values()) {
const fields = await this.prisma.channelReportField.findMany({
where: { channelId: channel.id, status: 'active', reportType: { in: [selected.reportType, 'both'] } },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
});
const targetCarriers = selected.reportType === 'signature' ? [...carriers].sort() : ['all'];
for (const carrier of targetCarriers) {
const businessKey = `${selected.reportType}:${selected.drainageItemId ?? signature.id}:v${materialVersion}:app:${signature.applicationId}:channel:${channel.id}:carrier:${carrier}`;
const targetReasons = [...blockedReasons];
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 existingTask = currentTasks.find(
(task) => task.channelId === channel.id && (selected.reportType === 'drainage' || task.carrier === carrier),
);
if (existingTask?.status === 'abandoned') targetReasons.push('该通道报备明细已放弃报备');
const duplicateBatchId = priorKeys.get(businessKey);
if (duplicateBatchId) targetReasons.push(`同一资料版本已在批次 ${duplicateBatchId} 生成`);
targets.push({
id: `${channel.id}:${carrier}`,
channelId: 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?.url ?? '引流资料'),
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,
};
}
}
@@ -6,83 +6,411 @@ 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,
SingleReportMaterialDto,
} 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 { ReportBatchGenerationService } from './batch-generation.service';
import { normalizeChannelCarriers } from '../channels/channels.helpers';
/** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportChannelExportService {
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {}
constructor(
private readonly prisma: PrismaService,
private readonly files: FilesService,
private readonly smsConfig: SmsConfigService,
) {}
async exportChannelBatch(batchId: string, channelId: string, items: Array<Awaited<ReturnType<ReportBatchGenerationService['prepareBatchItem']>>>) {
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
if (!channel) throw new NotFoundException('通道不存在');
const reportTypes = [...new Set(items.map((item) => item.reportType))];
const workbook = new ExcelJS.Workbook();
const fileRows: Array<{ item: (typeof items)[number]; taskId: string; rowNumber: number }> = [];
const incompleteBatchItemIds: string[] = [];
let totalRows = 0;
for (const reportType of reportTypes) {
const fields = await this.prisma.channelReportField.findMany({ where: { channelId, status: 'active', reportType: { in: [reportType, 'both'] } }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名报备' : '引流信息报备', { views: [{ state: 'frozen', ySplit: 1 }] });
sheet.properties.defaultRowHeight = 22;
sheet.columns = fields.map((field) => ({ header: field.exportName || field.name, key: field.code, width: field.columnWidth }));
styleHeader(sheet.getRow(1));
for (const item of items.filter((current) => current.reportType === reportType)) {
const values = fields.map((field) => resolveExportValue(item.snapshot, field.code, field.name) ?? field.defaultValue ?? '');
const missing = fields.filter((field, index) => field.required && !hasValue(values[index]));
const missingReason = fields.length === 0 ? '通道未配置当前资料类型的报备字段' : missing.length ? `缺少字段:${missing.map((field) => field.exportName || field.name).join('、')}` : null;
const reportCarriers = reportType === 'signature' ? normalizeChannelCarriers(channel.carriers, channel.carrier) : [null];
const tasks: Array<{ task: { id: string; reason: string | null }; existingTask: { status: string } | null }> = [];
for (const carrier of reportCarriers) {
const existingTask = await this.prisma.channelSignatureReportTask.findFirst({ where: { signatureId: item.signature.id, channelId, carrier, reportType, drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null } });
const task = existingTask
? await this.prisma.channelSignatureReportTask.update({ where: { id: existingTask.id }, data: { status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason, ...(reportType === 'signature' ? { approvedAt: null } : {}) } })
: await this.prisma.channelSignatureReportTask.create({ data: { tenantId: item.signature.tenantId, signatureId: item.signature.id, channelId, carrier, approvalScope: reportType === 'signature' ? 'carrier_specific' : 'legacy_channel', reportType, drainageItemId: item.drainageInfo?.id, status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } });
tasks.push({ task, existingTask });
}
const task = tasks[0].task;
if (missingReason) {
incompleteBatchItemIds.push(item.batchItem.id);
for (const entry of tasks) await this.recordTask(entry.task.id, channelId, entry.existingTask?.status, 'waiting_material', task.reason ?? undefined);
continue;
}
const row = sheet.addRow(values.map((value, index) => isFileRef(value) ? value.fileName : applyExportTransform(value, fields[index]?.transform)));
totalRows += 1;
let targetHeight = 22;
for (const [index, value] of values.entries()) {
if (!isFileRef(value)) continue;
const downloaded = await this.files.getDownload(value.fileObjectId);
if (!downloaded.fileObject.contentType.startsWith('image/')) continue;
const extension = normalizeImageExtension(extname(downloaded.fileObject.fileName).slice(1) || downloaded.fileObject.contentType.split('/')[1]);
if (!['png', 'jpeg', 'gif'].includes(extension)) continue;
const imageId = workbook.addImage({ base64: `data:${downloaded.fileObject.contentType};base64,${downloaded.content.toString('base64')}`, extension: extension as 'png' | 'jpeg' | 'gif' });
const widthCells = Math.max(0.8, fields[index].imageWidth / Math.max(60, fields[index].columnWidth * 7));
const heightRows = Math.max(0.8, fields[index].imageHeight / 20);
sheet.addImage(imageId, { tl: { col: index + 0.08, row: row.number - 1 + 0.08 }, br: { col: index + Math.min(0.95, widthCells), row: row.number - 1 + Math.min(0.95, heightRows) }, editAs: 'oneCell' } as never);
targetHeight = Math.max(targetHeight, fields[index].imageHeight * 0.75 + 8);
}
row.height = targetHeight;
fileRows.push({ item, taskId: task.id, rowNumber: row.number });
for (const entry of tasks) await this.recordTask(entry.task.id, channelId, entry.existingTask?.status, 'exporting');
async exportChannelBatch(
batchId: string,
channelId: string,
items: Array<Awaited<ReturnType<ReportBatchGenerationService['prepareBatchItem']>>>,
) {
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
if (!channel) throw new NotFoundException('通道不存在');
const reportTypes = [...new Set(items.map((item) => item.reportType))];
const workbook = new ExcelJS.Workbook();
const fileRows: Array<{ item: (typeof items)[number]; taskId: string; rowNumber: number }> = [];
const incompleteBatchItemIds: string[] = [];
let totalRows = 0;
for (const reportType of reportTypes) {
const fields = await this.prisma.channelReportField.findMany({
where: { channelId, status: 'active', reportType: { in: [reportType, 'both'] } },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
});
const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名报备' : '引流信息报备', {
views: [{ state: 'frozen', ySplit: 1 }],
});
sheet.properties.defaultRowHeight = 22;
sheet.columns = fields.map((field) => ({
header: field.exportName || field.name,
key: field.code,
width: field.columnWidth,
}));
styleHeader(sheet.getRow(1));
for (const item of items.filter((current) => current.reportType === reportType)) {
const values = fields.map(
(field) => resolveExportValue(item.snapshot, field.code, field.name) ?? field.defaultValue ?? '',
);
const missing = fields.filter((field, index) => field.required && !hasValue(values[index]));
const missingReason =
fields.length === 0
? '通道未配置当前资料类型的报备字段'
: missing.length
? `缺少字段:${missing.map((field) => field.exportName || field.name).join('、')}`
: null;
const reportCarriers =
reportType === 'signature'
? item.eligibleTargets
.filter((target) => target.channelId === channelId)
.map((target) => target.carrier as 'mobile' | 'unicom' | 'telecom')
: [null];
const tasks: Array<{ task: { id: string; reason: string | null }; existingTask: { status: string } | null }> =
[];
for (const carrier of reportCarriers) {
const existingTask = await this.prisma.channelSignatureReportTask.findFirst({
where: {
signatureId: item.signature.id,
channelId,
carrier,
reportType,
drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null,
},
});
const task = existingTask
? await this.prisma.channelSignatureReportTask.update({
where: { id: existingTask.id },
data: {
status: missingReason ? 'waiting_material' : 'exporting',
reason: missingReason,
...(reportType === 'signature' ? { approvedAt: null } : {}),
},
})
: await this.prisma.channelSignatureReportTask.create({
data: {
tenantId: item.signature.tenantId,
signatureId: item.signature.id,
channelId,
carrier,
approvalScope: reportType === 'signature' ? 'carrier_specific' : 'legacy_channel',
reportType,
drainageItemId: item.drainageInfo?.id,
status: missingReason ? 'waiting_material' : 'exporting',
reason: missingReason,
},
});
tasks.push({ task, existingTask });
}
const task = tasks[0].task;
if (missingReason) {
incompleteBatchItemIds.push(item.batchItem.id);
for (const entry of tasks)
await this.recordTask(
entry.task.id,
channelId,
entry.existingTask?.status,
'waiting_material',
task.reason ?? undefined,
);
continue;
}
const row = sheet.addRow(
values.map((value, index) =>
isFileRef(value) ? value.fileName : applyExportTransform(value, fields[index]?.transform),
),
);
totalRows += 1;
let targetHeight = 22;
for (const [index, value] of values.entries()) {
if (!isFileRef(value)) continue;
const downloaded = await this.files.getDownload(value.fileObjectId);
if (!downloaded.fileObject.contentType.startsWith('image/')) continue;
const extension = normalizeImageExtension(
extname(downloaded.fileObject.fileName).slice(1) || downloaded.fileObject.contentType.split('/')[1],
);
if (!['png', 'jpeg', 'gif'].includes(extension)) continue;
const imageId = workbook.addImage({
base64: `data:${downloaded.fileObject.contentType};base64,${downloaded.content.toString('base64')}`,
extension: extension as 'png' | 'jpeg' | 'gif',
});
const widthCells = Math.max(0.8, fields[index].imageWidth / Math.max(60, fields[index].columnWidth * 7));
const heightRows = Math.max(0.8, fields[index].imageHeight / 20);
sheet.addImage(imageId, {
tl: { col: index + 0.08, row: row.number - 1 + 0.08 },
br: { col: index + Math.min(0.95, widthCells), row: row.number - 1 + Math.min(0.95, heightRows) },
editAs: 'oneCell',
} as never);
targetHeight = Math.max(targetHeight, fields[index].imageHeight * 0.75 + 8);
}
row.height = targetHeight;
fileRows.push({ item, taskId: task.id, rowNumber: row.number });
for (const entry of tasks)
await this.recordTask(entry.task.id, channelId, entry.existingTask?.status, 'exporting');
}
if (workbook.worksheets.every((sheet) => sheet.rowCount <= 1)) {
for (const sheet of [...workbook.worksheets]) workbook.removeWorksheet(sheet.id);
const empty = workbook.addWorksheet('无可导出数据');
empty.getCell('A1').value = '所选资料缺少当前通道必填字段,请补充后重新生成。';
empty.getColumn(1).width = 64;
}
const buffer = Buffer.from(await workbook.xlsx.writeBuffer());
const fileName = `${safeFileName(channel.name)}-${batchId.slice(-8)}.xlsx`;
const uploaded = await this.files.upload({ purpose: 'report_export', prefix: `report-exports/${batchId}` }, { originalname: fileName, mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: buffer.length, buffer });
const file = await this.prisma.reportExportFile.create({ data: { batchId, channelId, fileObjectId: uploaded.id, fileName, rowCount: totalRows } });
if (fileRows.length) await this.prisma.reportExportFileItem.createMany({ data: fileRows.map((entry) => ({ exportFileId: file.id, batchItemId: entry.item.batchItem.id, taskId: entry.taskId, rowNumber: entry.rowNumber })) });
return { file: { ...file, fileObject: uploaded }, incompleteBatchItemIds };
}
if (workbook.worksheets.every((sheet) => sheet.rowCount <= 1)) {
for (const sheet of [...workbook.worksheets]) workbook.removeWorksheet(sheet.id);
const empty = workbook.addWorksheet('无可导出数据');
empty.getCell('A1').value = '所选资料缺少当前通道必填字段,请补充后重新生成。';
empty.getColumn(1).width = 64;
}
const buffer = Buffer.from(await workbook.xlsx.writeBuffer());
const fileName = `${safeFileName(channel.name)}-${batchId.slice(-8)}.xlsx`;
const uploaded = await this.files.upload(
{ purpose: 'report_export', prefix: `report-exports/${batchId}` },
{
originalname: fileName,
mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
size: buffer.length,
buffer,
},
);
const file = await this.prisma.reportExportFile.create({
data: { batchId, channelId, fileObjectId: uploaded.id, fileName, rowCount: totalRows },
});
if (fileRows.length)
await this.prisma.reportExportFileItem.createMany({
data: fileRows.map((entry) => ({
exportFileId: file.id,
batchItemId: entry.item.batchItem.id,
taskId: entry.taskId,
rowNumber: entry.rowNumber,
})),
});
return { file: { ...file, fileObject: uploaded }, incompleteBatchItemIds };
}
recordTask(taskId: string, channelId: string, statusBefore: string | undefined, statusAfter: string, reason?: string) {
return this.prisma.channelSignatureReportRecord.create({ data: { taskId, channelId, action: 'batch_export', statusBefore, statusAfter, reason, sourceEntry: 'report_task' } });
async getSingleMaterialDetail(data: SingleReportMaterialDto) {
const reportType = data.reportType ?? 'signature';
if (!data.signatureId || !data.channelId) throw new BadRequestException('签名和通道不能为空');
if (reportType === 'drainage' && !data.drainageItemId) throw new BadRequestException('引流信息不能为空');
const [signature, channel] = await Promise.all([
this.prisma.smsSignature.findUnique({
where: { id: data.signatureId },
include: { tenant: true, application: true },
}),
this.prisma.smsChannel.findUnique({ where: { id: data.channelId } }),
]);
if (!signature || signature.auditStatus === 'deleted') throw new NotFoundException('签名不存在');
if (!channel || channel.status === 'deleted') throw new NotFoundException('通道不存在');
let materialVersion = signature.materialVersion;
let snapshot: Record<string, unknown>;
if (data.batchItemId) {
const batchItem = await this.prisma.reportMaterialBatchItem.findFirst({
where: {
id: data.batchItemId,
signatureId: signature.id,
exportItems: { some: { exportFile: { channelId: channel.id } } },
},
});
if (!batchItem) throw new NotFoundException('当前批次资料快照不存在');
if (batchItem.reportType !== reportType) throw new BadRequestException('批次资料类型不一致');
materialVersion = batchItem.materialVersion;
snapshot = jsonRecord(batchItem.snapshot);
} else if (reportType === 'signature') {
snapshot = {
reportType,
signature: {
id: signature.id,
name: signature.name,
purpose: signature.purpose,
tenantName: signature.tenant.name,
applicationName: signature.application?.name,
},
values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues),
};
} else {
const drainage = await this.prisma.smsDrainageInfo.findFirst({
where: { id: data.drainageItemId, signatureId: signature.id },
});
if (!drainage || drainage.auditStatus === 'deleted') throw new NotFoundException('引流信息不存在');
materialVersion = drainage.materialVersion;
snapshot = {
reportType,
signature: {
id: signature.id,
name: signature.name,
tenantName: signature.tenant.name,
applicationName: signature.application?.name,
},
drainage: { id: drainage.id, siteName: drainage.siteName, url: drainage.url, remark: drainage.remark },
values: jsonRecord(drainage.reportValues),
};
}
const fields = await this.prisma.channelReportField.findMany({
where: { channelId: channel.id, status: 'active', reportType: { in: [reportType, 'both'] } },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
});
const configuredCodes = new Set(fields.map((field) => field.code));
const values = jsonRecord(snapshot.values);
const materialFields = fields.map((field) => {
const submittedValue = resolveExportValue(snapshot, field.code, field.name);
const value = hasValue(submittedValue) ? submittedValue : field.defaultValue;
return {
id: field.id,
code: field.code,
name: field.name,
exportName: field.exportName,
fieldType: field.fieldType,
required: field.required,
columnWidth: field.columnWidth,
imageWidth: field.imageWidth,
imageHeight: field.imageHeight,
transform: field.transform,
value: value ?? null,
submitted: hasValue(submittedValue),
missing: field.required && !hasValue(value),
};
});
const historicalFields = Object.entries(values)
.filter(([code, value]) => !configuredCodes.has(code) && hasValue(value))
.sort(([left], [right]) => left.localeCompare(right, 'zh-CN'))
.map(([code, value]) => ({ code, name: code, value }));
return {
reportType,
signatureId: signature.id,
signatureName: signature.name,
tenant: { id: signature.tenant.id, name: signature.tenant.name },
application: signature.application ? { id: signature.application.id, name: signature.application.name } : null,
channel: { id: channel.id, name: channel.name, code: channel.code },
carrier: data.carrier ?? null,
materialVersion,
batchItemId: data.batchItemId ?? null,
fields: materialFields,
historicalFields,
missingFields: materialFields.filter((field) => field.missing).map((field) => field.exportName || field.name),
};
}
async exportSingleMaterial(data: SingleReportMaterialDto, operatorId?: string) {
if ((data.reportType ?? 'signature') !== 'signature')
throw new BadRequestException('首版仅支持单条签名报备资料导出');
const detail = await this.getSingleMaterialDetail(data);
if (detail.missingFields.length)
throw new BadRequestException({
code: 'REPORT_MATERIAL_INCOMPLETE',
message: `缺少必填字段:${detail.missingFields.join('、')}`,
missingFields: detail.missingFields,
});
const workbook = new ExcelJS.Workbook();
workbook.creator = 'CMPP短信平台';
const sheet = workbook.addWorksheet('签名报备', { views: [{ state: 'frozen', ySplit: 1 }] });
sheet.columns = detail.fields.map((field) => ({
header: field.exportName || field.name,
key: field.code,
width: field.columnWidth,
}));
styleHeader(sheet.getRow(1));
const row = sheet.addRow(
detail.fields.map((field) =>
isFileRef(field.value) ? field.value.fileName : applyExportTransform(field.value, field.transform),
),
);
let targetHeight = 22;
for (const [index, field] of detail.fields.entries()) {
if (!isFileRef(field.value)) continue;
const downloaded = await this.files.getDownload(field.value.fileObjectId);
if (!downloaded.fileObject.contentType.startsWith('image/')) continue;
const extension = normalizeImageExtension(
extname(downloaded.fileObject.fileName).slice(1) || downloaded.fileObject.contentType.split('/')[1],
);
if (!['png', 'jpeg', 'gif'].includes(extension)) continue;
const imageId = workbook.addImage({
base64: `data:${downloaded.fileObject.contentType};base64,${downloaded.content.toString('base64')}`,
extension: extension as 'png' | 'jpeg' | 'gif',
});
const widthCells = Math.max(0.8, field.imageWidth / Math.max(60, field.columnWidth * 7));
const heightRows = Math.max(0.8, field.imageHeight / 20);
sheet.addImage(imageId, {
tl: { col: index + 0.08, row: row.number - 1 + 0.08 },
br: { col: index + Math.min(0.95, widthCells), row: row.number - 1 + Math.min(0.95, heightRows) },
editAs: 'oneCell',
} as never);
targetHeight = Math.max(targetHeight, field.imageHeight * 0.75 + 8);
}
row.height = targetHeight;
const fileName = `${safeFileName(detail.channel.name)}-${safeFileName(detail.signatureName)}-V${detail.materialVersion}.xlsx`;
const content = Buffer.from(await workbook.xlsx.writeBuffer());
await this.prisma.operationLog.create({
data: {
tenantId: detail.tenant.id,
userId: operatorId,
action: 'report_material.single_export',
resource: 'report_material',
resourceId: detail.signatureId,
detail: {
fileName,
channelId: detail.channel.id,
carrier: detail.carrier,
materialVersion: detail.materialVersion,
batchItemId: detail.batchItemId,
successCount: 1,
failedCount: 0,
} as Prisma.InputJsonValue,
},
});
return { fileName, content };
}
recordTask(
taskId: string,
channelId: string,
statusBefore: string | undefined,
statusAfter: string,
reason?: string,
) {
return this.prisma.channelSignatureReportRecord.create({
data: {
taskId,
channelId,
action: 'batch_export',
statusBefore,
statusAfter,
reason,
sourceEntry: 'report_task',
},
});
}
}
+452 -289
View File
@@ -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;
}
}
+189 -59
View File
@@ -6,68 +6,198 @@ 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,
dynamicValues,
jsonRecord,
hasValue,
isFileRef,
resolveExportValue,
applyExportTransform,
styleHeader,
normalizeImageExtension,
imageContentType,
safeFileName,
normalizeBatchIdempotencyKey,
jsonStringArray,
jsonSafe,
} from './report-materials.helpers';
import { normalizeChannelCarriers } from '../channels/channels.helpers';
/** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportPendingQueryService {
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {}
constructor(
private readonly prisma: PrismaService,
private readonly files: FilesService,
private readonly smsConfig: SmsConfigService,
) {}
async listPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery) {
const items = await this.findPendingItems(query);
const page = normalizePage(query.page);
const pageSize = normalizePageSize(query.pageSize);
return {
items: items.slice((page - 1) * pageSize, page * pageSize),
total: items.length,
page,
pageSize,
};
}
async listPending(
query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery,
) {
const items = await this.findPendingItems(query);
const page = normalizePage(query.page);
const pageSize = normalizePageSize(query.pageSize);
return {
items: items.slice((page - 1) * pageSize, page * pageSize),
total: items.length,
page,
pageSize,
};
}
async findPendingItems(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery) {
const changedAt = dateRange(query.startAt, query.endAt);
const keyword = query.keyword?.trim();
const [signatures, drainageInfos] = await Promise.all([
query.reportType === 'drainage' ? Promise.resolve([]) : this.prisma.smsSignature.findMany({
where: {
pendingReport: true,
auditStatus: 'approved',
tenantId: query.tenantId,
applicationId: query.applicationId,
reportChangedAt: changedAt,
OR: keyword ? [
{ name: { contains: keyword } },
{ tenant: { name: { contains: keyword } } },
{ application: { name: { contains: keyword } } },
] : undefined,
},
include: { tenant: true, application: true },
orderBy: { reportChangedAt: 'desc' },
}),
query.reportType === 'signature' ? Promise.resolve([]) : this.prisma.smsDrainageInfo.findMany({
where: {
pendingReport: true,
auditStatus: 'approved',
tenantId: query.tenantId,
applicationId: query.applicationId,
reportChangedAt: changedAt,
OR: keyword ? [
{ siteName: { contains: keyword } },
{ url: { contains: keyword } },
{ signature: { name: { contains: keyword } } },
{ tenant: { name: { contains: keyword } } },
{ application: { name: { contains: keyword } } },
] : undefined,
},
include: { tenant: true, application: true, signature: true },
orderBy: { reportChangedAt: 'desc' },
}),
]);
return [
...signatures.map((item) => ({ id: `signature:${item.id}`, reportType: 'signature', signatureId: item.id, drainageItemId: null, materialVersion: item.materialVersion, changedAt: item.reportChangedAt, name: item.name, detail: item.purpose, tenant: item.tenant, application: item.application })),
...drainageInfos.map((item) => ({ id: `drainage:${item.id}`, reportType: 'drainage', signatureId: item.signatureId, drainageItemId: item.id, materialVersion: item.materialVersion, changedAt: item.reportChangedAt, name: item.url, detail: item.remark, signatureName: item.signature.name, tenant: item.tenant, application: item.application })),
].sort((left, right) => new Date(right.changedAt).getTime() - new Date(left.changedAt).getTime());
}
async findPendingItems(
query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery,
) {
const changedAt = dateRange(query.startAt, query.endAt);
const keyword = query.keyword?.trim();
const [signatures, drainageInfos] = await Promise.all([
query.reportType === 'drainage'
? Promise.resolve([])
: this.prisma.smsSignature.findMany({
where: {
pendingReport: true,
auditStatus: 'approved',
tenantId: query.tenantId,
applicationId: query.applicationId,
reportChangedAt: changedAt,
OR: keyword
? [
{ name: { contains: keyword } },
{ tenant: { name: { contains: keyword } } },
{ application: { name: { contains: keyword } } },
]
: undefined,
},
include: { tenant: true, application: true, reportTasks: { where: { reportType: 'signature' } } },
orderBy: { reportChangedAt: 'desc' },
}),
query.reportType === 'signature'
? Promise.resolve([])
: this.prisma.smsDrainageInfo.findMany({
where: {
pendingReport: true,
auditStatus: 'approved',
tenantId: query.tenantId,
applicationId: query.applicationId,
reportChangedAt: changedAt,
OR: keyword
? [
{ siteName: { contains: keyword } },
{ url: { contains: keyword } },
{ signature: { name: { contains: keyword } } },
{ tenant: { name: { contains: keyword } } },
{ application: { name: { contains: keyword } } },
]
: undefined,
},
include: { tenant: true, application: true, signature: true, reportTasks: true },
orderBy: { reportChangedAt: 'desc' },
}),
]);
const applicationIds = [
...new Set(
[...signatures, ...drainageInfos].map((item) => item.applicationId).filter((id): id is string => Boolean(id)),
),
];
const routes = applicationIds.length
? await this.prisma.channelRouteRule.findMany({
where: { applicationId: { in: applicationIds }, status: 'active' },
include: { group: { include: { items: { include: { channel: true } } } } },
})
: [];
const channelsFor = (applicationId?: string | null) => [
...new Map(
routes
.filter((route) => route.applicationId === applicationId && route.group.status === 'active')
.flatMap((route) => route.group.items.map((entry) => entry.channel))
.filter((channel) => channel.status === 'active')
.map((channel) => [channel.id, channel]),
).values(),
];
const summarize = (statuses: string[]) => ({
total: statuses.length,
pending: statuses.filter((status) => status === 'pending').length,
reporting: statuses.filter((status) => ['reporting', 'exporting'].includes(status)).length,
approved: statuses.filter((status) => status === 'approved').length,
failed: statuses.filter((status) => ['failed', 'rejected'].includes(status)).length,
abandoned: statuses.filter((status) => status === 'abandoned').length,
});
return [
...signatures.map((item) => {
const statuses = channelsFor(item.applicationId).flatMap((channel) =>
normalizeChannelCarriers(channel.carriers, channel.carrier).map(
(carrier) =>
item.reportTasks.find(
(task) =>
task.channelId === channel.id &&
(task.carrier === carrier || (!task.carrier && task.approvalScope === 'legacy_channel')),
)?.status ?? 'pending',
),
);
return {
id: `signature:${item.id}`,
reportType: 'signature',
signatureId: item.id,
drainageItemId: null,
materialVersion: item.materialVersion,
changedAt: item.reportChangedAt,
name: item.name,
detail: item.purpose,
tenant: item.tenant,
application: item.application,
statusSummary: summarize(statuses),
};
}),
...drainageInfos.map((item) => {
const statuses = channelsFor(item.applicationId).map(
(channel) => item.reportTasks.find((task) => task.channelId === channel.id)?.status ?? 'pending',
);
return {
id: `drainage:${item.id}`,
reportType: 'drainage',
signatureId: item.signatureId,
drainageItemId: item.id,
materialVersion: item.materialVersion,
changedAt: item.reportChangedAt,
name: item.url,
detail: item.remark,
signatureName: item.signature.name,
tenant: item.tenant,
application: item.application,
statusSummary: summarize(statuses),
};
}),
].sort((left, right) => new Date(right.changedAt).getTime() - new Date(left.changedAt).getTime());
}
}
@@ -49,10 +49,33 @@ export type PagedQuery = {
export interface CreateReportBatchDto {
createdById?: string;
idempotencyKey?: string;
items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion?: number }>;
items: Array<{
reportType: 'signature' | 'drainage';
signatureId: string;
drainageItemId?: string;
materialVersion?: number;
}>;
}
export type ReportBatchTarget = { id: string; name: string; carrier: string; businessKey: string; eligible: boolean; blockedReasons: string[]; duplicateBatchId?: string };
export interface SingleReportMaterialDto {
signatureId: string;
channelId: string;
carrier?: 'mobile' | 'unicom' | 'telecom';
reportType?: 'signature' | 'drainage';
drainageItemId?: string;
batchItemId?: string;
}
export type ReportBatchTarget = {
id: string;
channelId: string;
name: string;
carrier: string;
businessKey: string;
eligible: boolean;
blockedReasons: string[];
duplicateBatchId?: string;
};
export type ReportBatchInspection = {
id: string;
@@ -1,10 +1,28 @@
import { BadRequestException, Body, Controller, Get, Param, Post, Put, Query, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
import {
BadRequestException,
Body,
Controller,
Get,
Param,
Post,
Put,
Query,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags } from '@nestjs/swagger';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { ReportMaterialsService } from './report-materials.service';
import { CreateImportProfileDto, CreateReportBatchDto, ImportCommitDto, ReviewImportItemsDto } from './report-materials.contracts';
import {
CreateImportProfileDto,
CreateReportBatchDto,
ImportCommitDto,
ReviewImportItemsDto,
SingleReportMaterialDto,
} from './report-materials.contracts';
type UploadedWorkbook = { originalname: string; mimetype: string; size: number; buffer: Buffer };
type DownloadResponse = { setHeader(name: string, value: string): void; send(content: Buffer): void };
@@ -25,17 +43,37 @@ export class ReportMaterialsController {
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.listPending({ reportType, tenantId, applicationId, keyword, startAt, endAt, page: Number(page), pageSize: Number(pageSize) });
return this.service.listPending({
reportType,
tenantId,
applicationId,
keyword,
startAt,
endAt,
page: Number(page),
pageSize: Number(pageSize),
});
}
@Get('templates/:reportType')
async downloadTemplate(@Param('reportType') reportType: 'signature' | 'drainage', @CurrentSessionUserId() operatorId: string | undefined, @Res() response: DownloadResponse) {
if (!['signature', 'drainage'].includes(reportType)) throw new BadRequestException('reportType must be signature or drainage');
async downloadTemplate(
@Param('reportType') reportType: 'signature' | 'drainage',
@CurrentSessionUserId() operatorId: string | undefined,
@Res() response: DownloadResponse,
) {
if (!['signature', 'drainage'].includes(reportType))
throw new BadRequestException('reportType must be signature or drainage');
this.sendWorkbook(response, await this.service.buildOfficialTemplate(reportType, operatorId));
}
@Get('pending/export')
async exportPending(@Query('reportType') reportType: 'signature' | 'drainage' | undefined, @Query('tenantId') tenantId: string | undefined, @Query('applicationId') applicationId: string | undefined, @CurrentSessionUserId() operatorId: string | undefined, @Res() response: DownloadResponse) {
async exportPending(
@Query('reportType') reportType: 'signature' | 'drainage' | undefined,
@Query('tenantId') tenantId: string | undefined,
@Query('applicationId') applicationId: string | undefined,
@CurrentSessionUserId() operatorId: string | undefined,
@Res() response: DownloadResponse,
) {
this.sendWorkbook(response, await this.service.exportPending({ reportType, tenantId, applicationId }, operatorId));
}
@@ -52,7 +90,11 @@ export class ReportMaterialsController {
@Post('imports/analyze')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 12, parts: 13 } }))
analyzeImport(@UploadedFile() file: UploadedWorkbook, @Body() body: Record<string, string>, @CurrentSessionUserId() operatorId?: string) {
analyzeImport(
@UploadedFile() file: UploadedWorkbook,
@Body() body: Record<string, string>,
@CurrentSessionUserId() operatorId?: string,
) {
if (!file) throw new BadRequestException('请选择 XLSX 文件');
return this.service.analyzeImport(file, {
tenantId: body.tenantId,
@@ -82,12 +124,24 @@ export class ReportMaterialsController {
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.listImportReviewBatches({ reportType, status, keyword, startAt, endAt, page: Number(page), pageSize: Number(pageSize) });
return this.service.listImportReviewBatches({
reportType,
status,
keyword,
startAt,
endAt,
page: Number(page),
pageSize: Number(pageSize),
});
}
@Post('imports/:id/review')
@RequireRecentAuthentication()
reviewImportItems(@Param('id') id: string, @Body() body: ReviewImportItemsDto, @CurrentSessionUserId() reviewerId?: string) {
reviewImportItems(
@Param('id') id: string,
@Body() body: ReviewImportItemsDto,
@CurrentSessionUserId() reviewerId?: string,
) {
return this.service.reviewImportItems(id, { ...body, reviewerId });
}
@@ -102,6 +156,31 @@ export class ReportMaterialsController {
return this.service.listBatches({ keyword, startAt, endAt, page: Number(page), pageSize: Number(pageSize) });
}
@Get('batches/:id')
getBatch(@Param('id') id: string) {
return this.service.getBatch(id);
}
@Get('batches/:id/tasks')
listBatchTasks(
@Param('id') id: string,
@Query('keyword') keyword?: string,
@Query('reportType') reportType?: string,
@Query('status') status?: string,
@Query('channelId') channelId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.listBatchTasks(id, {
keyword,
reportType,
status,
channelId,
page: Number(page),
pageSize: Number(pageSize),
});
}
@Post('batches/preflight')
preflightBatch(@Body() body: CreateReportBatchDto) {
return this.service.preflightBatch(body);
@@ -113,6 +192,20 @@ export class ReportMaterialsController {
return this.service.createBatch({ ...body, createdById: operatorId });
}
@Post('single-detail')
getSingleMaterialDetail(@Body() body: SingleReportMaterialDto) {
return this.service.getSingleMaterialDetail(body);
}
@Post('single-export')
@RequireRecentAuthentication()
async exportSingleMaterial(
@Body() body: SingleReportMaterialDto,
@CurrentSessionUserId() operatorId: string | undefined,
@Res() response: DownloadResponse,
) {
this.sendWorkbook(response, await this.service.exportSingleMaterial(body, operatorId));
}
private sendWorkbook(response: DownloadResponse, exported: { fileName: string; content: Buffer }) {
response.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
@@ -6,7 +6,16 @@ import type { CreateImportProfileDto, EmbeddedImage, ImportMapping } from './rep
/** Pure workbook, mapping, pagination and export helpers shared by R4 domains. */
export function profileData(data: CreateImportProfileDto) {
return { name: data.name.trim(), reportType: data.reportType, tenantId: data.tenantId, applicationId: data.applicationId, sheetName: data.sheetName, headerRowCount: clamp(data.headerRowCount ?? 1, 1, 5), dataStartRow: Math.max(data.dataStartRow ?? 2, 2), status: data.status ?? 'active' };
return {
name: data.name.trim(),
reportType: data.reportType,
tenantId: data.tenantId,
applicationId: data.applicationId,
sheetName: data.sheetName,
headerRowCount: clamp(data.headerRowCount ?? 1, 1, 5),
dataStartRow: Math.max(data.dataStartRow ?? 2, 2),
status: data.status ?? 'active',
};
}
export function validateProfile(data: CreateImportProfileDto) {
@@ -24,16 +33,18 @@ export async function loadWorkbook(buffer: Buffer) {
export function assertSafeWorkbook(workbook: ExcelJS.Workbook) {
for (const worksheet of workbook.worksheets) {
worksheet.eachRow((row) => row.eachCell((cell) => {
const value = cell.value;
if (value && typeof value === 'object' && ('formula' in value || 'sharedFormula' in value)) {
throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`);
}
const text = typeof value === 'string' ? value.trimStart() : '';
if (/^[=+@]/.test(text) || /^-[^\d.]/.test(text)) {
throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`);
}
}));
worksheet.eachRow((row) =>
row.eachCell((cell) => {
const value = cell.value;
if (value && typeof value === 'object' && ('formula' in value || 'sharedFormula' in value)) {
throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`);
}
const text = typeof value === 'string' ? value.trimStart() : '';
if (/^[=+@]/.test(text) || /^-[^\d.]/.test(text)) {
throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`);
}
}),
);
}
}
@@ -43,63 +54,130 @@ export function safeSpreadsheetText(value: unknown) {
}
export function readEmbeddedImages(workbook: ExcelJS.Workbook, worksheet: ExcelJS.Worksheet): EmbeddedImage[] {
const getImages = (worksheet as unknown as { getImages?: () => Array<{ imageId: number; range: { tl: { nativeRow?: number; nativeCol?: number; row?: number; col?: number } } }> }).getImages;
const getImages = (
worksheet as unknown as {
getImages?: () => Array<{
imageId: number;
range: { tl: { nativeRow?: number; nativeCol?: number; row?: number; col?: number } };
}>;
}
).getImages;
if (!getImages) return [];
return getImages.call(worksheet).flatMap((drawing) => {
const image = (workbook as unknown as { getImage?: (id: number) => { buffer?: Buffer; base64?: string; extension?: string } }).getImage?.(drawing.imageId);
const image = (
workbook as unknown as { getImage?: (id: number) => { buffer?: Buffer; base64?: string; extension?: string } }
).getImage?.(drawing.imageId);
if (!image) return [];
const row = (drawing.range.tl.nativeRow ?? drawing.range.tl.row ?? 0) + 1;
const column = (drawing.range.tl.nativeCol ?? drawing.range.tl.col ?? 0) + 1;
const buffer = image.buffer ?? (image.base64 ? Buffer.from(image.base64.replace(/^data:[^;]+;base64,/, ''), 'base64') : undefined);
const buffer =
image.buffer ??
(image.base64 ? Buffer.from(image.base64.replace(/^data:[^;]+;base64,/, ''), 'base64') : undefined);
return buffer ? [{ row, column, extension: image.extension ?? 'png', buffer }] : [];
});
}
export function suggestMappings(columns: Array<{ sourceColumnIndex: number; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>, reportType: 'signature' | 'drainage'): ImportMapping[] {
export function suggestMappings(
columns: Array<{ sourceColumnIndex: number; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>,
reportType: 'signature' | 'drainage',
): ImportMapping[] {
return columns.flatMap((column, index) => {
const normalized = normalizeHeader(`${column.sourceHeaderPath}/${column.sourceHeader}`);
const core = reportType === 'signature' ? signatureCoreMapping(normalized) : drainageCoreMapping(normalized);
if (!core && !column.imageCount) return [];
return [{ sourceHeader: column.sourceHeader, sourceHeaderPath: column.sourceHeaderPath, sourceColumnIndex: column.sourceColumnIndex, targetFieldCode: core?.code ?? normalizeFieldCode(column.sourceHeader), targetKind: core?.kind ?? 'dynamic', fieldType: column.imageCount ? 'image' : 'string', required: Boolean(core?.required), sortOrder: (index + 1) * 10 }];
return [
{
sourceHeader: column.sourceHeader,
sourceHeaderPath: column.sourceHeaderPath,
sourceColumnIndex: column.sourceColumnIndex,
targetFieldCode: core?.code ?? normalizeFieldCode(column.sourceHeader),
targetKind: core?.kind ?? 'dynamic',
fieldType: column.imageCount ? 'image' : 'string',
required: Boolean(core?.required),
sortOrder: (index + 1) * 10,
},
];
});
}
export function remapProfileColumns(profileColumns: ImportMapping[], sourceColumns: Array<{ sourceColumnIndex: number; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>): ImportMapping[] {
export function remapProfileColumns(
profileColumns: ImportMapping[],
sourceColumns: Array<{
sourceColumnIndex: number;
sourceHeader: string;
sourceHeaderPath: string;
imageCount: number;
}>,
): ImportMapping[] {
const used = new Set<number>();
return profileColumns.flatMap((profileColumn) => {
const headerPath = normalizeHeader(profileColumn.sourceHeaderPath || profileColumn.sourceHeader);
const header = normalizeHeader(profileColumn.sourceHeader);
const source = sourceColumns.find((column) => !used.has(column.sourceColumnIndex) && normalizeHeader(column.sourceHeaderPath) === headerPath)
?? sourceColumns.find((column) => !used.has(column.sourceColumnIndex) && normalizeHeader(column.sourceHeader) === header);
const source =
sourceColumns.find(
(column) => !used.has(column.sourceColumnIndex) && normalizeHeader(column.sourceHeaderPath) === headerPath,
) ??
sourceColumns.find(
(column) => !used.has(column.sourceColumnIndex) && normalizeHeader(column.sourceHeader) === header,
);
if (!source) return [];
used.add(source.sourceColumnIndex);
return [{ ...profileColumn, sourceColumnIndex: source.sourceColumnIndex, sourceHeader: source.sourceHeader, sourceHeaderPath: source.sourceHeaderPath, fieldType: source.imageCount > 0 && profileColumn.fieldType === 'string' ? 'image' : profileColumn.fieldType }];
return [
{
...profileColumn,
sourceColumnIndex: source.sourceColumnIndex,
sourceHeader: source.sourceHeader,
sourceHeaderPath: source.sourceHeaderPath,
fieldType: source.imageCount > 0 && profileColumn.fieldType === 'string' ? 'image' : profileColumn.fieldType,
},
];
});
}
export function signatureCoreMapping(header: string): { code: string; kind: ImportMapping['targetKind']; required?: boolean } | undefined {
export function signatureCoreMapping(
header: string,
): { code: string; kind: ImportMapping['targetKind']; required?: boolean } | undefined {
if (/短信签名|签名名称|签名/.test(header)) return { code: 'signature_name', kind: 'signatureName', required: true };
if (/用途|签名依据/.test(header)) return { code: 'purpose', kind: 'purpose' };
return undefined;
}
export function drainageCoreMapping(header: string): { code: string; kind: ImportMapping['targetKind']; required?: boolean } | undefined {
export function drainageCoreMapping(
header: string,
): { code: string; kind: ImportMapping['targetKind']; required?: boolean } | undefined {
if (/短信签名|签名名称/.test(header)) return { code: 'signature_name', kind: 'signatureName', required: true };
if (/引流.*(?:url|地址|号码)|网址|url|链接|手机号码|固定电话|电话号码/.test(header)) return { code: 'url', kind: 'url', required: true };
if (/引流.*(?:url|地址|号码)|网址|url|链接|手机号码|固定电话|电话号码/.test(header))
return { code: 'url', kind: 'url', required: true };
if (/站点|网站名称/.test(header)) return { code: 'site_name', kind: 'siteName' };
if (/备注|说明/.test(header)) return { code: 'remark', kind: 'remark' };
return undefined;
}
export function normalizeHeader(value: string) { return value.toLowerCase().replace(/[\s**::()()_-]/g, ''); }
export function normalizeHeader(value: string) {
return value.toLowerCase().replace(/[\s**::()()_-]/g, '');
}
export function normalizeFieldCode(value: string) { return `import_${value.trim().toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_').slice(0, 40) || randomUUID().slice(0, 8)}`; }
export function normalizeFieldCode(value: string) {
return `import_${
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_')
.slice(0, 40) || randomUUID().slice(0, 8)
}`;
}
export function clamp(value: number, minimum: number, maximum: number) { return Math.min(maximum, Math.max(minimum, Number.isFinite(value) ? Math.round(value) : minimum)); }
export function clamp(value: number, minimum: number, maximum: number) {
return Math.min(maximum, Math.max(minimum, Number.isFinite(value) ? Math.round(value) : minimum));
}
export function normalizePage(value?: number) { return Math.max(1, Math.floor(Number(value) || 1)); }
export function normalizePage(value?: number) {
return Math.max(1, Math.floor(Number(value) || 1));
}
export function normalizePageSize(value?: number) { return Math.min(100, Math.max(1, Math.floor(Number(value) || 20))); }
export function normalizePageSize(value?: number) {
return Math.min(100, Math.max(1, Math.floor(Number(value) || 20)));
}
export function dateRange(startAt?: string, endAt?: string) {
const start = startAt ? new Date(`${startAt}T00:00:00+08:00`) : undefined;
@@ -115,7 +193,11 @@ export function cellText(cell: ExcelJS.Cell) {
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : String(value);
if (typeof value === 'string' || typeof value === 'boolean') return String(value).trim();
if ('result' in value && value.result !== undefined) return String(value.result ?? '').trim();
if ('richText' in value) return value.richText.map((item) => item.text).join('').trim();
if ('richText' in value)
return value.richText
.map((item) => item.text)
.join('')
.trim();
if ('text' in value) return String(value.text).trim();
return cell.text.trim();
}
@@ -128,20 +210,51 @@ export function transformValue(value: string, transform?: string) {
return value.trim();
}
export function mappedCoreValue(mappings: ImportMapping[], values: Record<string, unknown>, kind: ImportMapping['targetKind']) {
export function mappedCoreValue(
mappings: ImportMapping[],
values: Record<string, unknown>,
kind: ImportMapping['targetKind'],
) {
const mapping = mappings.find((item) => item.targetKind === kind);
return mapping ? String(values[mapping.targetFieldCode] ?? '').trim() : '';
}
export function dynamicValues(mappings: ImportMapping[], values: Record<string, unknown>) {
return Object.fromEntries(mappings.filter((item) => item.targetKind === 'dynamic').map((item) => [item.targetFieldCode, values[item.targetFieldCode]]));
export function mappedCorePatchValue(
mappings: ImportMapping[],
values: Record<string, unknown>,
kind: ImportMapping['targetKind'],
) {
const mapping = mappings.find((item) => item.targetKind === kind);
if (!mapping || !hasValue(values[mapping.targetFieldCode])) return undefined;
return String(values[mapping.targetFieldCode]).trim();
}
export function jsonRecord(value: unknown): Record<string, unknown> { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
export function dynamicValues(mappings: ImportMapping[], values: Record<string, unknown>) {
return Object.fromEntries(
mappings
.filter((item) => item.targetKind === 'dynamic' && hasValue(values[item.targetFieldCode]))
.map((item) => [item.targetFieldCode, values[item.targetFieldCode]]),
);
}
export function hasValue(value: unknown) { return isFileRef(value) ? Boolean(value.fileObjectId) : value !== null && value !== undefined && String(value).trim().length > 0; }
export function jsonRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
export function isFileRef(value: unknown): value is { fileObjectId: string; fileName: string; contentType?: string } { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && typeof (value as Record<string, unknown>).fileObjectId === 'string'; }
export function hasValue(value: unknown) {
return isFileRef(value)
? Boolean(value.fileObjectId)
: value !== null && value !== undefined && String(value).trim().length > 0;
}
export function isFileRef(value: unknown): value is { fileObjectId: string; fileName: string; contentType?: string } {
return (
Boolean(value) &&
typeof value === 'object' &&
!Array.isArray(value) &&
typeof (value as Record<string, unknown>).fileObjectId === 'string'
);
}
export function resolveExportValue(snapshot: Record<string, unknown>, code: string, name?: string) {
const values = jsonRecord(snapshot.values);
@@ -149,9 +262,16 @@ export function resolveExportValue(snapshot: Record<string, unknown>, code: stri
const signature = jsonRecord(snapshot.signature);
const drainage = jsonRecord(snapshot.drainage);
const aliases: Record<string, unknown> = {
signature_name: signature.name, sign_name: signature.name, signatureName: signature.name,
purpose: signature.purpose, enterprise_name: signature.tenantName, company_name: signature.tenantName,
application_name: signature.applicationName, site_name: drainage.siteName, url: drainage.url, remark: drainage.remark,
signature_name: signature.name,
sign_name: signature.name,
signatureName: signature.name,
purpose: signature.purpose,
enterprise_name: signature.tenantName,
company_name: signature.tenantName,
application_name: signature.applicationName,
site_name: drainage.siteName,
url: drainage.url,
remark: drainage.remark,
};
if (hasValue(aliases[code])) return aliases[code];
const semantic = normalizeHeader(`${code}/${name ?? ''}`);
@@ -180,15 +300,24 @@ export function styleHeader(row: ExcelJS.Row) {
});
}
export function normalizeImageExtension(value: string) { const normalized = value.toLowerCase().replace(/^\./, ''); return normalized === 'jpg' ? 'jpeg' : normalized; }
export function normalizeImageExtension(value: string) {
const normalized = value.toLowerCase().replace(/^\./, '');
return normalized === 'jpg' ? 'jpeg' : normalized;
}
export function imageContentType(extension: string) { const normalized = normalizeImageExtension(extension); return normalized === 'jpeg' ? 'image/jpeg' : normalized === 'gif' ? 'image/gif' : 'image/png'; }
export function imageContentType(extension: string) {
const normalized = normalizeImageExtension(extension);
return normalized === 'jpeg' ? 'image/jpeg' : normalized === 'gif' ? 'image/gif' : 'image/png';
}
export function safeFileName(value: string) { return value.replace(/[\\/:*?"<>|]/g, '_').slice(0, 80) || '通道报备'; }
export function safeFileName(value: string) {
return value.replace(/[\\/:*?"<>|]/g, '_').slice(0, 80) || '通道报备';
}
export function normalizeBatchIdempotencyKey(value?: string) {
const key = value?.trim();
if (!key || key.length > 128 || !/^[A-Za-z0-9._:-]{8,128}$/.test(key)) throw new BadRequestException({ code: 'IDEMPOTENCY_KEY_INVALID', message: 'idempotencyKey 必填且长度为8至128位' });
if (!key || key.length > 128 || !/^[A-Za-z0-9._:-]{8,128}$/.test(key))
throw new BadRequestException({ code: 'IDEMPOTENCY_KEY_INVALID', message: 'idempotencyKey 必填且长度为8至128位' });
return key;
}
@@ -1,7 +1,26 @@
import ExcelJS from 'exceljs';
import { ReportMaterialsService } from './report-materials.service';
import { mappedCorePatchValue } from './report-materials.helpers';
describe('ReportMaterialsService', () => {
it('does not clear an existing core field when the import column is unmapped or blank', () => {
expect(mappedCorePatchValue([], {}, 'purpose')).toBeUndefined();
expect(
mappedCorePatchValue(
[
{
sourceHeader: '用途说明',
sourceColumnIndex: 2,
targetFieldCode: 'purpose',
targetKind: 'purpose',
fieldType: 'string',
},
],
{ purpose: '' },
'purpose',
),
).toBeUndefined();
});
it('builds an official XLSX import template with documented signature columns', async () => {
const operationLog = { create: jest.fn().mockResolvedValue({ id: 'log-template' }) };
const service = new ReportMaterialsService({ operationLog } as never, {} as never, {} as never);
@@ -31,12 +50,23 @@ describe('ReportMaterialsService', () => {
sheet.getCell('A2').value = { formula: 'HYPERLINK("https://invalid.example","click")', result: 'click' };
const buffer = Buffer.from(await workbook.xlsx.writeBuffer());
const files = { upload: jest.fn() };
const service = new ReportMaterialsService({ reportMaterialImportProfile: { findUnique: jest.fn() } } as never, files as never, {} as never);
const service = new ReportMaterialsService(
{ reportMaterialImportProfile: { findUnique: jest.fn() } } as never,
files as never,
{} as never,
);
await expect(service.analyzeImport(
{ originalname: 'unsafe.xlsx', mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: buffer.length, buffer },
{ tenantId: 'tenant-1', reportType: 'signature', headerRowCount: 1, dataStartRow: 2 },
)).rejects.toThrow('公式或可执行单元格');
await expect(
service.analyzeImport(
{
originalname: 'unsafe.xlsx',
mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
size: buffer.length,
buffer,
},
{ tenantId: 'tenant-1', reportType: 'signature', headerRowCount: 1, dataStartRow: 2 },
),
).rejects.toThrow('公式或可执行单元格');
expect(files.upload).not.toHaveBeenCalled();
});
@@ -45,69 +75,232 @@ describe('ReportMaterialsService', () => {
const sheet = workbook.addWorksheet('签名资料');
sheet.addRow(['短信签名', '营业执照']);
sheet.addRow(['测试签名', '']);
const imageId = workbook.addImage({ base64: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZsmAAAAAASUVORK5CYII=', extension: 'png' });
const imageId = workbook.addImage({
base64:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZsmAAAAAASUVORK5CYII=',
extension: 'png',
});
sheet.addImage(imageId, { tl: { col: 1, row: 1 }, ext: { width: 80, height: 60 } });
const buffer = Buffer.from(await workbook.xlsx.writeBuffer());
const prisma = {
reportMaterialImportProfile: { findUnique: jest.fn().mockResolvedValue({ sheetName: '签名资料', columns: [{ sourceHeader: '短信签名', sourceHeaderPath: '短信签名', sourceColumnIndex: 9, targetFieldCode: 'signature_name', targetKind: 'signatureName', fieldType: 'string', required: true, sortOrder: 10 }] }) },
reportMaterialImportBatch: { create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'import-1', ...data })) },
reportMaterialImportProfile: {
findUnique: jest
.fn()
.mockResolvedValue({
sheetName: '签名资料',
columns: [
{
sourceHeader: '短信签名',
sourceHeaderPath: '短信签名',
sourceColumnIndex: 9,
targetFieldCode: 'signature_name',
targetKind: 'signatureName',
fieldType: 'string',
required: true,
sortOrder: 10,
},
],
}),
},
reportMaterialImportBatch: {
create: jest
.fn()
.mockImplementation(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'import-1', ...data }),
),
},
operationLog: { create: jest.fn().mockResolvedValue({ id: 'log-1' }) },
};
const files = { upload: jest.fn().mockResolvedValue({ id: 'source-1', fileName: '签名资料.xlsx', contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) };
const files = {
upload: jest
.fn()
.mockResolvedValue({
id: 'source-1',
fileName: '签名资料.xlsx',
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
}),
};
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
const result = await service.analyzeImport({ originalname: '签名资料.xlsx', mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: buffer.length, buffer }, { tenantId: 'tenant-1', applicationId: 'app-1', reportType: 'signature', headerRowCount: 1, dataStartRow: 2, profileId: 'profile-1' });
const result = await service.analyzeImport(
{
originalname: '签名资料.xlsx',
mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
size: buffer.length,
buffer,
},
{
tenantId: 'tenant-1',
applicationId: 'app-1',
reportType: 'signature',
headerRowCount: 1,
dataStartRow: 2,
profileId: 'profile-1',
},
);
expect(result.imageCount).toBe(1);
expect(result.columns).toEqual(expect.arrayContaining([expect.objectContaining({ sourceHeader: '营业执照', imageCount: 1 })]));
expect(result.columns).toEqual(
expect.arrayContaining([expect.objectContaining({ sourceHeader: '营业执照', imageCount: 1 })]),
);
expect(result.rows).toEqual([expect.objectContaining({ rowNumber: 2, imageColumns: [2] })]);
expect(result.suggestedMappings).toEqual([expect.objectContaining({ sourceColumnIndex: 1, targetKind: 'signatureName' })]);
expect(result.suggestedMappings).toEqual([
expect.objectContaining({ sourceColumnIndex: 1, targetKind: 'signatureName' }),
]);
});
it('expands one selected signature to every routed channel and embeds images in each XLSX', async () => {
const uploadedWorkbooks: Buffer[] = [];
let batchItemSequence = 0;
let exportSequence = 0;
const channels = [{ id: 'channel-a', name: '通道A', status: 'active' }, { id: 'channel-b', name: '通道B', status: 'active' }];
const channels = [
{ id: 'channel-a', name: '通道A', status: 'active' },
{ id: 'channel-b', name: '通道B', status: 'active' },
];
const prisma = {
$transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)),
$executeRaw: jest.fn().mockResolvedValue(1),
operationLog: { findFirst: jest.fn().mockResolvedValue(null), findUnique: jest.fn().mockResolvedValue({ detail: { fingerprint: 'fingerprint' } }), create: jest.fn().mockResolvedValue({ id: 'operation-1' }), update: jest.fn().mockResolvedValue({}) },
operationLog: {
findFirst: jest.fn().mockResolvedValue(null),
findUnique: jest.fn().mockResolvedValue({ detail: { fingerprint: 'fingerprint' } }),
create: jest.fn().mockResolvedValue({ id: 'operation-1' }),
update: jest.fn().mockResolvedValue({}),
},
reportMaterialBatch: {
create: jest.fn().mockResolvedValue({ id: 'batch-1', batchNo: 'RB001' }),
update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'batch-1', batchNo: 'RB001', ...data })),
update: jest
.fn()
.mockImplementation(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'batch-1', batchNo: 'RB001', ...data }),
),
},
smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'signature-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', purpose: '验证码', auditStatus: 'approved', pendingReport: true, materialVersion: 3, drainageInfo: { signatureReportValues: { license: { fileObjectId: 'image-1', fileName: 'license.png', contentType: 'image/png' } } }, tenant: { name: '测试企业' }, application: { name: '测试应用', status: 'active' } }),
findUnique: jest
.fn()
.mockResolvedValue({
id: 'signature-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
name: '测试签名',
purpose: '验证码',
auditStatus: 'approved',
pendingReport: true,
materialVersion: 3,
drainageInfo: {
signatureReportValues: {
license: { fileObjectId: 'image-1', fileName: 'license.png', contentType: 'image/png' },
},
},
tenant: { name: '测试企业' },
application: { name: '测试应用', status: 'active' },
}),
update: jest.fn().mockResolvedValue({}),
},
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ carrier: 'mobile', group: { status: 'active', items: channels.map((channel, index) => ({ priority: index, carrier: 'mobile', channel })) } }]) },
reportMaterialBatchItem: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn().mockImplementation(() => Promise.resolve({ id: `batch-item-${++batchItemSequence}` })) },
smsChannel: { findUnique: jest.fn().mockImplementation(({ where }: { where: { id: string } }) => Promise.resolve(channels.find((channel) => channel.id === where.id))) },
channelReportField: { findMany: jest.fn().mockResolvedValue([
{ code: 'sign', name: '短信签名', exportName: '通道签名', required: true, columnWidth: 18, imageWidth: 120, imageHeight: 80, transform: null, defaultValue: null },
{ code: 'license', name: '营业执照', exportName: '营业执照图片', required: true, columnWidth: 24, imageWidth: 120, imageHeight: 80, transform: null, defaultValue: null },
]) },
channelSignatureReportTask: { findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: `task-${String(data.channelId)}`, ...data })), update: jest.fn() },
channelRouteRule: {
findMany: jest
.fn()
.mockResolvedValue([
{
carrier: 'mobile',
group: {
status: 'active',
items: channels.map((channel, index) => ({ priority: index, carrier: 'mobile', channel })),
},
},
]),
},
reportMaterialBatchItem: {
findMany: jest.fn().mockResolvedValue([]),
create: jest.fn().mockImplementation(() => Promise.resolve({ id: `batch-item-${++batchItemSequence}` })),
},
smsChannel: {
findUnique: jest
.fn()
.mockImplementation(({ where }: { where: { id: string } }) =>
Promise.resolve(channels.find((channel) => channel.id === where.id)),
),
},
channelReportField: {
findMany: jest.fn().mockResolvedValue([
{
code: 'sign',
name: '短信签名',
exportName: '通道签名',
required: true,
columnWidth: 18,
imageWidth: 120,
imageHeight: 80,
transform: null,
defaultValue: null,
},
{
code: 'license',
name: '营业执照',
exportName: '营业执照图片',
required: true,
columnWidth: 24,
imageWidth: 120,
imageHeight: 80,
transform: null,
defaultValue: null,
},
]),
},
channelSignatureReportTask: {
findMany: jest.fn().mockResolvedValue([]),
findFirst: jest.fn().mockResolvedValue(null),
create: jest
.fn()
.mockImplementation(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: `task-${String(data.channelId)}`, ...data }),
),
update: jest.fn(),
},
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({}) },
reportExportFile: { create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: `export-${++exportSequence}`, ...data })) },
reportExportFile: {
create: jest
.fn()
.mockImplementation(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: `export-${++exportSequence}`, ...data }),
),
},
reportExportFileItem: { createMany: jest.fn().mockResolvedValue({ count: 1 }) },
};
const files = {
getDownload: jest.fn().mockResolvedValue({ fileObject: { fileName: 'license.png', contentType: 'image/png' }, content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZsmAAAAAASUVORK5CYII=', 'base64') }),
upload: jest.fn().mockImplementation((_options: unknown, file: { originalname: string; mimetype: string; buffer: Buffer }) => {
uploadedWorkbooks.push(file.buffer);
return Promise.resolve({ id: `file-${uploadedWorkbooks.length}`, fileName: file.originalname, contentType: file.mimetype });
}),
getDownload: jest
.fn()
.mockResolvedValue({
fileObject: { fileName: 'license.png', contentType: 'image/png' },
content: Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZsmAAAAAASUVORK5CYII=',
'base64',
),
}),
upload: jest
.fn()
.mockImplementation((_options: unknown, file: { originalname: string; mimetype: string; buffer: Buffer }) => {
uploadedWorkbooks.push(file.buffer);
return Promise.resolve({
id: `file-${uploadedWorkbooks.length}`,
fileName: file.originalname,
contentType: file.mimetype,
});
}),
};
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
const result = await service.createBatch({ idempotencyKey: 'report-batch:test-1', items: [{ reportType: 'signature', signatureId: 'signature-1', materialVersion: 3 }] });
const result = await service.createBatch({
idempotencyKey: 'report-batch:test-1',
items: [{ reportType: 'signature', signatureId: 'signature-1', materialVersion: 3 }],
});
expect(result).toMatchObject({ status: 'completed', channelCount: 2, fileCount: 2 });
expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledTimes(2);
expect(prisma.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'signature-1' }, data: { pendingReport: false } });
expect(prisma.smsSignature.update).toHaveBeenCalledWith({
where: { id: 'signature-1' },
data: { pendingReport: false },
});
expect(uploadedWorkbooks).toHaveLength(2);
for (const buffer of uploadedWorkbooks) {
const workbook = new ExcelJS.Workbook();
@@ -123,24 +316,83 @@ describe('ReportMaterialsService', () => {
const prisma = {
$transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)),
$executeRaw: jest.fn().mockResolvedValue(1),
operationLog: { findFirst: jest.fn().mockResolvedValue(null), findUnique: jest.fn().mockResolvedValue({ detail: { fingerprint: 'fingerprint' } }), create: jest.fn().mockResolvedValue({ id: 'operation-2' }), update: jest.fn().mockResolvedValue({}) },
reportMaterialBatch: { create: jest.fn().mockResolvedValue({ id: 'batch-2' }), update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve(data)) },
smsSignature: { findUnique: jest.fn().mockResolvedValue({ id: 'signature-2', tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', auditStatus: 'approved', pendingReport: true, materialVersion: 1, drainageInfo: {}, tenant: { name: '企业' }, application: { name: '应用', status: 'active' } }), update: jest.fn() },
operationLog: {
findFirst: jest.fn().mockResolvedValue(null),
findUnique: jest.fn().mockResolvedValue({ detail: { fingerprint: 'fingerprint' } }),
create: jest.fn().mockResolvedValue({ id: 'operation-2' }),
update: jest.fn().mockResolvedValue({}),
},
reportMaterialBatch: {
create: jest.fn().mockResolvedValue({ id: 'batch-2' }),
update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve(data)),
},
smsSignature: {
findUnique: jest
.fn()
.mockResolvedValue({
id: 'signature-2',
tenantId: 'tenant-1',
applicationId: 'app-1',
name: '测试签名',
auditStatus: 'approved',
pendingReport: true,
materialVersion: 1,
drainageInfo: {},
tenant: { name: '企业' },
application: { name: '应用', status: 'active' },
}),
update: jest.fn(),
},
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ carrier: 'mobile', group: { status: 'active', items: [{ carrier: 'mobile', channel: { id: 'channel-a', name: '通道A', status: 'active' } }] } }]) },
reportMaterialBatchItem: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn().mockResolvedValue({ id: 'batch-item-2' }) },
channelRouteRule: {
findMany: jest
.fn()
.mockResolvedValue([
{
carrier: 'mobile',
group: {
status: 'active',
items: [{ carrier: 'mobile', channel: { id: 'channel-a', name: '通道A', status: 'active' } }],
},
},
]),
},
reportMaterialBatchItem: {
findMany: jest.fn().mockResolvedValue([]),
create: jest.fn().mockResolvedValue({ id: 'batch-item-2' }),
},
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-a', name: '通道A' }) },
channelReportField: { findMany: jest.fn().mockResolvedValue([]) },
channelSignatureReportTask: { findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'task-2', ...data })) },
channelSignatureReportTask: {
findMany: jest.fn().mockResolvedValue([]),
findFirst: jest.fn().mockResolvedValue(null),
create: jest
.fn()
.mockImplementation(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'task-2', ...data }),
),
},
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({}) },
reportExportFile: { create: jest.fn().mockResolvedValue({ id: 'export-2' }) },
reportExportFileItem: { createMany: jest.fn() },
};
const files = { upload: jest.fn().mockResolvedValue({ id: 'file-2', fileName: 'empty.xlsx', contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) };
const files = {
upload: jest
.fn()
.mockResolvedValue({
id: 'file-2',
fileName: 'empty.xlsx',
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
}),
};
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
await expect(service.createBatch({ idempotencyKey: 'report-batch:test-2', items: [{ reportType: 'signature', signatureId: 'signature-2', materialVersion: 1 }] }))
.rejects.toMatchObject({ response: expect.objectContaining({ code: 'REPORT_BATCH_NOT_ELIGIBLE' }) });
await expect(
service.createBatch({
idempotencyKey: 'report-batch:test-2',
items: [{ reportType: 'signature', signatureId: 'signature-2', materialVersion: 1 }],
}),
).rejects.toMatchObject({ response: expect.objectContaining({ code: 'REPORT_BATCH_NOT_ELIGIBLE' }) });
expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled();
expect(prisma.smsSignature.update).not.toHaveBeenCalled();
@@ -150,15 +402,47 @@ describe('ReportMaterialsService', () => {
const prisma = {
$transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)),
$executeRaw: jest.fn().mockResolvedValue(1),
operationLog: { findFirst: jest.fn().mockResolvedValue({ id: 'operation-existing', detail: { status: 'completed', fingerprint: expect.anything(), result: { id: 'batch-existing', batchNo: 'RB-EXISTING', status: 'completed', result: { successCount: 1, skippedCount: 0, failedCount: 0, items: [] } } } }) },
operationLog: {
findFirst: jest
.fn()
.mockResolvedValue({
id: 'operation-existing',
detail: {
status: 'completed',
fingerprint: expect.anything(),
result: {
id: 'batch-existing',
batchNo: 'RB-EXISTING',
status: 'completed',
result: { successCount: 1, skippedCount: 0, failedCount: 0, items: [] },
},
},
}),
},
reportMaterialBatch: { create: jest.fn() },
};
const service = new ReportMaterialsService(prisma as never, {} as never, {} as never);
const items = [{ reportType: 'signature' as const, signatureId: 'signature-1', materialVersion: 3 }];
const fingerprint = createFingerprint(items);
prisma.operationLog.findFirst.mockResolvedValueOnce({ id: 'operation-existing', detail: { status: 'completed', fingerprint, result: { id: 'batch-existing', batchNo: 'RB-EXISTING', status: 'completed', result: { successCount: 1, skippedCount: 0, failedCount: 0, items: [] } } } });
prisma.operationLog.findFirst.mockResolvedValueOnce({
id: 'operation-existing',
detail: {
status: 'completed',
fingerprint,
result: {
id: 'batch-existing',
batchNo: 'RB-EXISTING',
status: 'completed',
result: { successCount: 1, skippedCount: 0, failedCount: 0, items: [] },
},
},
});
await expect(service.createBatch({ idempotencyKey: 'report-batch:replay', items })).resolves.toMatchObject({ id: 'batch-existing', replayed: true, operationId: 'operation-existing' });
await expect(service.createBatch({ idempotencyKey: 'report-batch:replay', items })).resolves.toMatchObject({
id: 'batch-existing',
replayed: true,
operationId: 'operation-existing',
});
expect(prisma.reportMaterialBatch.create).not.toHaveBeenCalled();
});
@@ -182,7 +466,11 @@ describe('ReportMaterialsService', () => {
sheetName: '签名资料',
dataStartRow: 2,
}),
update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'import-review-1', ...data, items: stagedRows })),
update: jest
.fn()
.mockImplementation(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'import-review-1', ...data, items: stagedRows }),
),
},
reportMaterialImportItem: {
createMany: jest.fn().mockImplementation(({ data }: { data: Array<Record<string, unknown>> }) => {
@@ -205,19 +493,34 @@ describe('ReportMaterialsService', () => {
const result = await service.commitImport('import-review-1', {
operatorId: 'operator-1',
mappings: [
{ sourceHeader: '短信签名', sourceColumnIndex: 1, targetFieldCode: 'signature_name', targetKind: 'signatureName', fieldType: 'string', required: true },
{ sourceHeader: '用途说明', sourceColumnIndex: 2, targetFieldCode: 'purpose', targetKind: 'purpose', fieldType: 'string' },
{
sourceHeader: '短信签名',
sourceColumnIndex: 1,
targetFieldCode: 'signature_name',
targetKind: 'signatureName',
fieldType: 'string',
required: true,
},
{
sourceHeader: '用途说明',
sourceColumnIndex: 2,
targetFieldCode: 'purpose',
targetKind: 'purpose',
fieldType: 'string',
},
],
});
expect(result).toMatchObject({ status: 'pending_review', successCount: 1, failedCount: 0 });
expect(stagedRows).toEqual([expect.objectContaining({
rowNumber: 2,
reportType: 'signature',
operation: 'create',
status: 'pending_review',
payload: expect.objectContaining({ name: '待审签名', purpose: '验证码' }),
})]);
expect(stagedRows).toEqual([
expect.objectContaining({
rowNumber: 2,
reportType: 'signature',
operation: 'create',
status: 'pending_review',
payload: expect.objectContaining({ name: '待审签名', purpose: '验证码' }),
}),
]);
expect(smsConfig.createSignature).not.toHaveBeenCalled();
expect(smsConfig.updateSignature).not.toHaveBeenCalled();
expect(smsConfig.approveSignature).not.toHaveBeenCalled();
@@ -239,11 +542,13 @@ describe('ReportMaterialsService', () => {
};
const service = new ReportMaterialsService(prisma as never, {} as never, {} as never);
await expect(service.reviewImportItems('import-review-2', {
decision: 'reject',
itemIds: ['item-1'],
reviewerId: 'reviewer-1',
})).resolves.toMatchObject({ status: 'rejected', rejectedCount: 1, failedCount: 0 });
await expect(
service.reviewImportItems('import-review-2', {
decision: 'reject',
itemIds: ['item-1'],
reviewerId: 'reviewer-1',
}),
).resolves.toMatchObject({ status: 'rejected', rejectedCount: 1, failedCount: 0 });
expect(prisma.reportMaterialImportItem.update).toHaveBeenCalledWith({
where: { id: 'item-1' },
data: expect.objectContaining({ status: 'rejected', reviewReason: undefined, reviewedById: 'reviewer-1' }),
@@ -253,15 +558,19 @@ describe('ReportMaterialsService', () => {
it('calculates generated batch totals and success rate from per-channel report tasks', async () => {
const prisma = {
reportMaterialBatch: {
findMany: jest.fn().mockResolvedValue([{
id: 'batch-stats-1',
batchNo: 'RB-STATS-1',
exportFiles: [
{ items: [{ task: { id: 'task-1', status: 'approved' } }, { task: { id: 'task-2', status: 'rejected' } }] },
{ items: [{ task: { id: 'task-3', status: 'approved' } }] },
],
items: [],
}]),
findMany: jest.fn().mockResolvedValue([
{
id: 'batch-stats-1',
batchNo: 'RB-STATS-1',
exportFiles: [
{
items: [{ task: { id: 'task-1', status: 'approved' } }, { task: { id: 'task-2', status: 'rejected' } }],
},
{ items: [{ task: { id: 'task-3', status: 'approved' } }] },
],
items: [],
},
]),
count: jest.fn().mockResolvedValue(1),
},
};
@@ -279,12 +588,27 @@ describe('ReportMaterialsService', () => {
it('rejects malformed preflight items as a readable 400 before Prisma is called', async () => {
const prisma = { smsSignature: { findUnique: jest.fn() } };
const service = new ReportMaterialsService(prisma as never, {} as never, {} as never);
await expect(service.preflightBatch({ items: [{ reportType: 'signature', signatureId: '' }] })).rejects.toMatchObject({ response: expect.objectContaining({ code: 'REPORT_BATCH_ITEM_INVALID' }) });
await expect(
service.preflightBatch({ items: [{ reportType: 'signature', signatureId: '' }] }),
).rejects.toMatchObject({ response: expect.objectContaining({ code: 'REPORT_BATCH_ITEM_INVALID' }) });
expect(prisma.smsSignature.findUnique).not.toHaveBeenCalled();
});
});
function createFingerprint(items: Array<{ reportType: string; signatureId: string; materialVersion: number }>) {
const { createHash } = require('node:crypto') as typeof import('node:crypto');
return createHash('sha256').update(JSON.stringify(items.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: null, materialVersion: item.materialVersion })).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))))).digest('hex');
return createHash('sha256')
.update(
JSON.stringify(
items
.map((item) => ({
reportType: item.reportType,
signatureId: item.signatureId,
drainageItemId: null,
materialVersion: item.materialVersion,
}))
.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))),
),
)
.digest('hex');
}
@@ -2,7 +2,19 @@ import { Injectable } from '@nestjs/common';
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 type {
AnalyzeImportOptions,
CreateImportProfileDto,
CreateReportBatchDto,
EmbeddedImage,
ImportCommitDto,
ImportMapping,
PagedQuery,
ReportBatchInspection,
ReportBatchTarget,
ReviewImportItemsDto,
SingleReportMaterialDto,
} from './report-materials.contracts';
import { ReportBatchGenerationService } from './batch-generation.service';
import { ReportBatchOperationService } from './batch-operation.service';
import { ReportChannelExportService } from './channel-export.service';
@@ -29,18 +41,29 @@ export class ReportMaterialsService {
this.importReview = new ReportImportReviewService(prisma, files, smsConfig, this.importParser);
this.batchOperation = new ReportBatchOperationService(prisma, files, smsConfig);
this.channelExport = new ReportChannelExportService(prisma, files, smsConfig);
this.batchGeneration = new ReportBatchGenerationService(prisma, files, smsConfig, this.batchOperation, this.channelExport);
this.batchGeneration = new ReportBatchGenerationService(
prisma,
files,
smsConfig,
this.batchOperation,
this.channelExport,
);
}
async buildOfficialTemplate(reportType: 'signature' | 'drainage', operatorId?: string) {
return this.officialExport.buildOfficialTemplate(reportType, operatorId);
}
async exportPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string }, operatorId?: string) {
async exportPending(
query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string },
operatorId?: string,
) {
return this.officialExport.exportPending(query, operatorId);
}
async listPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery) {
async listPending(
query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery,
) {
return this.pendingQuery.listPending(query);
}
@@ -52,7 +75,10 @@ export class ReportMaterialsService {
return this.importParser.saveImportProfile(data);
}
async analyzeImport(file: { originalname: string; mimetype: string; size: number; buffer: Buffer }, options: AnalyzeImportOptions) {
async analyzeImport(
file: { originalname: string; mimetype: string; size: number; buffer: Buffer },
options: AnalyzeImportOptions,
) {
return this.importParser.analyzeImport(file, options);
}
@@ -72,6 +98,17 @@ export class ReportMaterialsService {
return this.batchGeneration.listBatches(query);
}
async getBatch(batchId: string) {
return this.batchGeneration.getBatch(batchId);
}
async listBatchTasks(
batchId: string,
query: PagedQuery & { reportType?: string; status?: string; channelId?: string } = {},
) {
return this.batchGeneration.listBatchTasks(batchId, query);
}
async createBatch(data: CreateReportBatchDto) {
return this.batchGeneration.createBatch(data);
}
@@ -79,4 +116,12 @@ export class ReportMaterialsService {
async preflightBatch(data: Pick<CreateReportBatchDto, 'items'>) {
return this.batchGeneration.preflightBatch(data);
}
async getSingleMaterialDetail(data: SingleReportMaterialDto) {
return this.channelExport.getSingleMaterialDetail(data);
}
async exportSingleMaterial(data: SingleReportMaterialDto, operatorId?: string) {
return this.channelExport.exportSingleMaterial(data, operatorId);
}
}
File diff suppressed because it is too large Load Diff
@@ -796,13 +796,14 @@ describe('SmsConfigService', () => {
name: { contains: '签名' },
drainageItems: expect.objectContaining({ some: expect.objectContaining({ OR: expect.any(Array) }) }),
}),
include: {
include: expect.objectContaining({
materials: true,
tenant: true,
application: true,
drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } },
reportTasks: { include: { channel: true, drainageInfo: true } },
},
reportBatchItems: expect.any(Object),
}),
orderBy: [{ name: 'asc' }, { id: 'asc' }],
}));
});