feat: build reporting workbench workflow
This commit is contained in:
@@ -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 }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }],
|
||||
}));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user