diff --git a/api/src/channels/channel-reporting.service.ts b/api/src/channels/channel-reporting.service.ts index a2d4b9a..2516b72 100644 --- a/api/src/channels/channel-reporting.service.ts +++ b/api/src/channels/channel-reporting.service.ts @@ -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 } : {}), }, }); diff --git a/api/src/channels/channels.controller.ts b/api/src/channels/channels.controller.ts index 4efed59..b230ab1 100644 --- a/api/src/channels/channels.controller.ts +++ b/api/src/channels/channels.controller.ts @@ -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); } } diff --git a/api/src/channels/channels.service.ts b/api/src/channels/channels.service.ts index 0725132..1a952c4 100644 --- a/api/src/channels/channels.service.ts +++ b/api/src/channels/channels.service.ts @@ -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; diff --git a/api/src/report-materials/batch-generation.service.ts b/api/src/report-materials/batch-generation.service.ts index 4ec8efe..4ed97c2 100644 --- a/api/src/report-materials/batch-generation.service.ts +++ b/api/src/report-materials/batch-generation.service.ts @@ -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 => 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(); + 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>; - 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>(); - 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(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>; + 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>(); + 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( + 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) { - 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 { - 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 }>(); - 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() }; - 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(); - 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 { + 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; + } + >(); + 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(), + }; + 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(); + 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, + }; + } } diff --git a/api/src/report-materials/channel-export.service.ts b/api/src/report-materials/channel-export.service.ts index 2451230..54b0773 100644 --- a/api/src/report-materials/channel-export.service.ts +++ b/api/src/report-materials/channel-export.service.ts @@ -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>>) { - 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>>, + ) { + 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; + 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', + }, + }); + } } diff --git a/api/src/report-materials/import-review.service.ts b/api/src/report-materials/import-review.service.ts index 0adebd0..3f190eb 100644 --- a/api/src/report-materials/import-review.service.ts +++ b/api/src/report-materials/import-review.service.ts @@ -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 = {}; - 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 = {}; + 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) { - 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) { - 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, + ) { + 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, + ) { + 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; + } } diff --git a/api/src/report-materials/pending-query.service.ts b/api/src/report-materials/pending-query.service.ts index 8890deb..5c4ad7e 100644 --- a/api/src/report-materials/pending-query.service.ts +++ b/api/src/report-materials/pending-query.service.ts @@ -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()); + } } diff --git a/api/src/report-materials/report-materials.contracts.ts b/api/src/report-materials/report-materials.contracts.ts index 8c6232f..d25bc8d 100644 --- a/api/src/report-materials/report-materials.contracts.ts +++ b/api/src/report-materials/report-materials.contracts.ts @@ -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; diff --git a/api/src/report-materials/report-materials.controller.ts b/api/src/report-materials/report-materials.controller.ts index d04301b..c064b7d 100644 --- a/api/src/report-materials/report-materials.controller.ts +++ b/api/src/report-materials/report-materials.controller.ts @@ -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, @CurrentSessionUserId() operatorId?: string) { + analyzeImport( + @UploadedFile() file: UploadedWorkbook, + @Body() body: Record, + @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'); diff --git a/api/src/report-materials/report-materials.helpers.ts b/api/src/report-materials/report-materials.helpers.ts index 97bf8d8..12b9621 100644 --- a/api/src/report-materials/report-materials.helpers.ts +++ b/api/src/report-materials/report-materials.helpers.ts @@ -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(); 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, kind: ImportMapping['targetKind']) { +export function mappedCoreValue( + mappings: ImportMapping[], + values: Record, + 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) { - return Object.fromEntries(mappings.filter((item) => item.targetKind === 'dynamic').map((item) => [item.targetFieldCode, values[item.targetFieldCode]])); +export function mappedCorePatchValue( + mappings: ImportMapping[], + values: Record, + 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 { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } +export function dynamicValues(mappings: ImportMapping[], values: Record) { + 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 { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; +} -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).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).fileObjectId === 'string' + ); +} export function resolveExportValue(snapshot: Record, code: string, name?: string) { const values = jsonRecord(snapshot.values); @@ -149,9 +262,16 @@ export function resolveExportValue(snapshot: Record, code: stri const signature = jsonRecord(snapshot.signature); const drainage = jsonRecord(snapshot.drainage); const aliases: Record = { - 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; } diff --git a/api/src/report-materials/report-materials.service.spec.ts b/api/src/report-materials/report-materials.service.spec.ts index c868782..78e9b6d 100644 --- a/api/src/report-materials/report-materials.service.spec.ts +++ b/api/src/report-materials/report-materials.service.spec.ts @@ -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 }) => 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 }) => + 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 }) => Promise.resolve({ id: 'batch-1', batchNo: 'RB001', ...data })), + update: jest + .fn() + .mockImplementation(({ data }: { data: Record }) => + 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 }) => 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 }) => + Promise.resolve({ id: `task-${String(data.channelId)}`, ...data }), + ), + update: jest.fn(), + }, channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({}) }, - reportExportFile: { create: jest.fn().mockImplementation(({ data }: { data: Record }) => Promise.resolve({ id: `export-${++exportSequence}`, ...data })) }, + reportExportFile: { + create: jest + .fn() + .mockImplementation(({ data }: { data: Record }) => + 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 }) => 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 }) => 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 }) => Promise.resolve({ id: 'task-2', ...data })) }, + channelSignatureReportTask: { + findMany: jest.fn().mockResolvedValue([]), + findFirst: jest.fn().mockResolvedValue(null), + create: jest + .fn() + .mockImplementation(({ data }: { data: Record }) => + 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 }) => Promise.resolve({ id: 'import-review-1', ...data, items: stagedRows })), + update: jest + .fn() + .mockImplementation(({ data }: { data: Record }) => + Promise.resolve({ id: 'import-review-1', ...data, items: stagedRows }), + ), }, reportMaterialImportItem: { createMany: jest.fn().mockImplementation(({ data }: { data: Array> }) => { @@ -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'); } diff --git a/api/src/report-materials/report-materials.service.ts b/api/src/report-materials/report-materials.service.ts index 0d523c7..94aa713 100644 --- a/api/src/report-materials/report-materials.service.ts +++ b/api/src/report-materials/report-materials.service.ts @@ -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) { 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); + } } diff --git a/api/src/sms-config/signature.service.ts b/api/src/sms-config/signature.service.ts index 46015fc..fd876a3 100644 --- a/api/src/sms-config/signature.service.ts +++ b/api/src/sms-config/signature.service.ts @@ -1,12 +1,64 @@ -import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { randomInt, randomUUID } from 'node:crypto'; import { isIpAllowed } from '../common/ip-allowlist'; import { assertMoneyUnits } from '../common/money'; import { PrismaService } from '../prisma/prisma.service'; import { automaticDeliveryMode } from '../open-api/delivery-mode'; -import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts'; -import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers'; +import type { + ApplicationListQuery, + CreateSignatureMaterialDto, + CreateSmsApplicationDto, + CreateSmsDrainageInfoDto, + CreateSmsSignatureDto, + CreateSmsSignatureOptions, + CreateSmsTemplateDto, + CreateSmsTemplateOptions, + DrainageInfoListQuery, + GatewayDownstreamConnectionEventDto, + ReplaceApplicationRouteRulesDto, + ReviewDto, + SignatureListQuery, + StatusChangeDto, + TemplateListQuery, + UpdateSmsApplicationDto, + UpdateSmsDrainageInfoDto, + UpdateSmsSignatureDto, + UpdateSmsTemplateDto, +} from './sms-config.contracts'; +import { + APPLICATION_DISABLE_GRACE_MS, + DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, + DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, + UNRESOLVED_DOWNSTREAM_STATUSES, + type TemplateVariableInput, + estimateBillingUnits, + generateApplicationPassword, + getPositiveInteger, + getPositiveIntegerEnv, + hasReportValue, + inferTemplateVariables, + isRecord, + normalizeApplicationCmppStatus, + normalizeApplicationInterfaceType, + normalizeApplicationPassword, + normalizeApplicationQueuePriority, + normalizeCmppAccessNumberConfig, + normalizeSmsSignature, + parseGatewayDate, + reportValueParts, + startOfToday, + validateAndNormalizeTemplateVariables, + validateCompleteSmsSignature, +} from './sms-config.helpers'; import { SmsReportValidationService } from './report-validation.service'; import { SmsAuditService } from './audit.service'; import { shanghaiDateRange } from '../common/shanghai-date-range'; @@ -15,19 +67,284 @@ import { normalizeChannelCarriers } from '../channels/channels.helpers'; /** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ export class SmsSignatureService { - constructor(private readonly prisma: PrismaService, private readonly reportValidation: SmsReportValidationService, private readonly audit: SmsAuditService) {} + constructor( + private readonly prisma: PrismaService, + private readonly reportValidation: SmsReportValidationService, + private readonly audit: SmsAuditService, + ) {} async listSignatures(queryOrTenantId?: string | SignatureListQuery) { - const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {}; - const signatureSort = query.signatureSort === 'asc' || query.signatureSort === 'desc' ? query.signatureSort : undefined; - const signatures = await this.prisma.smsSignature.findMany({ - where: { - tenantId: query.tenantId, - auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, - tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, - application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, - name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined, - updatedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo), - drainageItems: query.drainageKeyword ? { + const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : (queryOrTenantId ?? {}); + const signatureSort = + query.signatureSort === 'asc' || query.signatureSort === 'desc' ? query.signatureSort : undefined; + const signatures = await this.prisma.smsSignature.findMany({ + where: { + tenantId: query.tenantId, + auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, + tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, + application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, + name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined, + updatedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo), + drainageItems: query.drainageKeyword + ? { + some: { + auditStatus: { not: 'deleted' }, + OR: [ + { siteName: { contains: query.drainageKeyword } }, + { url: { contains: query.drainageKeyword } }, + { remark: { contains: query.drainageKeyword } }, + ], + }, + } + : undefined, + OR: query.keyword + ? [ + { name: { contains: query.keyword } }, + { purpose: { contains: query.keyword } }, + { tenant: { name: { contains: query.keyword } } }, + { application: { name: { contains: query.keyword } } }, + ] + : undefined, + }, + include: { + materials: true, + tenant: true, + application: true, + drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } }, + reportTasks: { include: { channel: true, drainageInfo: true } }, + reportBatchItems: { + where: { batch: { status: { in: ['completed', 'partial_failed'] } } }, + select: { reportType: true, materialVersion: true, snapshot: true }, + }, + }, + orderBy: signatureSort ? [{ name: signatureSort }, { id: 'asc' }] : { createdAt: 'desc' }, + ...(query.page && query.pageSize + ? { + skip: (query.page - 1) * query.pageSize, + take: query.pageSize, + } + : {}), + }); + const applicationIds = signatures + .map((signature) => signature.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: { include: { reportFields: true } } } } } } }, + }) + : []; + const hasCommonDrainageFields = await this.prisma.commonReportField + .count({ + where: { status: 'active', reportType: 'drainage', drainageField: { status: 'active' } }, + }) + .then((count) => count > 0); + return signatures.map((signature) => { + const { reportBatchItems: _reportBatchItems, ...signatureView } = signature; + const legacyPayload = isRecord(signature.drainageInfo) ? signature.drainageInfo : {}; + const applicationChannels = [ + ...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 signatureTasks = (signature.reportTasks ?? []).filter((task) => task.reportType === 'signature'); + const generatedTargets = new Set(); + for (const item of (signature.reportBatchItems ?? []).filter( + (entry) => entry.reportType === 'signature' && entry.materialVersion === signature.materialVersion, + )) { + const businessKeys = isRecord(item.snapshot) ? item.snapshot.businessKeys : undefined; + if (!Array.isArray(businessKeys)) continue; + for (const value of businessKeys) { + const match = typeof value === 'string' ? value.match(/:channel:([^:]+):carrier:([^:]+)$/) : null; + if (!match) continue; + for (const carrier of match[2] + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean)) + generatedTargets.add(`${match[1]}:${carrier}`); + } + } + const pendingReportTargets = applicationChannels + .flatMap((channel) => + normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => ({ channel, carrier })), + ) + .filter(({ channel, carrier }) => { + const task = + signatureTasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier) ?? + signatureTasks.find( + (candidate) => + candidate.channelId === channel.id && + candidate.carrier === null && + candidate.approvalScope === 'legacy_channel', + ); + if (task?.status === 'abandoned') return false; + return !generatedTargets.has(`${channel.id}:${carrier}`) && !generatedTargets.has(`${channel.id}:legacy`); + }); + const pendingReportBlockedReason = + signature.auditStatus !== 'approved' + ? '审核通过后计算' + : !signature.applicationId + ? '未绑定短信应用' + : signature.application?.status !== 'active' + ? '短信应用未启用' + : applicationChannels.length === 0 + ? '暂无有效报备通道' + : !signature.pendingReport || pendingReportTargets.length === 0 + ? '当前资料版本无需生成批次' + : null; + const drainageLinks = signature.drainageItems.map((item) => ({ + id: item.id, + siteName: item.siteName, + url: item.url, + remark: item.remark ?? '', + reportValues: isRecord(item.reportValues) ? item.reportValues : {}, + auditStatus: item.auditStatus, + rejectReason: item.rejectReason, + submittedAt: item.submittedAt.toISOString(), + reviewedAt: item.reviewedAt?.toISOString(), + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + })); + return { + ...signatureView, + name: normalizeSmsSignature(signature.name), + drainageInfo: { ...legacyPayload, links: drainageLinks }, + reportTargets: (() => { + const tasks = signatureTasks; + return applicationChannels.flatMap((channel) => + normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => { + 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 { + channel, + channelId: channel.id, + carrier, + status: task?.status ?? 'pending', + taskId: task?.id, + approvedAt: task?.approvedAt, + approvalScope: task?.approvalScope ?? 'carrier_specific', + }; + }), + ); + })(), + pendingReportDetailCount: + signature.auditStatus === 'approved' && signature.pendingReport ? pendingReportTargets.length : 0, + pendingReportMaterialVersion: + signature.auditStatus === 'approved' && signature.pendingReport ? signature.materialVersion : null, + pendingReportBlockedReason, + drainageReportTargets: Object.fromEntries( + signature.drainageItems.map((drainageItem) => { + const drainageItemId = drainageItem.id; + const channels = routes + .filter((route) => route.applicationId === signature.applicationId && route.group) + .flatMap((route) => route.group!.items.map((item) => item.channel)) + .filter( + (channel) => + channel.status !== 'deleted' && + (hasCommonDrainageFields || + channel.reportFields.some( + (field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType), + )), + ); + const taskByChannel = new Map( + (signature.reportTasks ?? []) + .filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId) + .map((task) => [task.channelId, task]), + ); + return [ + drainageItemId, + [...new Map(channels.map((channel) => [channel.id, channel])).values()].flatMap((channel) => { + const task = taskByChannel.get(channel.id); + return task ? [{ channel, channelId: channel.id, status: task.status, taskId: task.id }] : []; + }), + ]; + }), + ), + drainageCarrierReportSummary: Object.fromEntries( + signature.drainageItems.map((drainageItem) => { + const drainageItemId = drainageItem.id; + const channels = routes + .filter((route) => route.applicationId === signature.applicationId && route.group) + .flatMap((route) => route.group!.items.map((item) => item.channel)) + .filter( + (channel) => + channel.status !== 'deleted' && + (hasCommonDrainageFields || + channel.reportFields.some( + (field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType), + )), + ); + const targets = [...new Map(channels.map((channel) => [channel.id, channel])).values()]; + const taskByChannel = new Map( + (signature.reportTasks ?? []) + .filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId) + .map((task) => [task.channelId, task]), + ); + return [ + drainageItemId, + Object.fromEntries( + ['mobile', 'unicom', 'telecom'].map((carrier) => { + const carrierTargets = targets.filter((channel) => + normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier), + ); + const statuses = carrierTargets.flatMap((channel) => + taskByChannel.get(channel.id)?.status ? [taskByChannel.get(channel.id)!.status] : [], + ); + return [carrier, summarizeReportStatuses(statuses)]; + }), + ), + ]; + }), + ), + carrierReportSummary: Object.fromEntries( + ['mobile', 'unicom', 'telecom'].map((carrier) => { + const configured = routes + .filter((route) => route.applicationId === signature.applicationId && route.group) + .flatMap((route) => route.group!.items.map((item) => item.channel)) + .filter( + (channel) => + channel.status !== 'deleted' && + normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier), + ); + const targets = [...new Map(configured.map((channel) => [channel.id, channel])).values()]; + const signatureTasks = (signature.reportTasks ?? []).filter((task) => task.reportType === 'signature'); + const statuses = targets.map( + (channel) => + signatureTasks.find((task) => task.channelId === channel.id && task.carrier === carrier)?.status ?? + signatureTasks.find( + (task) => + task.channelId === channel.id && task.carrier === null && task.approvalScope === 'legacy_channel', + )?.status ?? + 'pending', + ); + return [carrier, summarizeReportStatuses(statuses)]; + }), + ), + }; + }); + } + + async listSignaturesPage(query: SignatureListQuery) { + 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 where: Prisma.SmsSignatureWhereInput = { + tenantId: query.tenantId, + auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, + tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, + application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, + name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined, + updatedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo), + drainageItems: query.drainageKeyword + ? { some: { auditStatus: { not: 'deleted' }, OR: [ @@ -36,416 +353,383 @@ export class SmsSignatureService { { remark: { contains: query.drainageKeyword } }, ], }, - } : undefined, - OR: query.keyword ? [ + } + : undefined, + OR: query.keyword + ? [ { name: { contains: query.keyword } }, { purpose: { contains: query.keyword } }, { tenant: { name: { contains: query.keyword } } }, { application: { name: { contains: query.keyword } } }, - ] : undefined, - }, - include: { - materials: true, - tenant: true, - application: true, - drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } }, - reportTasks: { include: { channel: true, drainageInfo: true } }, - }, - orderBy: signatureSort - ? [{ name: signatureSort }, { id: 'asc' }] - : { createdAt: 'desc' }, - ...(query.page && query.pageSize ? { - skip: (query.page - 1) * query.pageSize, - take: query.pageSize, - } : {}), - }); - const applicationIds = signatures.map((signature) => signature.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: { include: { reportFields: true } } } } } } }, - }) : []; - const hasCommonDrainageFields = await this.prisma.commonReportField.count({ - where: { status: 'active', reportType: 'drainage', drainageField: { status: 'active' } }, - }).then((count) => count > 0); - return signatures.map((signature) => { - const legacyPayload = isRecord(signature.drainageInfo) ? signature.drainageInfo : {}; - const drainageLinks = signature.drainageItems.map((item) => ({ - id: item.id, - siteName: item.siteName, - url: item.url, - remark: item.remark ?? '', - reportValues: isRecord(item.reportValues) ? item.reportValues : {}, - auditStatus: item.auditStatus, - rejectReason: item.rejectReason, - submittedAt: item.submittedAt.toISOString(), - reviewedAt: item.reviewedAt?.toISOString(), - createdAt: item.createdAt.toISOString(), - updatedAt: item.updatedAt.toISOString(), - })); - return { - ...signature, - name: normalizeSmsSignature(signature.name), - drainageInfo: { ...legacyPayload, links: drainageLinks }, - reportTargets: (() => { - const channels = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted'); - const tasks = (signature.reportTasks ?? []).filter((task) => task.reportType === 'signature'); - return [...new Map(channels.map((channel) => [channel.id, channel])).values()].flatMap((channel) => ( - normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => { - 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 { channel, channelId: channel.id, carrier, status: task?.status ?? 'pending', taskId: task?.id, approvedAt: task?.approvedAt, approvalScope: task?.approvalScope ?? 'carrier_specific' }; - }) - )); - })(), - drainageReportTargets: Object.fromEntries(signature.drainageItems.map((drainageItem) => { - const drainageItemId = drainageItem.id; - const channels = routes - .filter((route) => route.applicationId === signature.applicationId && route.group) - .flatMap((route) => route.group!.items.map((item) => item.channel)) - .filter((channel) => channel.status !== 'deleted' && (hasCommonDrainageFields || channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType)))); - const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task])); - return [drainageItemId, [...new Map(channels.map((channel) => [channel.id, channel])).values()].flatMap((channel) => { - const task = taskByChannel.get(channel.id); - return task ? [{ channel, channelId: channel.id, status: task.status, taskId: task.id }] : []; - })]; - })), - drainageCarrierReportSummary: Object.fromEntries(signature.drainageItems.map((drainageItem) => { - const drainageItemId = drainageItem.id; - const channels = routes - .filter((route) => route.applicationId === signature.applicationId && route.group) - .flatMap((route) => route.group!.items.map((item) => item.channel)) - .filter((channel) => channel.status !== 'deleted' && (hasCommonDrainageFields || channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType)))); - const targets = [...new Map(channels.map((channel) => [channel.id, channel])).values()]; - const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task])); - return [drainageItemId, Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => { - const carrierTargets = targets.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)); - const statuses = carrierTargets.flatMap((channel) => taskByChannel.get(channel.id)?.status ? [taskByChannel.get(channel.id)!.status] : []); - return [carrier, summarizeReportStatuses(statuses)]; - }))]; - })), - carrierReportSummary: Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => { - const configured = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted' && normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)); - const targets = [...new Map(configured.map((channel) => [channel.id, channel])).values()]; - const signatureTasks = (signature.reportTasks ?? []).filter((task) => task.reportType === 'signature'); - const statuses = targets.map((channel) => signatureTasks.find((task) => task.channelId === channel.id && task.carrier === carrier)?.status - ?? signatureTasks.find((task) => task.channelId === channel.id && task.carrier === null && task.approvalScope === 'legacy_channel')?.status - ?? 'pending'); - return [carrier, summarizeReportStatuses(statuses)]; - })), - }; - }); - } - - async listSignaturesPage(query: SignatureListQuery) { - 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 where: Prisma.SmsSignatureWhereInput = { - tenantId: query.tenantId, - auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, - tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, - application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, - name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined, - updatedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo), - drainageItems: query.drainageKeyword ? { - some: { - auditStatus: { not: 'deleted' }, - OR: [ - { siteName: { contains: query.drainageKeyword } }, - { url: { contains: query.drainageKeyword } }, - { remark: { contains: query.drainageKeyword } }, - ], - }, - } : undefined, - OR: query.keyword ? [ - { name: { contains: query.keyword } }, - { purpose: { contains: query.keyword } }, - { tenant: { name: { contains: query.keyword } } }, - { application: { name: { contains: query.keyword } } }, - ] : undefined, - }; - const [items, total] = await Promise.all([ - this.listSignatures({ ...query, page, pageSize }), - this.prisma.smsSignature.count({ where }), - ]); - return { items, total, page, pageSize }; - } + ] + : undefined, + }; + const [items, total] = await Promise.all([ + this.listSignatures({ ...query, page, pageSize }), + this.prisma.smsSignature.count({ where }), + ]); + return { items, total, page, pageSize }; + } listSignatureOptions(tenantId?: string) { - return this.prisma.smsSignature.findMany({ - where: { tenantId, auditStatus: { not: 'deleted' } }, - select: { id: true, tenantId: true, applicationId: true, name: true, auditStatus: true }, - orderBy: [{ name: 'asc' }, { id: 'asc' }], - }); - } + return this.prisma.smsSignature.findMany({ + where: { tenantId, auditStatus: { not: 'deleted' } }, + select: { id: true, tenantId: true, applicationId: true, name: true, auditStatus: true }, + orderBy: [{ name: 'asc' }, { id: 'asc' }], + }); + } - async listClientSignatures(tenantId?: string, signatureId?: string, query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}) { - const signatures = await this.prisma.smsSignature.findMany({ - where: { - id: signatureId, - tenantId, - applicationId: query.applicationId, - auditStatus: { notIn: ['deleted', 'disabled'], ...(query.status && query.status !== 'all' ? { equals: query.status } : {}) }, - OR: query.keyword?.trim() ? [ + async listClientSignatures( + tenantId?: string, + signatureId?: string, + query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}, + ) { + const signatures = await this.prisma.smsSignature.findMany({ + where: { + id: signatureId, + tenantId, + applicationId: query.applicationId, + auditStatus: { + notIn: ['deleted', 'disabled'], + ...(query.status && query.status !== 'all' ? { equals: query.status } : {}), + }, + OR: query.keyword?.trim() + ? [ + { name: { contains: query.keyword.trim() } }, + { purpose: { contains: query.keyword.trim() } }, + { application: { name: { contains: query.keyword.trim() } } }, + ] + : undefined, + }, + select: { + id: true, + tenantId: true, + applicationId: true, + name: true, + purpose: true, + auditStatus: true, + reportStatus: true, + pendingReport: true, + reportChangedAt: true, + rejectReason: true, + drainageInfo: true, + createdAt: true, + updatedAt: true, + application: { select: { id: true, name: true, status: true } }, + materials: { + select: { id: true, fileObjectId: true, materialType: true, title: true, description: true, createdAt: true }, + }, + drainageItems: { + where: { auditStatus: { not: 'deleted' } }, + orderBy: { updatedAt: 'desc' }, + select: { + id: true, + siteName: true, + url: true, + remark: true, + reportValues: true, + auditStatus: true, + rejectReason: true, + submittedAt: true, + reviewedAt: true, + createdAt: true, + updatedAt: true, + }, + }, + reportTasks: { + select: { + channelId: true, + carrier: true, + status: true, + approvalScope: true, + reportType: true, + drainageItemId: true, + }, + }, + _count: { select: { reportMaterials: true } }, + }, + orderBy: { updatedAt: 'desc' }, + skip: query.page && query.pageSize ? (query.page - 1) * query.pageSize : undefined, + take: query.pageSize, + }); + const applicationIds = signatures + .map((signature) => signature.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: { include: { reportFields: true } } } } } } }, + }) + : []; + const hasCommonDrainageFields = await this.prisma.commonReportField + .count({ + where: { status: 'active', reportType: 'drainage', drainageField: { status: 'active' } }, + }) + .then((count) => count > 0); + return signatures.map((signature) => { + const stored = isRecord(signature.drainageInfo) ? signature.drainageInfo : {}; + const applicationChannels = [ + ...new Map( + routes + .filter((route) => route.applicationId === signature.applicationId && route.group) + .flatMap((route) => route.group!.items.map((item) => item.channel)) + .filter((channel) => channel.status !== 'deleted') + .map((channel) => [channel.id, channel]), + ).values(), + ]; + const signatureTasks = signature.reportTasks.filter((task) => task.reportType === 'signature'); + const carrierReportSummary = Object.fromEntries( + ['mobile', 'unicom', 'telecom'].map((carrier) => { + const targets = applicationChannels.filter((channel) => + normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier), + ); + const statuses = targets.map( + (channel) => + signatureTasks.find((task) => task.channelId === channel.id && task.carrier === carrier)?.status ?? + signatureTasks.find( + (task) => + task.channelId === channel.id && task.carrier === null && task.approvalScope === 'legacy_channel', + )?.status ?? + 'pending', + ); + return [carrier, summarizeReportStatuses(statuses)]; + }), + ); + const drainageCarrierReportSummary = Object.fromEntries( + signature.drainageItems.map((item) => { + const targets = applicationChannels.filter( + (channel) => + hasCommonDrainageFields || + channel.reportFields.some( + (field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType), + ), + ); + const tasks = signature.reportTasks.filter( + (task) => task.reportType === 'drainage' && task.drainageItemId === item.id, + ); + return [ + item.id, + Object.fromEntries( + ['mobile', 'unicom', 'telecom'].map((carrier) => { + const carrierTargets = targets.filter((channel) => + normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier), + ); + const statuses = carrierTargets.map( + (channel) => tasks.find((task) => task.channelId === channel.id)?.status ?? 'pending', + ); + return [carrier, summarizeReportStatuses(statuses)]; + }), + ), + ]; + }), + ); + return { + id: signature.id, + tenantId: signature.tenantId, + applicationId: signature.applicationId, + name: normalizeSmsSignature(signature.name), + purpose: signature.purpose, + auditStatus: signature.auditStatus, + reportStatus: signature.reportStatus, + pendingReport: signature.pendingReport, + reportChangedAt: signature.reportChangedAt, + rejectReason: signature.rejectReason, + createdAt: signature.createdAt, + updatedAt: signature.updatedAt, + application: signature.application, + materials: signature.materials, + submittedMaterialCount: signature.materials.length + signature._count.reportMaterials, + carrierReportSummary, + drainageCarrierReportSummary, + reportValues: isRecord(stored.signatureReportValues) ? stored.signatureReportValues : {}, + drainageInfo: { + links: signature.drainageItems.map((item) => ({ + ...item, + reportValues: isRecord(item.reportValues) ? item.reportValues : {}, + })), + }, + }; + }); + } + + async getClientSignatureView(signatureId: string, tenantId?: string) { + const [signature] = await this.listClientSignatures(tenantId, signatureId); + if (!signature) throw new NotFoundException('Signature not found'); + return signature; + } + + async getClientSignatureWorkspace( + tenantId?: string, + query: { keyword?: string; applicationId?: string; status?: 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 filteredWhere: Prisma.SmsSignatureWhereInput = { + tenantId, + applicationId: query.applicationId, + auditStatus: { + notIn: ['deleted', 'disabled'], + ...(query.status && query.status !== 'all' ? { equals: query.status } : {}), + }, + OR: query.keyword?.trim() + ? [ { name: { contains: query.keyword.trim() } }, { purpose: { contains: query.keyword.trim() } }, { application: { name: { contains: query.keyword.trim() } } }, - ] : undefined, - }, - select: { - id: true, - tenantId: true, - applicationId: true, - name: true, - purpose: true, - auditStatus: true, - reportStatus: true, - pendingReport: true, - reportChangedAt: true, - rejectReason: true, - drainageInfo: true, - createdAt: true, - updatedAt: true, - application: { select: { id: true, name: true, status: true } }, - materials: { - select: { id: true, fileObjectId: true, materialType: true, title: true, description: true, createdAt: true }, - }, - drainageItems: { - where: { auditStatus: { not: 'deleted' } }, - orderBy: { updatedAt: 'desc' }, - select: { - id: true, - siteName: true, - url: true, - remark: true, - reportValues: true, - auditStatus: true, - rejectReason: true, - submittedAt: true, - reviewedAt: true, - createdAt: true, - updatedAt: true, - }, - }, - reportTasks: { - select: { channelId: true, carrier: true, status: true, approvalScope: true, reportType: true, drainageItemId: true }, - }, - _count: { select: { reportMaterials: true } }, - }, - orderBy: { updatedAt: 'desc' }, - skip: query.page && query.pageSize ? (query.page - 1) * query.pageSize : undefined, - take: query.pageSize, - }); - const applicationIds = signatures.map((signature) => signature.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: { include: { reportFields: true } } } } } } }, - }) : []; - const hasCommonDrainageFields = await this.prisma.commonReportField.count({ - where: { status: 'active', reportType: 'drainage', drainageField: { status: 'active' } }, - }).then((count) => count > 0); - return signatures.map((signature) => { - const stored = isRecord(signature.drainageInfo) ? signature.drainageInfo : {}; - const applicationChannels = [...new Map(routes - .filter((route) => route.applicationId === signature.applicationId && route.group) - .flatMap((route) => route.group!.items.map((item) => item.channel)) - .filter((channel) => channel.status !== 'deleted') - .map((channel) => [channel.id, channel])).values()]; - const signatureTasks = signature.reportTasks.filter((task) => task.reportType === 'signature'); - const carrierReportSummary = Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => { - const targets = applicationChannels.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)); - const statuses = targets.map((channel) => signatureTasks.find((task) => task.channelId === channel.id && task.carrier === carrier)?.status - ?? signatureTasks.find((task) => task.channelId === channel.id && task.carrier === null && task.approvalScope === 'legacy_channel')?.status - ?? 'pending'); - return [carrier, summarizeReportStatuses(statuses)]; - })); - const drainageCarrierReportSummary = Object.fromEntries(signature.drainageItems.map((item) => { - const targets = applicationChannels.filter((channel) => hasCommonDrainageFields || channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType))); - const tasks = signature.reportTasks.filter((task) => task.reportType === 'drainage' && task.drainageItemId === item.id); - return [item.id, Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => { - const carrierTargets = targets.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)); - const statuses = carrierTargets.map((channel) => tasks.find((task) => task.channelId === channel.id)?.status ?? 'pending'); - return [carrier, summarizeReportStatuses(statuses)]; - }))]; - })); - return { - id: signature.id, - tenantId: signature.tenantId, - applicationId: signature.applicationId, - name: normalizeSmsSignature(signature.name), - purpose: signature.purpose, - auditStatus: signature.auditStatus, - reportStatus: signature.reportStatus, - pendingReport: signature.pendingReport, - reportChangedAt: signature.reportChangedAt, - rejectReason: signature.rejectReason, - createdAt: signature.createdAt, - updatedAt: signature.updatedAt, - application: signature.application, - materials: signature.materials, - submittedMaterialCount: signature.materials.length + signature._count.reportMaterials, - carrierReportSummary, - drainageCarrierReportSummary, - reportValues: isRecord(stored.signatureReportValues) ? stored.signatureReportValues : {}, - drainageInfo: { - links: signature.drainageItems.map((item) => ({ - ...item, - reportValues: isRecord(item.reportValues) ? item.reportValues : {}, - })), - }, - }; - }); - } - - async getClientSignatureView(signatureId: string, tenantId?: string) { - const [signature] = await this.listClientSignatures(tenantId, signatureId); - if (!signature) throw new NotFoundException('Signature not found'); - return signature; - } - - async getClientSignatureWorkspace(tenantId?: string, query: { keyword?: string; applicationId?: string; status?: 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 filteredWhere: Prisma.SmsSignatureWhereInput = { - tenantId, - applicationId: query.applicationId, - auditStatus: { notIn: ['deleted', 'disabled'], ...(query.status && query.status !== 'all' ? { equals: query.status } : {}) }, - OR: query.keyword?.trim() ? [ - { name: { contains: query.keyword.trim() } }, - { purpose: { contains: query.keyword.trim() } }, - { application: { name: { contains: query.keyword.trim() } } }, - ] : undefined, - }; - const [items, total, statusCounts] = await Promise.all([ - this.listClientSignatures(tenantId, undefined, { ...query, page, pageSize }), - this.prisma.smsSignature.count({ where: filteredWhere }), - this.prisma.smsSignature.groupBy({ - by: ['auditStatus'], - where: { tenantId, auditStatus: { notIn: ['deleted', 'disabled'] } }, - _count: { _all: true }, - }), - ]); - const summary = { total: 0, pending: 0, approved: 0, rejected: 0, draft: 0 }; - for (const item of statusCounts) { - const count = item._count._all; - summary.total += count; - if (item.auditStatus in summary && item.auditStatus !== 'total') { - summary[item.auditStatus as keyof Omit] = count; - } + ] + : undefined, + }; + const [items, total, statusCounts] = await Promise.all([ + this.listClientSignatures(tenantId, undefined, { ...query, page, pageSize }), + this.prisma.smsSignature.count({ where: filteredWhere }), + this.prisma.smsSignature.groupBy({ + by: ['auditStatus'], + where: { tenantId, auditStatus: { notIn: ['deleted', 'disabled'] } }, + _count: { _all: true }, + }), + ]); + const summary = { total: 0, pending: 0, approved: 0, rejected: 0, draft: 0 }; + for (const item of statusCounts) { + const count = item._count._all; + summary.total += count; + if (item.auditStatus in summary && item.auditStatus !== 'total') { + summary[item.auditStatus as keyof Omit] = count; } - return { items, summary, total, page, pageSize }; } + return { items, summary, total, page, pageSize }; + } async createSignature(data: CreateSmsSignatureDto, options: CreateSmsSignatureOptions = {}) { - await this.reportValidation.validateSignatureReportValues(data.applicationId, data.drainageInfo); - const drainageInfo = await this.reportValidation.withReportRequirementSnapshot(data.applicationId, data.drainageInfo); - const name = validateCompleteSmsSignature(data.name); - const signature = await this.prisma.smsSignature.create({ - data: { - tenantId: data.tenantId, - applicationId: data.applicationId, - name, - purpose: data.purpose, - auditStatus: options.initialAuditStatus, - drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, - }, - }); - await this.reportValidation.syncSignatureReportValues(signature.id, data.applicationId, drainageInfo); - if (options.initialAuditStatus) { - await this.audit.createAuditRecord({ - tenantId: signature.tenantId, - targetType: 'sms_signature', - targetId: signature.id, - action: 'admin_create_approved', - statusAfter: options.initialAuditStatus, - reason: '运营端新建签名自动审核通过', - }); - } - return signature; - } - - async updateSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) { - const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); - if (!signature || (tenantId && signature.tenantId !== tenantId)) { - throw new NotFoundException('Signature not found'); - } - await this.reportValidation.validateSignatureReportValues(data.applicationId ?? signature.applicationId ?? undefined, data.drainageInfo); - const applicationId = data.applicationId ?? signature.applicationId ?? undefined; - const drainageInfo = data.drainageInfo - ? await this.reportValidation.withReportRequirementSnapshot(applicationId, data.drainageInfo) - : undefined; - const name = data.name === undefined ? undefined : validateCompleteSmsSignature(data.name); - const materialChanged = (data.applicationId !== undefined && data.applicationId !== signature.applicationId) - || (name !== undefined && name !== normalizeSmsSignature(signature.name)) - || (data.purpose !== undefined && data.purpose !== signature.purpose) - || (data.drainageInfo !== undefined && JSON.stringify(data.drainageInfo) !== JSON.stringify(signature.drainageInfo ?? null)); - const auditStatus = materialChanged && signature.auditStatus === 'approved' ? 'pending' : data.auditStatus; - const updated = await this.prisma.smsSignature.update({ - where: { id: signatureId }, - data: { - applicationId: data.applicationId, - name, - purpose: data.purpose, - auditStatus, - rejectReason: auditStatus === 'pending' ? null : undefined, - drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, - materialVersion: { increment: 1 }, - pendingReport: true, - reportChangedAt: new Date(), - }, - include: { materials: true, tenant: true, application: true }, - }); - await this.reportValidation.syncSignatureReportValues(signatureId, updated.applicationId ?? undefined, drainageInfo); - return updated; - } - - async updateClientSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) { - const current = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); - if (!current || (tenantId && current.tenantId !== tenantId)) throw new NotFoundException('Signature not found'); - if (!['draft', 'rejected', 'approved'].includes(current.auditStatus)) { - throw new BadRequestException('当前审核状态不允许修改签名'); - } - const updated = await this.updateSignature(signatureId, { ...data, auditStatus: 'pending' }, tenantId); - await this.audit.createAuditRecord({ - tenantId: current.tenantId, - targetType: 'sms_signature', - targetId: signatureId, - action: 'client_update_submit', - statusBefore: current.auditStatus, - statusAfter: 'pending', - }); - return updated; - } - - createSignatureMaterial(data: CreateSignatureMaterialDto) { - return this.prisma.signatureMaterial.create({ - data: { - signatureId: data.signatureId, - fileObjectId: data.fileObjectId, - materialType: data.materialType, - title: data.title, - description: data.description, - }, - }); - } - - async submitSignature(signatureId: string, tenantId?: string) { - const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); - if (!signature || signature.auditStatus === 'deleted' || (tenantId && signature.tenantId !== tenantId)) { - throw new NotFoundException('Signature not found'); - } - - const updated = await this.prisma.smsSignature.update({ - where: { id: signatureId }, - data: { auditStatus: 'pending', rejectReason: null }, - }); + await this.reportValidation.validateSignatureReportValues(data.applicationId, data.drainageInfo); + const drainageInfo = await this.reportValidation.withReportRequirementSnapshot( + data.applicationId, + data.drainageInfo, + ); + const name = validateCompleteSmsSignature(data.name); + const signature = await this.prisma.smsSignature.create({ + data: { + tenantId: data.tenantId, + applicationId: data.applicationId, + name, + purpose: data.purpose, + auditStatus: options.initialAuditStatus, + drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, + }, + }); + await this.reportValidation.syncSignatureReportValues(signature.id, data.applicationId, drainageInfo); + if (options.initialAuditStatus) { await this.audit.createAuditRecord({ tenantId: signature.tenantId, targetType: 'sms_signature', - targetId: signatureId, - action: 'submit', - statusBefore: signature.auditStatus, - statusAfter: 'pending', + targetId: signature.id, + action: 'admin_create_approved', + statusAfter: options.initialAuditStatus, + reason: '运营端新建签名自动审核通过', }); - return updated; } + return { + ...signature, + reportMaterialChanged: true, + reportPoolAvailableAfter: signature.auditStatus === 'approved' ? ('immediate' as const) : ('approval' as const), + }; + } + + async updateSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) { + const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); + if (!signature || (tenantId && signature.tenantId !== tenantId)) { + throw new NotFoundException('Signature not found'); + } + await this.reportValidation.validateSignatureReportValues( + data.applicationId ?? signature.applicationId ?? undefined, + data.drainageInfo, + ); + const applicationId = data.applicationId ?? signature.applicationId ?? undefined; + const drainageInfo = data.drainageInfo + ? await this.reportValidation.withReportRequirementSnapshot(applicationId, data.drainageInfo) + : undefined; + const name = data.name === undefined ? undefined : validateCompleteSmsSignature(data.name); + const materialChanged = + (data.applicationId !== undefined && data.applicationId !== signature.applicationId) || + (name !== undefined && name !== normalizeSmsSignature(signature.name)) || + (data.purpose !== undefined && data.purpose !== signature.purpose) || + (data.drainageInfo !== undefined && + JSON.stringify(drainageInfo ?? null) !== JSON.stringify(signature.drainageInfo ?? null)); + const auditStatus = materialChanged && signature.auditStatus === 'approved' ? 'pending' : data.auditStatus; + const updated = await this.prisma.smsSignature.update({ + where: { id: signatureId }, + data: { + applicationId: data.applicationId, + name, + purpose: data.purpose, + auditStatus, + rejectReason: auditStatus === 'pending' ? null : undefined, + drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, + materialVersion: materialChanged ? { increment: 1 } : undefined, + pendingReport: materialChanged ? true : undefined, + reportChangedAt: materialChanged ? new Date() : undefined, + }, + include: { materials: true, tenant: true, application: true }, + }); + await this.reportValidation.syncSignatureReportValues( + signatureId, + updated.applicationId ?? undefined, + drainageInfo, + ); + return { + ...updated, + reportMaterialChanged: materialChanged, + reportPoolAvailableAfter: updated.auditStatus === 'approved' ? ('immediate' as const) : ('approval' as const), + }; + } + + async updateClientSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) { + const current = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); + if (!current || (tenantId && current.tenantId !== tenantId)) throw new NotFoundException('Signature not found'); + if (!['draft', 'rejected', 'approved'].includes(current.auditStatus)) { + throw new BadRequestException('当前审核状态不允许修改签名'); + } + const updated = await this.updateSignature(signatureId, { ...data, auditStatus: 'pending' }, tenantId); + await this.audit.createAuditRecord({ + tenantId: current.tenantId, + targetType: 'sms_signature', + targetId: signatureId, + action: 'client_update_submit', + statusBefore: current.auditStatus, + statusAfter: 'pending', + }); + return updated; + } + + createSignatureMaterial(data: CreateSignatureMaterialDto) { + return this.prisma.signatureMaterial.create({ + data: { + signatureId: data.signatureId, + fileObjectId: data.fileObjectId, + materialType: data.materialType, + title: data.title, + description: data.description, + }, + }); + } + + async submitSignature(signatureId: string, tenantId?: string) { + const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); + if (!signature || signature.auditStatus === 'deleted' || (tenantId && signature.tenantId !== tenantId)) { + throw new NotFoundException('Signature not found'); + } + + const updated = await this.prisma.smsSignature.update({ + where: { id: signatureId }, + data: { auditStatus: 'pending', rejectReason: null }, + }); + await this.audit.createAuditRecord({ + tenantId: signature.tenantId, + targetType: 'sms_signature', + targetId: signatureId, + action: 'submit', + statusBefore: signature.auditStatus, + statusAfter: 'pending', + }); + return updated; + } } diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index 3bebc84..a2aad5b 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -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' }], })); }); diff --git a/docs/reporting-batch-import-records-remediation-plan-20260902.md b/docs/reporting-batch-import-records-remediation-plan-20260902.md new file mode 100644 index 0000000..f95be44 --- /dev/null +++ b/docs/reporting-batch-import-records-remediation-plan-20260902.md @@ -0,0 +1,720 @@ +# 报备工作台、签名补资料与报备记录改造方案 + +日期:2026-09-02 +状态:已按确认方案实施,待测试环境验收 +基线:`main / cd824999f3b604155e09fbad1f5c6258cfe1d918` + +## 1. 改造目标 + +本轮保留现有资料进入待生成范围、批次生成、文件处理和通道报备状态模型,将现有“报备任务”重组为“报备工作台”,集中完成八项改造: + +1. 将“报备资料池、报备批次、通道报备明细、状态记录”拆成四个职责清晰的二级菜单。 +2. 重新设计报备批次,在批次列表增加批次详情入口,并在批次内查看、筛选和操作真实报备明细。 +3. 修复已有签名导入时把补资料误处理为整块覆盖的问题,避免未映射字段及用途被意外清空。 +4. 增强状态记录的查询、重置、分页和操作人展示,方便按业务主体和状态变化追溯。 +5. 增加单条通道报备明细的签名资料导出,按目标通道的真实字段配置生成一行报备文件。 +6. 优化“短信通道管理—报备详情”的分页、今日发送排序、查询、状态修改和报备资料查看体验。 +7. 企业签名新增或报备资料真实变化并提交成功后,提示用户前往报备资料池生成批次。 +8. 企业签名列表展示最新材料版本尚待生成批次的签名报备明细数。 + +本轮不改变短信发送、队列、计费、通道路由和供应商协议链路,不发送、补发、重投或重新入队短信。 + +## 2. 已确认的产品决策 + +### 2.1 保留待生成资料池现有逻辑 + +- 不新增资料池级“放弃/恢复”状态。 +- 继续以 `pendingReport=true` 且审核通过作为进入待生成池的主要条件。 +- 继续由现有材料版本、应用状态、应用路由、通道字段和重复批次检查决定是否可生成。 +- 继续保留通道报备明细上的 `abandoned` 状态,不把它扩展为签名全局禁用状态。 +- 本轮不修改新建签名、客户端审核和运营端自动审核的既有流程。 + +### 2.2 保留现有文件能力 + +- 保留 XLSX 解析、MinIO 原文件/图片存储、图片/文件字段映射和通道 XLSX 嵌图能力。 +- 本轮不新增文件格式、文件清理、压缩包、批量下载或回执文件解析能力。 +- 已生成批次详情只展示和下载现有 `ReportExportFile`,不改变文件生成与保存逻辑。 + +### 2.3 已有签名导入视为补资料 + +- 继续使用“企业 + 应用 + 完整签名名称 + 未删除”识别已有签名。 +- 导入确认后仍先进入导入审核批次;审核通过前不修改真实签名。 +- 审核通过已有签名时执行字段级补资料,而不是整块替换。 +- 只有本次导入实际映射且单元格有值的字段参与更新:已有字段覆盖,新字段追加,未导入字段保留。 +- 映射列为空时默认不清空旧值。本轮不增加“显式清空字段”语法;如后续需要,应单独设计清空标记和二次确认。 +- 未映射“用途”时不得写入空字符串,也不得改变原用途。 + +## 3. 报备工作台信息架构 + +### 3.1 菜单与路由 + +原一级菜单“报备任务”更名为“报备工作台”,其下不再使用“待生成资料 / 已生成批次”页签承载不同业务对象,而是拆成四个二级菜单: + +| 二级菜单 | 建议路由 | 页面主对象 | 核心职责 | +| --- | --- | --- | --- | +| 报备资料池 | `/admin/report-materials` | 已审核的签名/引流资料及材料版本 | 判断能否生成批次、选择资料并生成批次、查看各状态明细汇总 | +| 报备批次 | `/admin/report-batches` | `ReportMaterialBatch` | 查看批次进度、通道文件和本批次报备明细 | +| 通道报备明细 | `/admin/report-tasks` | 企业应用 × 签名/引流对象 × 通道 × 运营商 | 查看未报备及已落库任务、批量修改报备状态、进入详情 | +| 状态记录 | `/admin/report-records` | `ChannelSignatureReportRecord` | 查询每次状态变化、原因、入口、操作人和时间轨迹 | + +权限点应按四页职责拆分或复用现有最接近的权限;实施前先核对当前菜单权限表和角色授权。为避免既有收藏和页面跳转失效,现有路由能复用的继续复用;原批次页签链接通过兼容跳转进入新的“报备批次”页面。 + +四个页面之间共享统一的业务标识和跳转条件:资料池可进入关联通道明细,批次可进入本批次明细,明细可进入状态记录,状态记录可回到对应明细。前端不得通过多个列表结果自行拼接关系,关联范围由后端返回的真实标识确定。 + +### 3.2 报备资料池 + +“待生成资料”更名为“报备资料池”。它仍沿用当前资料进入和批次生成规则,但页面不再只表达“有没有生成过文件”,还要呈现资料对应通道报备明细的汇总状态。 + +资料池维度明确为“企业应用 × 签名/引流对象 × 材料版本”,不是“企业应用 × 签名 × 通道”。通道及运营商属于该资料行下面的报备明细维度,由资料池做数量汇总和下钻;如果把通道作为资料池主维度,会与“通道报备明细”页面重复,并造成同一材料版本重复展示。 + +- 资料池主行仍以签名/引流资料及其材料版本为选择和生成单位。 +- 默认视图突出当前可生成批次的资料;可切换查看全部、待生成、已生成、资料不完整等状态,避免生成后对象完全不可追踪。 +- 每行显示关联通道明细总数及未报备、报备中、通过、失败、放弃数量。 +- 通道报备状态发生变化后,资料池汇总随真实明细状态更新;不反向改写签名审核状态或材料版本。 +- `abandoned` 只排除对应的通道报备明细,不把整个签名从资料池永久移除;同一签名的其他通道/运营商明细仍可参与批次。 +- 生成批次时只选择符合现有生成规则且未放弃的通道报备明细,并继续执行材料版本、路由、通道字段及重复生成校验。 + +这一步是页面查询与汇总语义调整,不要求签名创建时提前写入所有通道任务记录。 + +### 3.3 报备批次 + +“已生成批次”从资料页签中拆出,成为独立的批次级主列表,批次明细通过独立入口打开。 + +主列表建议字段: + +| 字段 | 展示内容 | +| --- | --- | +| 报备批次号 | `batchNo`,作为主要识别信息 | +| 生成时间 | 北京时间 | +| 生成范围 | 所选资料数、通道数、文件数 | +| 报备进度 | 报备总数、通过数、成功率 | +| 生成状态 | 生成中、生成完成、部分生成、生成失败 | +| 操作 | “查看批次”主入口 | + +现有逐文件下载链接从主列表移入批次详情,避免一行中出现大量链接并挤压进度信息。 + +### 3.4 批次详情工作台 + +点击“查看批次”打开大尺寸批次详情层。首版沿用平台现有 Modal 体系,不新增独立路由;如果真实批次规模导致弹层不可用,再升级为独立详情页。 + +详情分为三个区域: + +1. 批次摘要 + - 批次号、生成时间、生成状态、所选资料数、通道数、文件数。 + - 报备总数、通过数、成功率。 + - 部分失败或失败时展示真实 `errorMessage`,不得用统一成功提示覆盖。 +2. 通道文件 + - 按通道显示文件名、行数、生成状态和下载入口。 + - 文件对象缺失时显示“文件不可用”,不渲染失效下载按钮。 +3. 报备明细 + - 一行对应一个真实 `ChannelSignatureReportTask`。 + - 展示签名/引流对象、企业、应用、通道、运营商、文件行号、资料版本、当前状态和更新时间。 + - 支持按关键字、报备类型、状态和通道筛选,使用后端分页。 + - 操作包含“查看报备资料”“导出报备资料”(签名明细)和“修改状态”。 + +### 3.5 通道报备明细 + +通道报备明细的业务粒度统一定义为: + +```text +企业应用 × 签名/引流对象 × 通道 × 运营商 +``` + +页面同时展示两类行: + +1. 根据当前有效应用路由计算出的、尚未产生数据库任务的“未报备”明细; +2. 已存在 `ChannelSignatureReportTask` 的报备中、通过、失败、放弃及历史明细。 + +实现采用“查询时补齐、首次业务操作时落库”的方式:未报备明细由后端基于真实签名、应用路由、通道和运营商计算,前端不得自行做笛卡尔积;当用户批量修改状态或生成批次时,再在事务内创建缺失任务。这样可以让未报备项可见、可选、可批量操作,同时避免签名新增或路由变化时提前制造大量无业务动作的任务记录。 + +页面能力包括: + +- 按企业、应用、签名/引流对象、通道、运营商、状态和关键字筛选; +- 单选、跨当前页选择规则明确的批量选择、批量修改报备状态; +- 展示真实任务号;尚未落库的未报备行显示“首次操作后生成”,不得伪造任务号; +- 查看资料字段、材料版本、当前状态及状态轨迹; +- 从批次详情进入时限定当前批次,从资料池进入时限定当前资料对象; +- 状态更新后同步刷新本页、资料池汇总、批次汇总和状态记录。 + +批量操作必须由后端校验权限、租户、通道归属和允许的状态变化,并在同一事务中完成任务创建/更新及状态记录写入。局部失败不得静默吞错,应返回可定位到具体明细的结果。 + +### 3.6 明细详情与状态操作 + +- 复用报备明细页的详情字段、状态轨迹和 `changeReportTaskStatuses` 统一状态接口。 +- 不在批次详情中复制新的报备状态保存逻辑。 +- 修改成功后同时刷新:当前明细、批次通过数、成功率和报备记录。 +- 状态原因保持选填;状态变化继续写 `ChannelSignatureReportRecord`,`sourceEntry` 使用 `report_task`。 +- 企业签名页当前只保留“报备状态”入口;本轮不恢复旧的只读“报备详情”弹窗,避免形成第三套详情实现。 + +### 3.7 API 设计 + +报备资料池改为统一查询接口,返回资料本身、生成资格及通道明细汇总。现有待生成接口可暂时保留供兼容跳转或逐步迁移: + +```text +GET /api/admin/report-materials + ?scope=all|pending|generated|incomplete + &keyword= + &page= + &pageSize= +``` + +通道报备明细新增统一列表查询,响应需区分虚拟未报备行与已落库任务,并返回稳定的业务组合键: + +```text +GET /api/admin/report-details + ?keyword= + &enterpriseId= + &applicationId= + &signatureId= + &channelId= + &carrier= + &status= + &batchId= + &page= + &pageSize= +``` + +批量状态操作复用现有统一状态变更服务;接口须接受任务ID或完整业务组合键,后者在事务内按需创建缺失任务: + +```text +POST /api/admin/report-tasks/status-change +``` + +保留现有批次列表接口,新增批次详情查询: + +```text +GET /api/admin/report-materials/batches/:id +``` + +返回批次摘要及通道文件,不一次性返回全部明细。 + +新增批次明细分页接口: + +```text +GET /api/admin/report-materials/batches/:id/tasks + ?keyword= + &reportType= + &status= + &channelId= + &page= + &pageSize= +``` + +查询关系必须来自: + +```text +ReportMaterialBatch + -> ReportMaterialBatchItem + -> ReportExportFileItem + -> ChannelSignatureReportTask +``` + +不得只按签名ID猜测批次归属,也不得用前端拼接现有多个列表结果冒充批次详情。 + +### 3.8 页面状态 + +批次列表和详情必须分别覆盖: + +- 首次加载; +- 空批次; +- 批次生成中; +- 部分生成; +- 生成失败; +- 文件缺失; +- 明细为空; +- 请求失败; +- 修改状态进行中、成功和失败; +- 历史批次、历史通道级未拆分任务和已删除业务主体的兼容展示。 + +资料池和通道报备明细还必须覆盖:虚拟未报备行、无有效路由、部分通道放弃、状态变更后汇总刷新、批量操作部分失败以及历史任务与当前路由不一致等状态。 + +### 3.9 单条明细签名资料导出 + +“通道报备明细”和“短信通道管理—报备详情”均增加“导出报备资料”操作,首版仅针对单条签名报备明细,不等同于导出当前列表,也不生成新的批次。 + +导出口径如下: + +- 一次导出一个“企业应用 × 签名 × 通道 × 运营商”明细,生成只有一条数据行的 XLSX。 +- 字段只使用目标通道当前启用的签名报备字段,按 `ChannelReportField.sortOrder ASC, createdAt ASC` 排列;表头优先使用 `exportName`,否则使用字段名称。 +- 从普通明细入口导出当前材料版本;从历史批次详情入口导出该批次冻结的材料快照,文件中及下载前均明确显示资料版本,避免把当前资料误当成历史批次资料。 +- 复用现有通道批次导出的字段解析、转换、列宽和图片嵌入能力,不另写一套 XLSX 映射逻辑。 +- 图片继续按现有能力嵌入 XLSX;其他文件类型维持现有文件字段处理规则,本轮不扩展压缩包或附件打包。 +- 导出前执行通道字段和必填资料校验。缺失必填字段时不生成看似可交付的空白文件,返回具体缺失字段,并允许用户先进入“查看报备资料”定位问题。 +- 导出动作写操作日志,记录操作者、租户、签名、通道、运营商、资料版本和结果;它不改变报备状态,不创建报备批次,不触发短信任务,也不把虚拟未报备明细强制落库。 + +为支持尚未落库的未报备明细,接口使用稳定业务组合键而不是只接受任务ID: + +```text +POST /api/admin/report-details/material-export +{ + "signatureId": "...", + "channelId": "...", + "carrier": "mobile|unicom|telecom", + "materialVersion": 3, + "batchItemId": "可选;从历史批次导出时传入" +} +``` + +后端必须重新校验当前用户权限、租户范围、签名与应用归属、通道及运营商组合;不能相信前端提交的企业名称、字段值或文件对象ID。 + +### 3.10 企业签名保存提示与待生成明细数 + +#### 3.10.1 新增或资料变化后的引导弹窗 + +企业签名发生新增、修改或报备资料变化并成功提交后,前端根据后端返回的真实变更结果弹出引导,不使用前端表单脏状态猜测是否已经落库。 + +弹窗主文案为: + +> 资料发生变化,如需提交至通道报备,请到“报备工作台—报备资料池”生成报备批次。 + +交互和边界如下: + +- 提供“稍后处理”和“前往报备资料池”两个操作;前往资料池时自动携带当前企业、应用和签名筛选条件。 +- 如果当前新增或修改仍需审核,补充提示“审核通过后将进入报备资料池”,不得暗示未审核资料已经可以生成批次。 +- 只有签名新增成功,或签名名称、所属应用、用途、签名主体资料、动态报备字段及文件等会影响报备材料或通道目标的内容真实发生变化时提示。 +- 仅打开后未修改、保存失败、后端事务回滚或与报备无关的展示字段未发生变化时不提示。 +- 导入补资料在审核通过并真实应用到已有签名后,也应产生相同的资料变化标识;导入审核提交但尚未应用时不提前提示已可生成。 +- 提示本身不自动生成批次、不创建通道任务、不改变报备状态,也不触发任何短信链路。 + +签名新增/修改接口的成功响应建议增加: + +```text +reportMaterialChanged: boolean +materialVersion: number +pendingReport: boolean +reportPoolAvailableAfter: "immediate" | "approval" +``` + +由后端在提交事务内判断材料是否变化并返回最终材料版本,前端只负责展示与跳转。 + +#### 3.10.2 企业签名列表的待生成报备明细数 + +企业签名列表增加“待生成报备明细数”列。该数字按每个签名的最新材料版本计算,统计尚需进入报备批次的有效签名明细: + +```text +企业应用 × 签名 × 通道 × 运营商 +``` + +计算规则: + +1. 从签名所属应用的当前有效路由获取目标通道,并按通道实际支持的运营商拆分组合。 +2. 只统计当前签名最新材料版本尚未生成对应报备批次的组合;旧版本生成过批次不能抵消新版本的待生成人数。 +3. 排除已删除或停用通道、不支持的运营商组合以及明确为 `abandoned` 的对应通道明细。 +4. 无有效应用路由时数量为 0,同时通过提示说明“暂无有效报备通道”,不能伪造待生成任务。 +5. 尚未审核通过、按现有规则不能进入资料池的签名数量为 0,并展示“审核通过后计算”或等价说明。 +6. 一个组合即使存在多条历史批次或状态记录,也只能计数一次。 +7. 数量必须由后端随签名分页列表批量计算并返回,禁止前端逐行请求或加载全量任务后统计。 + +建议企业签名分页响应每行增加: + +```text +pendingReportDetailCount: number +pendingReportMaterialVersion: number | null +pendingReportBlockedReason: string | null +``` + +点击数量进入“通道报备明细”,自动带入当前签名、最新材料版本及“待生成”范围;页面另提供“前往报备资料池”入口。数量为 0 时不伪装成可点击链接。 + +## 4. 已有签名补资料 Bug 修复 + +### 4.1 当前问题 + +当前导入暂存会把导入得到的动态字段整体写入 `signatureReportValues`。当导入表只包含部分字段时,原有但未导入的字段会丢失;未映射用途时还可能把原用途更新为空字符串。 + +### 4.2 目标合并规则 + +设现有资料为: + +```json +{ + "license": "old-license", + "authorization": "old-authorization" +} +``` + +本次导入只有: + +```json +{ + "license": "new-license", + "contact": "new-contact" +} +``` + +审核通过后的结果必须为: + +```json +{ + "license": "new-license", + "authorization": "old-authorization", + "contact": "new-contact" +} +``` + +具体规则: + +1. 签名名称继续是匹配和校验必填字段,不作为普通补资料字段清空。 +2. `purpose` 只有映射且有值时才更新。 +3. 动态字段只收集已映射且有值的单元格。 +4. 合并顺序为“现有字段在前,本次有效导入字段在后”。 +5. 未映射字段和映射但为空的字段保留原值。 +6. 新增签名仍使用本次导入资料创建,不套用已有对象合并逻辑。 +7. 审核前继续保存 `originalSnapshot`,审核页应能区分新增、覆盖和保留字段。 + +### 4.3 实现调整 + +- 修改 `mappedCoreValue` 或新增可区分“未映射 / 已映射空值 / 已映射有值”的读取函数,避免用空字符串同时表示三种状态。 +- `stageSignatureRow` 生成字段补丁,不生成会清空旧字段的完整替换对象。 +- `applyImportItem` 对已有签名重新读取当前值后合并,避免导入审核等待期间被其他修改覆盖。 +- 保留现有材料版本递增、`pendingReport=true`、审核记录和待生成池逻辑。 +- 本修复不需要 Prisma schema migration。 + +### 4.4 并发与错误边界 + +- 审核应用时重新确认目标签名仍存在且未删除。 +- 如导入审核期间签名名称、应用或资料发生变化,必须基于最新对象合并,不得用旧快照覆盖整份资料。 +- 单行失败只把该导入明细标记为无效,不阻断同批其他行。 +- 不吞并 MinIO 下载、字段校验或数据库更新错误。 + +## 5. 状态记录检索改造 + +### 5.1 查询条件 + +“报备记录”页面更名为“状态记录”,调整为以下服务端组合查询: + +- 通用关键字:报备任务号、批次号、签名、引流 URL/号码、通道、动作和备注; +- 报备类型:签名、引流信息; +- 状态后:未报备、资料待补充、报备中、报备通过、报备失败、放弃报备; +- 动作:创建、生成批次、导出、人工修改、历史回执导入及系统动作; +- 修改入口:企业签名、报备任务、通道信息、系统、历史记录; +- 操作人; +- 记录时间范围。 + +中文状态和动作在前端转换为后端枚举值,不能要求用户输入数据库英文值。 + +### 5.2 列表与详情 + +- 列表补充批次号和操作人;历史无操作人的记录显示“系统/历史”。 +- 保留真实任务号、通道、主体、动作、状态变化、原因和时间。 +- 详情展示当前记录及所属任务的时间顺序状态轨迹;轨迹使用真实记录分页/查询结果,不把单条记录包装成“完整历史”。 +- 从批次详情、报备明细详情进入报备记录时预填批次号或任务号。 + +### 5.3 React 查询状态 + +- 输入条件与已应用条件分离。 +- 点击“查询”后应用条件并回到第一页。 +- 点击“重置”后清空条件、回到第一页并立即请求默认列表。 +- 翻页只使用已应用条件,不读取尚未查询的输入值。 +- 使用请求序号或 AbortController 防止旧请求覆盖新条件结果。 +- 分别展示加载、空数据和失败状态;失败不能保留旧列表并伪装为当前查询结果。 + +### 5.4 API 与索引 + +扩充现有接口: + +```text +GET /api/admin/report-records + ?keyword= + &batchNo= + &reportType= + &statusAfter= + &action= + &sourceEntry= + &operatorKeyword= + &createdAtFrom= + &createdAtTo= + &page= + &pageSize= +``` + +功能实现先使用真实 PostgreSQL 查询。根据真实数据量执行 `EXPLAIN (ANALYZE, BUFFERS)`;若状态、动作、入口或操作人查询出现不可接受的全表扫描,再新增以下组合索引: + +- `(statusAfter, createdAt)`; +- `(action, createdAt)`; +- `(sourceEntry, createdAt)`; +- `(operatorId, createdAt)`。 + +索引属于数据库 migration,只有查询计划证明需要时才纳入,不为小数据量预先增加全部索引。 + +## 6. “短信通道管理—报备详情”页面优化 + +### 6.1 当前问题与改造边界 + +当前页面一次性加载该通道全部报备任务,再在浏览器内按关键词和状态筛选;列表没有真实分页,默认按任务创建时间倒序。“今日发送”统计由另一套非分页查询补充,现有分页任务接口不支持按今日发送量排序。当前“查看详情”直接遍历资料 JSON,不能保证与该通道签名/引流信息字段配置顺序一致。 + +本次改造只调整通道报备管理、查询和资料导出,不改变今日发送数据口径,不发送、补发、重投或重新入队短信。 + +### 6.2 服务端分页与今日发送排序 + +- 页面改为真实服务端分页,默认每页 20 条,允许切换 20/50/100 条。 +- 默认排序为“今日发送条数从大到小”;今日按北京时间 `00:00:00` 至次日 `00:00:00` 计算。 +- “今日发送条数”沿用当前真实提交记录口径,以该明细对应签名、通道及引流对象归集的总尝试条数为准,不把成功数或计费条数替代为发送条数。 +- 排序必须在后端完成后再分页,不能先按创建时间分页,再对当前页做前端排序。 +- 今日发送量相同时依次按任务更新时间倒序、任务ID倒序,保证翻页稳定且不重复、不漏行。 +- 分页响应直接返回每条明细的今日发送统计和上次发送成功时间,页面不得再并行拉取全部任务后自行合并。 +- 历史通道级未拆分任务和没有今日发送记录的任务仍保留,今日发送数按 0 排在后面。 + +建议扩充通道报备明细查询: + +```text +GET /api/admin/channels/:channelId/report-details + ?keyword= + &reportType= + &tenantId= + &applicationId= + &signatureKeyword= + &drainageKeyword= + &carrier= + &status= + &todaySendMin= + &todaySendMax= + &submittedAtFrom= + &submittedAtTo= + &approvedAtFrom= + &approvedAtTo= + &sort=todaySendDesc + &page= + &pageSize= +``` + +先用真实 PostgreSQL 数据检查聚合、排序和分页查询计划。若在真实数据量下需要新索引或汇总结构,须单独说明迁移、写入成本、历史回填和部署风险,不能为了页面排序未经评估增加定时汇总或新表。 + +### 6.3 搜索条件细化 + +基础查询区保留高频条件,高级条件折叠展示,避免所有控件挤在一行: + +- 基础条件:资料类型(签名/引流信息)、签名名称或引流 URL/号码、企业、企业应用、运营商、报备状态; +- 高级条件:今日发送条数区间、提交报备时间、报备通过时间、是否有缺失必填资料; +- 固定通道由当前页面上下文确定,不再重复提供通道选择器; +- 输入态与已应用查询态分离,点击查询后回到第一页;重置后立即加载默认条件; +- 翻页和修改页大小只使用已应用条件,旧请求不得覆盖新查询结果; +- URL 保留已应用筛选和分页参数,便于从其他页面跳入、刷新和返回时保持上下文。 + +后端对各条件执行权限和类型校验,企业、应用和签名必须受当前用户租户范围限制。不能在前端拿到全量任务后做敏感数据筛选。 + +### 6.4 列表信息与操作 + +列表保留通道报备工作的核心信息,并减少单行视觉噪音: + +- 主体:签名或引流 URL/号码、所属企业、企业应用; +- 维度:资料类型、运营商; +- 报备:当前状态、提交时间、通过时间; +- 发送:今日发送总数及成功、未知、回执失败、提交失败概览,上次发送成功时间; +- 操作:“查看报备资料”“导出报备资料”“修改状态”。 + +原“查看详情”统一更名为“查看报备资料”。状态轨迹不再混在资料字段中,需要时从弹层进入独立“状态记录”菜单并带入任务条件。 + +### 6.5 “查看报备资料”弹层 + +点击后展示该通道针对当前报备类型要求的真实资料,而不是无序遍历签名 JSON: + +1. 顶部摘要固定展示企业、应用、签名/引流对象、通道、运营商、资料版本、当前状态及更新时间。 +2. 签名明细按该通道启用的 `signature` 或 `both` 字段配置排序;引流明细按 `drainage` 或 `both` 字段配置排序。 +3. 排序规则固定为 `sortOrder ASC, createdAt ASC`,与通道报备 XLSX 的列顺序一致。 +4. 字段名称优先显示通道配置名称;值从对应资料快照按字段编码解析,并应用与导出一致的取值规则。 +5. 图片显示缩略图并支持查看原图;文件字段显示真实文件名和授权下载入口;对象缺失或无权限时显示明确错误,不渲染失效链接。 +6. 当前通道已取消配置但历史资料仍存在的字段,统一追加在“其他历史资料”区域,保持确定性顺序,不直接丢弃。 +7. 缺失的必填字段明确标红并显示“缺少资料”,普通空字段显示“-”;不能把默认值伪装成用户已提交资料。 +8. 弹层提供“导出报备资料”和“查看状态记录”入口,复用单条导出及状态记录能力。 + +### 6.6 “修改状态”弹层重设计 + +实施前先输出与现有后台设计系统一致的完整桌面及窄屏概念稿。新弹层采用清晰的任务上下文和状态变更结构: + +- 顶部摘要:签名/引流对象、企业应用、通道和运营商,避免用户改错对象; +- 状态区:明显展示“当前状态 → 目标状态”,目标状态使用可读的单选项或状态卡,不使用拥挤的裸下拉框; +- 原因区:保留修改原因输入及字符提示;不擅自改变现有原因必填规则,如后续要求失败/放弃必须填写,应另行确认业务校验; +- 风险提示:对“放弃报备”等影响后续批次生成的状态展示明确说明; +- 操作区:取消与确认层级清晰,保存中禁止重复提交,关闭弹层不保留上一次任务的状态和原因; +- 失败时保留当前输入并显示后端真实错误;成功后关闭弹层并刷新当前页、资料池汇总、批次汇总和状态记录。 + +弹层只复用统一状态变更接口,不新增一套通道专用状态保存逻辑。 + +### 6.7 页面状态与响应式 + +必须验收首次加载、查询加载、空数据、请求失败、分页越界、任务被并发修改、文件缺失、无权限、历史任务及零发送量状态。桌面端保持可扫描的表格;窄屏优先保留主体、状态、今日发送和操作,其余信息进入行详情,不使用横向无限溢出的固定宽表格。 + +## 7. 数据模型与迁移判断 + +| 改造项 | 是否需要数据库迁移 | +| --- | --- | +| 一级菜单更名及四个二级页面拆分 | 否,优先复用现有菜单与权限配置能力 | +| 报备资料池汇总 | 否,基于现有资料、路由和任务查询 | +| 企业签名保存后的资料变化提示 | 否,由现有保存事务返回变化结果和材料版本 | +| 企业签名列表待生成报备明细数 | 默认否,基于现有应用路由、通道运营商、材料版本、批次项和任务状态计算 | +| 虚拟未报备明细及批量状态操作 | 否,首次操作时复用现有任务表按需落库 | +| 报备批次详情 | 否,复用现有批次、文件、明细关联 | +| 单条签名资料导出 | 否,复用现有字段配置、材料快照和 XLSX 导出能力 | +| 通道报备详情分页及今日发送排序 | 默认否;如真实查询计划证明需要索引或汇总结构,则需另行评估 migration | +| 已有签名补资料合并 | 否,修复 JSON 合并和字段读取逻辑 | +| 状态记录新增筛选 | 默认否 | +| 状态记录性能索引 | 视真实查询计划决定,可能需要 | + +本轮不修改 `ChannelSignatureReportTask` 状态集合和发送链路对 `approved` 的判断。 + +## 8. 实施顺序 + +### 阶段一:导入补资料 Bug 修复 + +1. 固化字段合并规则。 +2. 修改导入暂存和审核应用逻辑。 +3. 增加已有签名、未映射用途、部分动态字段和并发修改回归。 +4. 真实 PostgreSQL 事务内验证后回滚验收数据。 + +### 阶段二:报备工作台查询与数据联动 + +1. 增加资料池统一查询和通道明细统一查询,后端计算虚拟未报备行及资料状态汇总。 +2. 为企业签名分页列表批量计算最新材料版本的待生成报备明细数、版本和阻塞原因,避免逐行查询。 +3. 让签名新增、修改和导入补资料应用接口返回材料是否真实变化、最终材料版本及资料池可用时点。 +4. 扩充批量状态接口,使虚拟未报备行在首次操作时按需落库,并保证任务与状态记录事务一致。 +5. 验证状态变化可同步反映到资料池、企业签名计数、批次、通道明细和状态记录。 +6. 固化历史任务与当前路由变化、无路由、待审核和部分放弃的兼容规则。 + +### 阶段三:四菜单页面改造与报备批次重设计 + +1. 先产出与现有运营端设计系统一致的完整桌面/窄屏概念稿,确认四菜单的信息层级、跨页跳转、批次详情、文件区和批量操作。 +2. 将一级菜单改为“报备工作台”,拆分报备资料池、报备批次、通道报备明细和状态记录四个二级页面,并配置权限与兼容跳转。 +3. 在企业签名保存成功后增加资料变化引导弹窗,并在企业签名列表展示待生成报备明细数及下钻入口。 +4. 新增批次详情和批次明细分页 API,实现主列表和批次详情组件。 +5. 复用报备明细详情及统一状态接口,不复制状态保存逻辑。 +6. 完成真实 API、PostgreSQL、MinIO 文件和浏览器交互验收。 + +### 阶段四:单条导出与通道报备详情优化 + +1. 抽取并复用现有通道 XLSX 的字段解析、转换、图片嵌入和必填校验能力,增加单条签名资料导出接口。 +2. 将通道报备列表改为后端分页、后端今日发送聚合排序及稳定次级排序。 +3. 细化服务端搜索条件,完成输入态、已应用条件、分页和 URL 查询状态管理。 +4. 先确认“查看报备资料”和“修改状态”桌面/窄屏概念稿,再实现按通道字段顺序展示的资料弹层及新状态弹层。 +5. 使用真实 PostgreSQL 数据执行查询计划检查,使用真实 MinIO 文件验证图片、附件查看和单条 XLSX 下载。 + +### 阶段五:状态记录检索 + +1. 扩充 API 查询参数和响应映射。 +2. 修复查询、重置、分页及旧请求覆盖。 +3. 增加批次号、状态、入口、操作人查询和展示。 +4. 用真实数据检查查询计划,决定是否增加索引 migration。 + +## 9. 测试与验收 + +### 9.1 导入补资料 + +- 已有字段被新值覆盖。 +- 新字段被追加。 +- 未映射字段保持原值。 +- 映射但空白的字段保持原值。 +- 未映射用途不被清空。 +- 新增签名流程不受影响。 +- 审核前业务表不变,审核通过后才落库。 +- 同批单行失败不影响其他行。 +- 材料版本、待生成标志和审核记录正确。 + +### 9.2 报备资料池与通道报备明细 + +- 报备资料池一行严格对应“企业应用 × 签名/引流对象 × 材料版本”,不因多个通道重复资料主行。 +- 新建签名沿用当前规则进入资料范围,不额外批量预写任务。 +- 有有效路由但尚无任务记录的组合以“未报备”显示。 +- 无有效路由时不虚构通道报备明细,并明确展示不可生成原因。 +- 企业、应用、签名/引流对象、通道、运营商组合键稳定且不重复。 +- 单选和批量修改状态均使用真实后端接口;首次操作只创建一次任务。 +- 通过、失败、放弃等状态变化实时反映到资料池汇总。 +- 放弃只排除对应通道明细,不误伤同资料的其他通道/运营商。 +- 批量操作校验权限和租户,失败项返回具体原因并写入正确状态记录。 +- 历史任务保留可查,不因当前路由取消而丢失。 + +### 9.3 企业签名保存提示与待生成明细数 + +- 新建签名成功、报备相关字段真实修改成功、导入补资料审核通过并应用成功后,返回正确的资料变化标识和材料版本。 +- 未改动直接保存、保存失败、事务回滚及导入仍待审核时不误提示已经可以生成批次。 +- 待审核对象的弹窗明确提示审核通过后进入资料池;审核通过对象可直接带条件跳转资料池。 +- “稍后处理”和“前往报备资料池”行为正确,跳转后企业、应用和签名筛选条件准确。 +- 企业签名列表的计数按最新材料版本及有效“通道 × 运营商”组合计算,不把同一组合重复计数。 +- 新材料版本生成后重新计入,当前版本成功生成对应批次后扣减;放弃明细、无效通道及不支持运营商不计入。 +- 无有效路由、待审核和无待生成明细均返回 0,并展示正确且可区分的阻塞原因。 +- 分页列表由一次后端查询返回每行计数,不产生逐行 API 请求或明显的数据库 N+1 查询。 +- 使用不同租户、角色和分页数据验证计数隔离,不能泄露其他租户的路由或任务数量。 + +### 9.4 单条明细签名资料导出 + +- 当前资料入口导出当前材料版本,历史批次入口导出该批次材料快照。 +- XLSX 只有当前签名明细一条数据行,不混入同签名其他通道或运营商。 +- 列名、列顺序、默认值、转换、列宽及图片尺寸与目标通道字段配置一致。 +- 图片来自真实 MinIO 对象并正确嵌入;对象缺失、格式不支持和下载失败返回明确错误。 +- 缺少通道必填字段时列出缺失项,不生成可被误交付的空白文件。 +- 导出校验租户、权限、通道和运营商,不能通过修改请求参数导出其他租户资料。 +- 导出不创建批次、不改变任务状态、不创建虚拟任务、不触发短信链路,但写入真实操作日志。 + +### 9.5 报备批次 + +- 列表汇总与真实文件项和任务状态一致。 +- 查看批次只返回当前批次明细,不串入同签名其他批次。 +- 通道文件可下载且哈希与 MinIO 对象一致。 +- 批次明细使用真实后端分页和筛选。 +- 批次内修改状态后,任务、记录、通过数和成功率同步。 +- 生成中、部分失败、失败、文件缺失和历史数据正确展示。 + +### 9.6 短信通道管理—报备详情 + +- 总数、页码和每页条数来自真实后端分页,直接访问后续页不依赖前端已有全量数据。 +- 默认按北京时间今日发送总数全局倒序后分页;相同数量时顺序稳定。 +- 抽取不同页样本核对今日发送统计与 `SmsSubmitRecord`、`SmsMessageRecord` 的真实归集结果。 +- 企业、应用、签名/引流对象、运营商、状态、时间及今日发送区间可组合查询。 +- 查询、重置、翻页和页大小变化发送正确参数,快速查询时旧响应不覆盖新结果。 +- “查看报备资料”分别按签名字段配置和引流信息字段配置顺序展示,历史未配置字段进入独立区域。 +- 图片预览、原图/文件下载、缺失对象、空字段和必填字段缺失状态正确。 +- 修改状态弹层展示正确任务上下文和当前状态,防重复提交,失败保留输入,成功刷新所有关联统计与记录。 +- 桌面端及窄屏下表格、筛选区、资料弹层和状态弹层可操作,控制台无新增错误。 + +### 9.7 状态记录 + +- 中文条件正确映射到后端枚举。 +- 任务号、批次号、签名、引流信息、通道、状态、动作、入口和操作人可组合查询。 +- 查询、重置、翻页只发送一次正确请求。 +- 快速连续查询时旧响应不能覆盖新结果。 +- 操作人、修改入口和状态轨迹来自真实 PostgreSQL 数据。 + +### 9.8 菜单、权限与页面联动 + +- 一级菜单显示“报备工作台”,包含且仅包含本方案确定的四个二级入口。 +- 四个入口分别打开独立页面,刷新和直接访问路由均有效。 +- 原有页签或收藏链接通过兼容跳转落到正确页面。 +- 无权限用户不显示入口,直接访问也由后端/路由守卫拒绝。 +- 资料池、批次和通道明细进入状态记录时自动带入正确查询条件。 +- 桌面端及窄屏下菜单、表格、批量操作条和详情弹层可用。 + +### 9.9 门禁 + +- 报备材料、通道报备、短信配置定向单元测试。 +- API 全量回归、TypeScript 正式构建和 Prisma validate。 +- 前端组件测试、TypeScript 检查和 Vite 生产构建。 +- 桌面端及窄屏真实页面检查。 +- 浏览器控制台、加载、空数据、失败、权限和历史数据状态。 +- `git diff --check`、staged diff 精确核对。 + +自动化测试替身只用于隔离回归;最终功能验收必须使用真实 API、PostgreSQL、MinIO 文件和浏览器交互证据。 + +## 10. 发布与回滚边界 + +- 方案确认后默认只修改代码、测试和文档,不提交、不推送、不部署。 +- 如最终没有数据库索引 migration,回滚为前后端代码及文档回退。 +- 如增加索引 migration,发布前必须评估建索引锁和耗时,优先使用适合当前 PostgreSQL 版本的低影响方式;回滚不得删除业务数据。 +- 不修改预生产 fstab、数据盘 UUID、绑定挂载、存储保护脚本或 systemd drop-in。 +- 未经单独授权不访问或修改预生产,不发布测试环境。 + +## 11. 需在实施前锁定的验收口径 + +本方案已采用以下默认口径,如需调整应在编码前修改方案: + +1. 导入补资料时,空白单元格不清空旧值。 +2. 一级菜单使用“报备工作台”,其下固定为“报备资料池、报备批次、通道报备明细、状态记录”四个二级菜单。 +3. 报备资料池保留当前资料进入和批次生成规则,但补充所有相关通道明细的状态汇总,生成后仍可追踪。 +4. 未报备通道明细采用查询时计算、首次业务操作时落库,不在新建签名时批量预写任务。 +5. 报备批次详情首版使用大尺寸弹层,不新增批次详情路由。 +6. 批次主列表不直接铺开全部文件下载链接,文件统一进入批次详情。 +7. 企业签名页不恢复旧的只读报备详情弹窗,批次和通道报备明细共用一套详情能力。 +8. 单条签名资料导出为目标通道格式的一行 XLSX;普通入口导出当前材料版本,历史批次入口导出批次快照。 +9. 短信通道报备详情默认按北京时间今日发送总数全局倒序,排序完成后再分页。 +10. “查看报备资料”严格按当前通道的签名或引流字段配置顺序展示,历史剩余字段放在独立区域。 +11. 修改状态弹层只重做信息层级和交互,不新增状态枚举,不擅自改变原因必填规则。 +12. 企业签名新增或报备资料真实变化并成功提交后显示引导弹窗,目标页面固定为“报备工作台—报备资料池”;待审核对象必须说明审核前不可生成。 +13. 企业签名列表展示的是最新材料版本尚待生成批次的“通道 × 运营商”明细数,不是签名数,也不是历史任务总数。 +14. 状态记录性能索引、今日发送排序及待生成明细计数所需索引均以真实查询计划为准,不预先创建无证据索引或汇总表。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 4ad8df3..4989631 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5003,3 +5003,19 @@ npm run verify:phase8 | TC-ENTERPRISE-SIGNATURE-DENSITY-005 | 查看签名及引流信息操作列 | 两级操作列都只展示“报备状态、编辑、删除”;不显示“报备详情”;三个入口继续调用原真实后端流程 | | TC-ENTERPRISE-SIGNATURE-DENSITY-006 | 1600×1000桌面视口查看并展开首条签名 | 表头与数据列对齐,操作按钮不换行,展开表格无裁切;页面无框架错误层,控制台无相关错误;窄视口由列表容器横向滚动,不挤压错列 | | TC-ENTERPRISE-SIGNATURE-DENSITY-007 | 某一运营商下所有当前目标通道的报备任务均为abandoned | 该签名及其引流信息在对应运营商列汇总为“放弃报备”,同时显示0/总通道数;仅部分通道放弃时不得误判为全部放弃 | + +## TC-REPORT-WORKBENCH-20260902 报备工作台、补资料与单条导出 + +| 用例ID | 场景 | 预期 | +| --- | --- | --- | +| TC-REPORT-WORKBENCH-001 | 打开运营端导航及四个报备页面 | 一级菜单为“报备工作台”,二级菜单依次为“报备资料池、报备批次、通道报备明细、状态记录”;资料池和批次不再共用页签 | +| TC-REPORT-WORKBENCH-002 | 新增审核通过签名或修改报备相关资料 | 后端返回资料变化标识;页面提示到报备资料池生成批次;企业签名列表展示最新版本尚未生成的通道×运营商明细数并可下钻 | +| TC-REPORT-WORKBENCH-003 | 查看存在有效应用路由但尚未生成任务的签名 | 通道报备明细按企业应用×签名×通道×运营商显示虚拟“未报备”行,可单选或多选后通过真实状态接口创建/更新任务并写状态记录 | +| TC-REPORT-WORKBENCH-004 | 将一条通道运营商明细设为放弃报备后预检批次 | 仅该通道运营商组合被排除,其他有效组合仍可生成;不得发送、补发、重投或重新入队短信 | +| TC-REPORT-WORKBENCH-005 | 从资料池选择资料生成批次并打开批次明细 | 批次列表显示真实文件、通道和进度;“打开明细”展示该批次对应任务,可批量修改状态及按签名明细导出 | +| TC-REPORT-WORKBENCH-006 | 从通道报备明细或短信通道报备详情查看资料 | 字段严格按当前通道签名报备字段/引流字段sortOrder排列,历史未配置字段置后;加载失败、文件缺失和必填缺失显示真实错误 | +| TC-REPORT-WORKBENCH-007 | 导出一条签名通道运营商明细 | 后端读取真实签名、通道字段和MinIO对象生成单行XLSX;图片嵌入;不改变任务状态、不生成批次、不触发短信链路,并写操作日志 | +| TC-REPORT-WORKBENCH-008 | 短信通道管理进入报备详情 | 使用后端分页,默认按今日发送条数全量降序后分页;可按关键词、状态、运营商及今日发送区间查询;状态弹窗展示当前上下文和放弃风险 | +| TC-REPORT-WORKBENCH-009 | 导入命中已有签名且用途列未映射或为空 | 识别为补资料;未提供字段保持原值,提供的动态字段覆盖同名值并追加新字段;用途不得被空字符串清空;审核前不改真实签名 | +| TC-REPORT-WORKBENCH-010 | 在状态记录按批次、操作人、状态、入口、对象和时间搜索 | 返回真实状态记录及操作人;可追溯人工修改来源;分页、空数据、失败和历史无入口记录均正确展示 | +| TC-REPORT-WORKBENCH-011 | 桌面及390px窄屏查看四页和状态弹窗 | 桌面表格可扫描;窄屏核心主体、状态、今日发送和操作可访问,无按钮遮挡;控制台无新增错误,所有业务数据来自真实API/PostgreSQL | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 1e0a9ae..61e3f1e 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4271,3 +4271,13 @@ git diff --check - 工作站从测试环境实际下载主资源 `index-BNrNaR05.js`、`index-CMZPlsxo.css` 和企业签名页分块 `AdminEnterpriseSignaturesPage-D_Q97xUB.js`,SHA-256分别为 `f93dd4a0732edd4cad1396dbc67b588b27ef1188912c67fc27f7c297498c160d`、`8c98d139dc3cc1846f1b114ed278f42118b0d65fa83c303d0f12086aa2ed9329`、`bfed281a0849e86edc2d1e7fb2d332b167643139df4effcf8a3d584849c2ca23`,与服务器产物一致。 - Browser插件不在本会话可用技能中,按前端调试流程使用工作区Playwright Chromium对本地最新构建做1600×1000视觉复核。页面身份、非空、无框架错误层、控制台和展开交互通过;可见签名及引流信息的状态+数量、“放弃报备”和上下三角排序。截图使用隔离视觉数据,仅用于设计展示,不冒充测试环境真实API/数据验收;测试环境真实鉴权页面截图未完成。 - 全程未发送、补发或重投短信,未修改余额、客户、通道或签名/引流业务配置。原有修改及未跟踪文件继续保留,未覆盖或夹带。 + +## 2026-09-02 报备工作台、补资料合并与单条签名资料导出 + +- 按已确认方案将原“报备任务”重组为“报备工作台”,拆分为报备资料池、报备批次、通道报备明细、状态记录四个菜单。资料池保持现有审核通过且`pendingReport=true`的进入规则,并展示真实路由展开后的通道明细状态汇总;批次页可打开批次内任务、批量修改状态并下载通道文件。 +- 通道报备明细新增当前有效应用路由下的虚拟未报备签名明细,维度为企业应用×签名×通道×运营商;真实状态修改在后端事务内创建或更新任务并写状态记录。放弃报备只排除对应通道运营商组合,不阻断同一资料的其他组合。 +- 导入命中已有签名时改为补资料合并:只覆盖本次提供的同名字段并保留未提供字段;用途列未映射或为空时不再写入空字符串。导入仍先进入审核批次,审核通过前不修改真实签名。 +- 新增单条签名报备资料详情和XLSX导出接口,按通道报备字段顺序输出并支持真实MinIO图片嵌入;单条导出不创建批次、不修改任务状态、不触发短信链路,并写操作日志。短信通道报备详情改为后端分页、全量今日发送数降序、细化查询条件及按字段顺序查看资料。 +- 企业签名保存结果新增资料变化标识;页面仅在真实报备资料变化后提示前往报备资料池。企业签名列表新增最新资料版本尚待生成的通道×运营商明细数及下钻入口。状态记录增加批次号、操作人、变更后状态和修改入口查询。 +- 本轮未新增数据库迁移,未改变部署架构。定向报备材料12项、签名配置68项和企业签名组件2项通过;全量API 51套587项、前端10文件50项通过,前后端TypeScript、定向ESLint(仅既有Hook依赖警告)、Vite构建、依赖安全、部署契约、结构质量、包体积及`git diff --check`通过。Vite仍只有既有Chart分块超过500kB提示,入口gzip约107.51KiB,符合250KiB预算。 +- 发布边界仅为测试环境`100.93.204.60`,不推送远端、不访问预生产、不发送/补发/重投短信、不修改余额、通道或客户配置。测试机健康接口和SSH端口已恢复可达;部署结果、恢复资产、运行标记、服务/Stream/日志及真实页面验收在完成测试机认证后补记。 diff --git a/src/api/admin/channels-reports.api.ts b/src/api/admin/channels-reports.api.ts index 4cfdfa9..04bf2d7 100644 --- a/src/api/admin/channels-reports.api.ts +++ b/src/api/admin/channels-reports.api.ts @@ -1,5 +1,29 @@ import { request, requestBlob, requestForm, withQuery } from '../core/httpClient'; -import type { AdminChannel, ChannelConnectionLogResponse, ChannelGroup, ChannelGroupDeletionImpact, ChannelReportField, ChannelTestResponse, CmppConnectionState, DeleteTargetRequest, DeletionPreflight, DeletionResult, DeletionTargetType, DictionaryItem, PagedResult, ReportImportMapping, ReportImportProfile, ReportImportReviewBatch, ReportMaterialBatch, ReportMaterialBatchPreflight, ReportMaterialBatchResult, ReportMaterialPendingItem, ReportRecord, ReportTask } from '../types'; +import type { + AdminChannel, + ChannelConnectionLogResponse, + ChannelGroup, + ChannelGroupDeletionImpact, + ChannelReportField, + ChannelTestResponse, + CmppConnectionState, + DeleteTargetRequest, + DeletionPreflight, + DeletionResult, + DeletionTargetType, + DictionaryItem, + PagedResult, + ReportImportMapping, + ReportImportProfile, + ReportImportReviewBatch, + ReportMaterialBatch, + ReportMaterialBatchPreflight, + ReportMaterialBatchResult, + ReportMaterialPendingItem, + ReportRecord, + ReportTask, + SingleReportMaterialDetail, +} from '../types'; import { assertUploadFileSize } from '@/utils/fileUpload'; // Report generation consumes channel report fields, so these endpoints keep one @@ -8,88 +32,354 @@ export const adminChannelsReportsApi = { listChannels: () => request('/admin/channels'), listChannelsPage: (query: { keyword?: string; carrier?: string; status?: string; page: number; pageSize: number }) => request>(withQuery('/admin/channels', query)), - createChannel: (body: Partial & { passwordCipher?: string; desiredConnections?: number; windowSize?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number }) => - request('/admin/channels', { method: 'POST', body: JSON.stringify(body) }), - updateChannel: (id: string, body: Partial & { passwordCipher?: string; desiredConnections?: number; windowSize?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number }) => - request(`/admin/channels/${id}`, { method: 'PUT', body: JSON.stringify(body) }), - copyChannel: (id: string, body: { operatorId?: string } = {}) => request(`/admin/channels/${id}/copy`, { - method: 'POST', - body: JSON.stringify(body), - }), - testChannel: (id: string, body: { phoneNumber?: string; phones?: string[] | string; content: string; accessNo?: string }) => - request(`/admin/channels/${id}/test`, { method: 'POST', body: JSON.stringify(body) }), - changeChannelStatus: (id: string, status: string, reason?: string) => request(`/admin/channels/${id}/status`, { - method: 'POST', - body: JSON.stringify({ status, reason }), - }), - deleteChannel: (id: string, reason?: string) => request(`/admin/channels/${id}`, { - method: 'DELETE', - body: JSON.stringify({ reason }), - }), + createChannel: ( + body: Partial & { + passwordCipher?: string; + desiredConnections?: number; + windowSize?: number; + heartbeatIntervalSeconds?: number; + heartbeatMissThreshold?: number; + }, + ) => request('/admin/channels', { method: 'POST', body: JSON.stringify(body) }), + updateChannel: ( + id: string, + body: Partial & { + passwordCipher?: string; + desiredConnections?: number; + windowSize?: number; + heartbeatIntervalSeconds?: number; + heartbeatMissThreshold?: number; + }, + ) => request(`/admin/channels/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + copyChannel: (id: string, body: { operatorId?: string } = {}) => + request(`/admin/channels/${id}/copy`, { + method: 'POST', + body: JSON.stringify(body), + }), + testChannel: ( + id: string, + body: { phoneNumber?: string; phones?: string[] | string; content: string; accessNo?: string }, + ) => request(`/admin/channels/${id}/test`, { method: 'POST', body: JSON.stringify(body) }), + changeChannelStatus: (id: string, status: string, reason?: string) => + request(`/admin/channels/${id}/status`, { + method: 'POST', + body: JSON.stringify({ status, reason }), + }), + deleteChannel: (id: string, reason?: string) => + request(`/admin/channels/${id}`, { + method: 'DELETE', + body: JSON.stringify({ reason }), + }), getDeletionPreflight: (type: DeletionTargetType, id: string) => request(`/admin/deletions/${type}/${id}/preflight`), deleteGovernedTarget: (type: DeletionTargetType, id: string, body: DeleteTargetRequest) => request(`/admin/deletions/${type}/${id}`, { method: 'POST', body: JSON.stringify(body) }), - listChannelConnectionLogs: (id: string) => request(`/admin/channels/${id}/connection-logs`), + listChannelConnectionLogs: (id: string) => + request(`/admin/channels/${id}/connection-logs`), listChannelGroups: () => request('/admin/channel-groups'), - createChannelGroup: (body: { code: string; name: string; carrier: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number }) => - request('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }), - updateChannelGroup: (id: string, body: { code?: string; name?: string; carrier?: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number; items?: Array> }) => - request(`/admin/channel-groups/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + createChannelGroup: (body: { + code: string; + name: string; + carrier: 'mobile' | 'unicom' | 'telecom'; + description?: string; + status?: string; + retryEnabled?: boolean; + retryTimeLimitHours?: number; + retryTimeLimitMinutes?: number; + }) => request('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }), + updateChannelGroup: ( + id: string, + body: { + code?: string; + name?: string; + carrier?: 'mobile' | 'unicom' | 'telecom'; + description?: string; + status?: string; + retryEnabled?: boolean; + retryTimeLimitHours?: number; + retryTimeLimitMinutes?: number; + items?: Array>; + }, + ) => request(`/admin/channel-groups/${id}`, { method: 'PUT', body: JSON.stringify(body) }), getChannelGroupDeletionImpact: (id: string) => request(`/admin/channel-groups/${id}/deletion-impact`), - deleteChannelGroup: (id: string) => - request(`/admin/channel-groups/${id}`, { method: 'DELETE' }), + deleteChannelGroup: (id: string) => request(`/admin/channel-groups/${id}`, { method: 'DELETE' }), addChannelGroupItem: (body: Record) => request('/admin/channel-groups/items', { method: 'POST', body: JSON.stringify(body) }), listChannelRouteRules: () => request('/admin/channel-route-rules'), - createChannelRouteRule: (body: { tenantId?: string; applicationId: string; groupId: string; carrier: string; priority?: number; status?: string }) => - request('/admin/channel-route-rules', { method: 'POST', body: JSON.stringify(body) }), + createChannelRouteRule: (body: { + tenantId?: string; + applicationId: string; + groupId: string; + carrier: string; + priority?: number; + status?: string; + }) => request('/admin/channel-route-rules', { method: 'POST', body: JSON.stringify(body) }), listChannelConnections: (id: string) => request(`/admin/channels/${id}/connections`), - replaceApplicationRouteRules: (applicationId: string, body: { routes: Array<{ carrier: 'mobile' | 'unicom' | 'telecom'; groupId: string; priority?: number; status?: string }> }) => - request(`/admin/enterprise-applications/${applicationId}/route-rules`, { method: 'PUT', body: JSON.stringify(body) }), - listChannelReportFields: (channelId?: string) => request(withQuery('/admin/channel-report-fields', { channelId })), + replaceApplicationRouteRules: ( + applicationId: string, + body: { + routes: Array<{ carrier: 'mobile' | 'unicom' | 'telecom'; groupId: string; priority?: number; status?: string }>; + }, + ) => + request(`/admin/enterprise-applications/${applicationId}/route-rules`, { + method: 'PUT', + body: JSON.stringify(body), + }), + listChannelReportFields: (channelId?: string) => + request(withQuery('/admin/channel-report-fields', { channelId })), createChannelReportField: (body: Record) => request('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }), - replaceChannelReportFields: (channelId: string, reportType: 'signature' | 'drainage', fields: Array>) => - request(`/admin/channels/${channelId}/report-fields/${reportType}`, { method: 'PUT', body: JSON.stringify({ fields }) }), - listPendingReportMaterials: (query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string; keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}) => - request>(withQuery('/admin/report-materials/pending', query)), + replaceChannelReportFields: ( + channelId: string, + reportType: 'signature' | 'drainage', + fields: Array>, + ) => + request(`/admin/channels/${channelId}/report-fields/${reportType}`, { + method: 'PUT', + body: JSON.stringify({ fields }), + }), + listPendingReportMaterials: ( + query: { + reportType?: 'signature' | 'drainage'; + tenantId?: string; + applicationId?: string; + keyword?: string; + startAt?: string; + endAt?: string; + page?: number; + pageSize?: number; + } = {}, + ) => request>(withQuery('/admin/report-materials/pending', query)), listReportImportProfiles: (reportType?: 'signature' | 'drainage') => request(withQuery('/admin/report-materials/import-profiles', { reportType })), saveReportImportProfile: (body: Omit & { id?: string }) => - request('/admin/report-materials/import-profiles', { method: 'POST', body: JSON.stringify(body) }), - analyzeReportMaterialImport: (file: File, body: { tenantId: string; applicationId?: string; reportType: 'signature' | 'drainage'; sheetName?: string; headerRowCount?: number; dataStartRow?: number; profileId?: string }) => { + request('/admin/report-materials/import-profiles', { + method: 'POST', + body: JSON.stringify(body), + }), + analyzeReportMaterialImport: ( + file: File, + body: { + tenantId: string; + applicationId?: string; + reportType: 'signature' | 'drainage'; + sheetName?: string; + headerRowCount?: number; + dataStartRow?: number; + profileId?: string; + }, + ) => { assertUploadFileSize(file); const form = new FormData(); form.set('file', file); - Object.entries(body).forEach(([key, value]) => { if (value !== undefined) form.set(key, String(value)); }); - return requestForm & { id: string; columns: Array<{ sourceColumnIndex: number; columnLetter: string; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>; rows: Array>; suggestedMappings: ReportImportMapping[] }>('/admin/report-materials/imports/analyze', form); + Object.entries(body).forEach(([key, value]) => { + if (value !== undefined) form.set(key, String(value)); + }); + return requestForm< + Record & { + id: string; + columns: Array<{ + sourceColumnIndex: number; + columnLetter: string; + sourceHeader: string; + sourceHeaderPath: string; + imageCount: number; + }>; + rows: Array>; + suggestedMappings: ReportImportMapping[]; + } + >('/admin/report-materials/imports/analyze', form); }, - commitReportMaterialImport: (id: string, body: { mappings: ReportImportMapping[]; profile?: Omit & { id?: string } }) => - request>(`/admin/report-materials/imports/${id}/commit`, { method: 'PUT', body: JSON.stringify(body) }), - listReportImportReviewBatches: (query: { reportType?: 'signature' | 'drainage'; status?: string; keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}) => + commitReportMaterialImport: ( + id: string, + body: { mappings: ReportImportMapping[]; profile?: Omit & { id?: string } }, + ) => + request>(`/admin/report-materials/imports/${id}/commit`, { + method: 'PUT', + body: JSON.stringify(body), + }), + listReportImportReviewBatches: ( + query: { + reportType?: 'signature' | 'drainage'; + status?: string; + keyword?: string; + startAt?: string; + endAt?: string; + page?: number; + pageSize?: number; + } = {}, + ) => request>(withQuery('/admin/report-materials/imports/review-batches', query)), - reviewReportImportItems: (id: string, body: { decision: 'approve' | 'reject'; itemIds?: string[]; reason?: string }) => - request<{ batchId: string; status: string; approvedCount: number; rejectedCount: number; failedCount: number }>(`/admin/report-materials/imports/${id}/review`, { method: 'POST', body: JSON.stringify(body) }), - listReportMaterialBatches: (query: { keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}) => - request>(withQuery('/admin/report-materials/batches', query)), - preflightReportMaterialBatch: (body: { items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion?: number }> }) => - request('/admin/report-materials/batches/preflight', { method: 'POST', body: JSON.stringify(body) }), - createReportMaterialBatch: (body: { idempotencyKey: string; items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion: number }> }) => - request('/admin/report-materials/batches', { method: 'POST', body: JSON.stringify(body) }), - listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {}) => request(withQuery('/admin/report-tasks', query)), - listReportTasksPage: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage'; keyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) => - request>(withQuery('/admin/report-tasks', query)), - createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; carrier?: 'mobile' | 'unicom' | 'telecom'; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) => - request('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }), - changeReportTaskStatuses: (body: { items: Array<{ signatureId: string; channelId: string; carrier?: 'mobile' | 'unicom' | 'telecom'; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>; reason?: string; operatorId?: string; sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report' }) => - request }>>('/admin/report-tasks/status-change', { method: 'POST', body: JSON.stringify(body) }), + reviewReportImportItems: ( + id: string, + body: { decision: 'approve' | 'reject'; itemIds?: string[]; reason?: string }, + ) => + request<{ batchId: string; status: string; approvedCount: number; rejectedCount: number; failedCount: number }>( + `/admin/report-materials/imports/${id}/review`, + { method: 'POST', body: JSON.stringify(body) }, + ), + listReportMaterialBatches: ( + query: { keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}, + ) => request>(withQuery('/admin/report-materials/batches', query)), + getReportMaterialBatch: (id: string) => request(`/admin/report-materials/batches/${id}`), + listReportMaterialBatchTasks: ( + id: string, + query: { + keyword?: string; + reportType?: 'signature' | 'drainage'; + status?: string; + channelId?: string; + page?: number; + pageSize?: number; + } = {}, + ) => request>(withQuery(`/admin/report-materials/batches/${id}/tasks`, query)), + preflightReportMaterialBatch: (body: { + items: Array<{ + reportType: 'signature' | 'drainage'; + signatureId: string; + drainageItemId?: string; + materialVersion?: number; + }>; + }) => + request('/admin/report-materials/batches/preflight', { + method: 'POST', + body: JSON.stringify(body), + }), + createReportMaterialBatch: (body: { + idempotencyKey: string; + items: Array<{ + reportType: 'signature' | 'drainage'; + signatureId: string; + drainageItemId?: string; + materialVersion: number; + }>; + }) => + request('/admin/report-materials/batches', { + method: 'POST', + body: JSON.stringify(body), + }), + listReportTasks: ( + query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {}, + ) => request(withQuery('/admin/report-tasks', query)), + listReportTasksPage: (query: { + tenantId?: string; + applicationId?: string; + status?: string; + channelId?: string; + reportType?: 'signature' | 'drainage'; + keyword?: string; + carrier?: string; + todaySendMin?: number; + todaySendMax?: number; + sort?: string; + createdAtFrom?: string; + createdAtTo?: string; + page: number; + pageSize: number; + }) => request>(withQuery('/admin/report-tasks', query)), + listReportDetailsPage: (query: { + tenantId?: string; + applicationId?: string; + signatureId?: string; + channelId?: string; + carrier?: string; + status?: string; + reportType?: 'signature' | 'drainage'; + keyword?: string; + createdAtFrom?: string; + createdAtTo?: string; + page: number; + pageSize: number; + }) => request>(withQuery('/admin/report-details', query)), + getSingleReportMaterialDetail: (body: { + reportType?: 'signature' | 'drainage'; + signatureId: string; + channelId: string; + carrier?: 'mobile' | 'unicom' | 'telecom'; + drainageItemId?: string; + batchItemId?: string; + }) => + request('/admin/report-materials/single-detail', { + method: 'POST', + body: JSON.stringify(body), + }), + exportSingleReportMaterial: (body: { + reportType?: 'signature' | 'drainage'; + signatureId: string; + channelId: string; + carrier?: 'mobile' | 'unicom' | 'telecom'; + drainageItemId?: string; + batchItemId?: string; + }) => requestBlob('/admin/report-materials/single-export', { method: 'POST', body: JSON.stringify(body) }), + createReportTask: (body: { + tenantId: string; + signatureId: string; + channelId: string; + carrier?: 'mobile' | 'unicom' | 'telecom'; + reportType?: 'signature' | 'drainage'; + drainageItemId?: string; + createdById?: string; + }) => request('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }), + changeReportTaskStatuses: (body: { + items: Array<{ + signatureId: string; + channelId: string; + carrier?: 'mobile' | 'unicom' | 'telecom'; + status: string; + reportType?: 'signature' | 'drainage'; + drainageItemId?: string; + }>; + reason?: string; + operatorId?: string; + sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report'; + }) => + request< + Array<{ + signatureId: string; + reportStatus: string; + carrierReportSummary: Record; + }> + >('/admin/report-tasks/status-change', { method: 'POST', body: JSON.stringify(body) }), createReportExport: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number }) => - request>(`/admin/report-tasks/${id}/export`, { method: 'POST', body: JSON.stringify(body) }), - importReportReceipt: (id: string, body: { fileObjectId?: string; fileName: string; fileContent?: string; delimiter?: ',' | '\t'; rowCount?: number; successCount?: number; failedCount?: number; statusAfter?: string; reason?: string; result?: Record }) => - request>(`/admin/report-tasks/${id}/receipt-import`, { method: 'POST', body: JSON.stringify(body) }), - listReportRecords: (query: { taskId?: string; channelId?: string } = {}) => request(withQuery('/admin/report-records', query)), - listReportRecordsPage: (query: { taskId?: string; channelId?: string; keyword?: string; reportType?: 'signature' | 'drainage'; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) => - request>(withQuery('/admin/report-records', query)), + request>(`/admin/report-tasks/${id}/export`, { + method: 'POST', + body: JSON.stringify(body), + }), + importReportReceipt: ( + id: string, + body: { + fileObjectId?: string; + fileName: string; + fileContent?: string; + delimiter?: ',' | '\t'; + rowCount?: number; + successCount?: number; + failedCount?: number; + statusAfter?: string; + reason?: string; + result?: Record; + }, + ) => + request>(`/admin/report-tasks/${id}/receipt-import`, { + method: 'POST', + body: JSON.stringify(body), + }), + listReportRecords: (query: { taskId?: string; channelId?: string } = {}) => + request(withQuery('/admin/report-records', query)), + listReportRecordsPage: (query: { + taskId?: string; + channelId?: string; + batchNo?: string; + statusAfter?: string; + action?: string; + sourceEntry?: string; + operatorKeyword?: string; + keyword?: string; + reportType?: 'signature' | 'drainage'; + createdAtFrom?: string; + createdAtTo?: string; + page: number; + pageSize: number; + }) => request>(withQuery('/admin/report-records', query)), }; diff --git a/src/api/types/channels-reports.ts b/src/api/types/channels-reports.ts index 0d5f3ff..7a271a0 100644 --- a/src/api/types/channels-reports.ts +++ b/src/api/types/channels-reports.ts @@ -18,7 +18,15 @@ export type AdminChannel = { rateLimitPerSecond: number; unitPrice: number; status: string; - config?: { desiredConnections?: number; windowSize?: number; extensionDigits?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number; longMessageReceiptMode?: 'per_segment' | 'message_level'; [key: string]: unknown } | null; + config?: { + desiredConnections?: number; + windowSize?: number; + extensionDigits?: number; + heartbeatIntervalSeconds?: number; + heartbeatMissThreshold?: number; + longMessageReceiptMode?: 'per_segment' | 'message_level'; + [key: string]: unknown; + } | null; connectionStates?: CmppConnectionState[]; }; @@ -118,6 +126,14 @@ export type ReportMaterialPendingItem = { signatureName?: string; tenant?: TenantOption; application?: ClientSmsApplication | null; + statusSummary?: { + total: number; + pending: number; + reporting: number; + approved: number; + failed: number; + abandoned: number; + }; }; export type ReportMaterialBatch = { @@ -132,7 +148,47 @@ export type ReportMaterialBatch = { successRate: number; createdAt: string; completedAt?: string | null; - exportFiles: Array<{ id: string; fileObjectId?: string | null; fileName: string; rowCount: number; channelId?: string | null }>; + exportFiles: Array<{ + id: string; + fileObjectId?: string | null; + fileName: string; + rowCount: number; + channelId?: string | null; + }>; + items?: Array<{ + id: string; + reportType: 'signature' | 'drainage'; + signatureId: string; + drainageItemId?: string | null; + materialVersion: number; + status: string; + errorMessage?: string | null; + }>; +}; + +export type SingleReportMaterialDetail = { + reportType: 'signature' | 'drainage'; + signatureId: string; + signatureName: string; + tenant: { id: string; name: string }; + application?: { id: string; name: string } | null; + channel: { id: string; name: string; code: string }; + carrier?: 'mobile' | 'unicom' | 'telecom' | null; + materialVersion: number; + batchItemId?: string | null; + fields: Array<{ + id: string; + code: string; + name: string; + exportName?: string | null; + fieldType: string; + required: boolean; + value: unknown; + submitted: boolean; + missing: boolean; + }>; + historicalFields: Array<{ code: string; name: string; value: unknown }>; + missingFields: string[]; }; export type ReportImportReviewItem = { @@ -245,7 +301,16 @@ export type ApplicationReportField = { description?: string | null; reportTypes: string[]; commonReportTypes?: Array<'signature' | 'drainage'>; - channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: 'signature' | 'drainage' | 'both'; source?: 'common' | 'channel' | 'both' }>; + channels: Array<{ + id: string; + code: string; + name: string; + groupId: string; + groupName: string; + required: boolean; + reportType: 'signature' | 'drainage' | 'both'; + source?: 'common' | 'channel' | 'both'; + }>; }; export type ClientApplicationReportField = Omit; @@ -259,6 +324,7 @@ export type CommonReportField = DictionaryItem & { }; export type ReportTask = DictionaryItem & { + virtual?: boolean; tenantId: string; signatureId: string; channelId: string; @@ -277,14 +343,26 @@ export type ReportTask = DictionaryItem & { application?: { id: string; name: string } | null; }; drainageInfo?: SmsDrainageInfo | null; - channel?: { id: string; name: string; code: string; carrier?: string | null; carriers?: Array<'mobile' | 'unicom' | 'telecom'> }; + channel?: { + id: string; + name: string; + code: string; + carrier?: string | null; + carriers?: Array<'mobile' | 'unicom' | 'telecom'>; + }; reason?: string | null; createdAt?: string; updatedAt?: string; exportItems?: Array<{ id: string; rowNumber: number; - exportFile: { id: string; fileObjectId?: string | null; fileName: string; rowCount: number; batchId?: string | null }; + exportFile: { + id: string; + fileObjectId?: string | null; + fileName: string; + rowCount: number; + batchId?: string | null; + }; batchItem: { id: string; materialVersion: number; batch: { id: string; batchNo: string; createdAt: string } }; }>; records?: Array<{ @@ -320,4 +398,5 @@ export type ReportRecord = DictionaryItem & { sourceEntry?: 'system' | 'legacy' | 'enterprise_signature' | 'report_task' | 'channel_report'; channel?: AdminChannel; task?: ReportTask; + operator?: { id: string; username: string; displayName: string }; }; diff --git a/src/api/types/identity-config.ts b/src/api/types/identity-config.ts index cd83976..f4caeed 100644 --- a/src/api/types/identity-config.ts +++ b/src/api/types/identity-config.ts @@ -138,12 +138,29 @@ export type PendingAuditCounts = { export type DashboardResponse = { taskCount: number; - messageStatus: Array<{ status: string; _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } }>; - today: { sent: number; delivered: number; failed: number; unknown: number; successRate: number; spendCents: number; returnedCents: number; billingUnits: number }; + messageStatus: Array<{ + status: string; + _count: { _all: number }; + _sum: { amountCents?: number | null; billingUnits?: number | null }; + }>; + today: { + sent: number; + delivered: number; + failed: number; + unknown: number; + successRate: number; + spendCents: number; + returnedCents: number; + billingUnits: number; + }; uplinkCount: number; billing: { _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } }; transactions: { _count: { _all: number }; _sum: { amountCents?: number | null } }; - gatewayConnections: Array<{ status: string; _count: { _all: number }; _sum: { currentConnections?: number | null; desiredConnections?: number | null } }>; + gatewayConnections: Array<{ + status: string; + _count: { _all: number }; + _sum: { currentConnections?: number | null; desiredConnections?: number | null }; + }>; pendingAuditCount: number; pendingAudits: PendingAuditCounts; hourlySendTrend: Array<{ hour: number; label: string; submittedCount: number; successCount: number }>; @@ -157,8 +174,21 @@ export type DashboardResponse = { recentFailed: number; alertCount: number; }; - accounts: Array<{ id: string; tenantId: string; balanceCents: number; creditCents: number; status: string; tenant?: TenantOption }>; - enterpriseSpendRanks: Array<{ tenantId: string; tenantName: string; todaySpendCents: number; balanceCents: number; creditCents: number }>; + accounts: Array<{ + id: string; + tenantId: string; + balanceCents: number; + creditCents: number; + status: string; + tenant?: TenantOption; + }>; + enterpriseSpendRanks: Array<{ + tenantId: string; + tenantName: string; + todaySpendCents: number; + balanceCents: number; + creditCents: number; + }>; recentTasks: Array>; recentRecharges: Array; clientOverview?: { @@ -239,34 +269,71 @@ export type ClientSmsSignature = { tenant?: TenantOption; application?: ClientSmsApplication | null; reportStatus?: string; + materialVersion?: number; + pendingReport?: boolean; + reportChangedAt?: string; + reportMaterialChanged?: boolean; + reportPoolAvailableAfter?: 'immediate' | 'approval'; + pendingReportDetailCount?: number; + pendingReportMaterialVersion?: number | null; + pendingReportBlockedReason?: string | null; reportTasks?: Array; - reportTargets?: Array<{ channel: AdminChannel; channelId: string; carrier: 'mobile' | 'unicom' | 'telecom'; status: string; taskId?: string; approvedAt?: string | null; approvalScope?: 'carrier_specific' | 'legacy_channel' }>; + reportTargets?: Array<{ + channel: AdminChannel; + channelId: string; + carrier: 'mobile' | 'unicom' | 'telecom'; + status: string; + taskId?: string; + approvedAt?: string | null; + approvalScope?: 'carrier_specific' | 'legacy_channel'; + }>; carrierReportSummary?: Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>; - drainageReportTargets?: Record>; - drainageCarrierReportSummary?: Record>; + drainageReportTargets?: Record< + string, + Array<{ channel: AdminChannel; channelId: string; status: string; taskId?: string }> + >; + drainageCarrierReportSummary?: Record< + string, + Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }> + >; }; -export type ClientSmsSignatureView = Pick & { pendingReport?: boolean; reportChangedAt?: string; application?: Pick | null; submittedMaterialCount: number; reportValues: Record; - drainageInfo: { links: Array<{ - id: string; - siteName: string; - url: string; - remark?: string | null; - reportValues: Record; - auditStatus: string; - rejectReason?: string | null; - submittedAt: string; - reviewedAt?: string | null; - createdAt: string; - updatedAt: string; - }> }; + drainageInfo: { + links: Array<{ + id: string; + siteName: string; + url: string; + remark?: string | null; + reportValues: Record; + auditStatus: string; + rejectReason?: string | null; + submittedAt: string; + reviewedAt?: string | null; + createdAt: string; + updatedAt: string; + }>; + }; }; export type ClientSignatureWorkspace = { @@ -394,15 +461,64 @@ export type HttpApiConfig = { allowClientTest: boolean; }; -export type HttpApiConfigResponse = { applicationId: string; applicationName?: string; publicOrigin?: string; config: HttpApiConfig | null; ipAllowlist: string[] }; +export type HttpApiConfigResponse = { + applicationId: string; + applicationName?: string; + publicOrigin?: string; + config: HttpApiConfig | null; + ipAllowlist: string[]; +}; -export type HttpApiCredential = { id: string; name: string; accessKey: string; secretLast4: string; secret?: string; secretShownOnce?: boolean; status: string; expiresAt?: string | null; lastUsedAt?: string | null; lastUsedIp?: string | null; createdAt: string }; +export type HttpApiCredential = { + id: string; + name: string; + accessKey: string; + secretLast4: string; + secret?: string; + secretShownOnce?: boolean; + status: string; + expiresAt?: string | null; + lastUsedAt?: string | null; + lastUsedIp?: string | null; + createdAt: string; +}; -export type HttpWebhookEndpoint = { id: string; eventType: 'receipt' | 'uplink'; url: string; secretLast4: string; secret?: string; secretShownOnce?: boolean; status: string; lastTestAt?: string | null; lastTestStatus?: string | null; updatedAt: string }; +export type HttpWebhookEndpoint = { + id: string; + eventType: 'receipt' | 'uplink'; + url: string; + secretLast4: string; + secret?: string; + secretShownOnce?: boolean; + status: string; + lastTestAt?: string | null; + lastTestStatus?: string | null; + updatedAt: string; +}; -export type HttpApiRequestLog = { id: string; requestId: string; clientMessageId?: string | null; sourceIp?: string | null; httpStatus?: number | null; businessCode?: string | null; status: string; durationMs?: number | null; createdAt: string; completedAt?: string | null }; +export type HttpApiRequestLog = { + id: string; + requestId: string; + clientMessageId?: string | null; + sourceIp?: string | null; + httpStatus?: number | null; + businessCode?: string | null; + status: string; + durationMs?: number | null; + createdAt: string; + completedAt?: string | null; +}; -export type HttpWebhookDelivery = { id: string; status: string; attemptCount: number; lastHttpStatus?: number | null; lastError?: string | null; createdAt: string; event: { eventId: string; eventType: string; messageId?: string | null }; endpoint: { eventType: string; url: string } }; +export type HttpWebhookDelivery = { + id: string; + status: string; + attemptCount: number; + lastHttpStatus?: number | null; + lastError?: string | null; + createdAt: string; + event: { eventId: string; eventType: string; messageId?: string | null }; + endpoint: { eventType: string; url: string }; +}; export type EnterpriseApplication = { id: string; diff --git a/src/apps/admin/AdminChannelReportPage.tsx b/src/apps/admin/AdminChannelReportPage.tsx index a619258..7c3d0e9 100644 --- a/src/apps/admin/AdminChannelReportPage.tsx +++ b/src/apps/admin/AdminChannelReportPage.tsx @@ -1,14 +1,29 @@ import { useEffect, useMemo, useState } from 'react'; -import { ArrowLeft, Eye, FileSliders, Search } from 'lucide-react'; +import { ArrowLeft, Download, Eye, FileSliders, Search } from 'lucide-react'; import { useNavigate, useParams } from 'react-router-dom'; -import { adminApi, type AdminChannel, type ChannelReportField, type ClientSmsSignature, type DictionaryItem, type ReportRecord, type ReportTask } from '@/api/adminApi'; -import { Breadcrumb, Button, CarrierTag, Input, Modal, Select, Tag, Textarea } from '@/components/ui'; +import { + adminApi, + type AdminChannel, + type ChannelReportField, + type ClientSmsSignature, + type DictionaryItem, + type ReportRecord, + type ReportTask, + type SingleReportMaterialDetail, +} from '@/api/adminApi'; +import { Breadcrumb, Button, CarrierTag, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui'; import { formatDateTime } from '@/utils/dateTime'; import { successRateClassName } from '@/utils/successRate'; import { ReportFieldMappingModal } from './ReportFieldMappingModal'; type ReportType = 'signature' | 'drainage'; -type DrainageItem = Record & { id?: string; url?: string; siteName?: string; submittedAt?: string; remark?: string }; +type DrainageItem = Record & { + id?: string; + url?: string; + siteName?: string; + submittedAt?: string; + remark?: string; +}; const statusMeta: Record = { approved: { label: '报备成功', tone: 'success' }, @@ -25,21 +40,29 @@ const statusMeta: Record { - return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; } function formatSignatureName(value?: string | null) { - const name = String(value ?? '-').trim().replace(/^[【\[]+|[】\]]+$/g, ''); + const name = String(value ?? '-') + .trim() + .replace(/^[【[]+|[】\]]+$/g, ''); return `【${name || '-'}】`; } function drainageItems(signature?: ClientSmsSignature) { const payload = asRecord(signature?.drainageInfo); - return Array.isArray(payload.links) ? payload.links.filter((item): item is DrainageItem => Boolean(item) && typeof item === 'object') : []; + return Array.isArray(payload.links) + ? payload.links.filter((item): item is DrainageItem => Boolean(item) && typeof item === 'object') + : []; } function DateTime({ value }: { value?: unknown }) { - return value ? {formatDateTime(String(value))} : -; + return value ? ( + {formatDateTime(String(value))} + ) : ( + - + ); } function ReportStatus({ value }: { value?: string }) { @@ -58,31 +81,118 @@ function DeliveryStats({ task }: { task: ReportTask }) { failureCount: 0, failureRate: 0, }; - return
- 成功{stats.successRate}%{stats.successCount.toLocaleString('zh-CN')} - 未知{stats.unknownRate}%{stats.unknownCount.toLocaleString('zh-CN')} - 回执失败{stats.failureRate}%{stats.failureCount.toLocaleString('zh-CN')} - 提交失败{stats.submitFailureRate}%{stats.submitFailureCount.toLocaleString('zh-CN')} -
; + return ( +
+ + 成功{stats.successRate}% + {stats.successCount.toLocaleString('zh-CN')} + + + 未知{stats.unknownRate}% + {stats.unknownCount.toLocaleString('zh-CN')} + + + 回执失败{stats.failureRate}% + {stats.failureCount.toLocaleString('zh-CN')} + + + 提交失败{stats.submitFailureRate}% + {stats.submitFailureCount.toLocaleString('zh-CN')} + +
+ ); } -function DetailModal({ drainage, reportedAt, signature, task, onClose }: { drainage?: DrainageItem; reportedAt?: string | null; signature?: ClientSmsSignature; task: ReportTask; onClose: () => void }) { +function DetailModal({ + drainage, + reportedAt, + signature, + task, + onClose, +}: { + drainage?: DrainageItem; + reportedAt?: string | null; + signature?: ClientSmsSignature; + task: ReportTask; + onClose: () => void; +}) { const payload = asRecord(signature?.drainageInfo); const profile = asRecord(payload.signatureProfile); const reportValues = asRecord(drainage ? drainage.reportValues : payload.signatureReportValues); return ( - 关闭} onClose={onClose} open size="xl" title={drainage ? '查看引流信息详情' : '查看签名详情'}> + 关闭} + onClose={onClose} + open + size="xl" + title={drainage ? '查看引流信息详情' : '查看签名详情'} + >
- {drainage ? String(drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)} -

企业{signature?.tenant?.name ?? task.tenantId}

-

企业应用{signature?.application?.name ?? '-'}

-

提交报备时间

-

报备成功时间

-

上次发送成功时间

- {!drainage ? <>

签名依据{String(profile.basis ?? '-')}

公司名称{String(profile.companyName ?? '-')}

统一社会信用代码{String(profile.creditCode ?? '-')}

: null} - {drainage ? <>

引流 URL 或号码{String(drainage.url ?? '-')}

备注{String(drainage.remark ?? '-')}

: null} - {Object.entries(reportValues).map(([key, value]) =>

{key}{typeof value === 'object' ? String(asRecord(value).fileName ?? asRecord(value).fileObjectId ?? '-') : String(value ?? '-')}

)} -

今日发送

+ + {drainage ? String(drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)} + +

+ 企业 + {signature?.tenant?.name ?? task.tenantId} +

+

+ 企业应用 + {signature?.application?.name ?? '-'} +

+

+ 提交报备时间 + +

+

+ 报备成功时间 + +

+

+ 上次发送成功时间 + +

+ {!drainage ? ( + <> +

+ 签名依据 + {String(profile.basis ?? '-')} +

+

+ 公司名称 + {String(profile.companyName ?? '-')} +

+

+ 统一社会信用代码 + {String(profile.creditCode ?? '-')} +

+ + ) : null} + {drainage ? ( + <> +

+ 引流 URL 或号码 + {String(drainage.url ?? '-')} +

+

+ 备注 + {String(drainage.remark ?? '-')} +

+ + ) : null} + {Object.entries(reportValues).map(([key, value]) => ( +

+ {key} + + {typeof value === 'object' + ? String(asRecord(value).fileName ?? asRecord(value).fileObjectId ?? '-') + : String(value ?? '-')} + +

+ ))} +
+

今日发送

+ +
); @@ -99,7 +209,19 @@ export function AdminChannelReportPage() { const [libraryFields, setLibraryFields] = useState([]); const [keyword, setKeyword] = useState(''); const [status, setStatus] = useState('all'); - const [detail, setDetail] = useState<{ task: ReportTask; reportedAt?: string | null; signature?: ClientSmsSignature; drainage?: DrainageItem }>(); + const [carrier, setCarrier] = useState('all'); + const [todaySendMin, setTodaySendMin] = useState(''); + const [todaySendMax, setTodaySendMax] = useState(''); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const pageSize = 10; + const [material, setMaterial] = useState(); + const [detail, setDetail] = useState<{ + task: ReportTask; + reportedAt?: string | null; + signature?: ClientSmsSignature; + drainage?: DrainageItem; + }>(); const [statusTask, setStatusTask] = useState(); const [nextStatus, setNextStatus] = useState('approved'); const [statusReason, setStatusReason] = useState(''); @@ -109,31 +231,77 @@ export function AdminChannelReportPage() { function loadData() { Promise.all([ adminApi.listChannels(), - adminApi.listReportTasks({ channelId }), + adminApi.listReportTasksPage({ + channelId, + keyword: keyword.trim() || undefined, + status: status === 'all' ? undefined : status, + carrier: carrier === 'all' ? undefined : carrier, + todaySendMin: todaySendMin ? Number(todaySendMin) : undefined, + todaySendMax: todaySendMax ? Number(todaySendMax) : undefined, + sort: 'todaySendDesc', + page, + pageSize, + }), adminApi.listReportRecords({ channelId }), adminApi.listEnterpriseSignatures(), adminApi.listChannelReportFields(channelId), adminApi.listDrainageFields(), - ]).then(([channelItems, taskItems, recordItems, signatureItems, fieldItems, libraryItems]) => { - setChannel(channelItems.find((item) => item.id === channelId)); - setTasks(taskItems); - setRecords(recordItems); - setSignatures(signatureItems); - setFields(fieldItems); - setLibraryFields(libraryItems.filter((item) => item.status === 'active')); - setError(''); - }).catch((failure: Error) => setError(failure.message || '通道报备详情加载失败')); + ]) + .then(([channelItems, taskPage, recordItems, signatureItems, fieldItems, libraryItems]) => { + setChannel(channelItems.find((item) => item.id === channelId)); + setTasks(taskPage.items); + setTotal(taskPage.total); + setRecords(recordItems); + setSignatures(signatureItems); + setFields(fieldItems); + setLibraryFields(libraryItems.filter((item) => item.status === 'active')); + setError(''); + }) + .catch((failure: Error) => setError(failure.message || '通道报备详情加载失败')); } - useEffect(loadData, [channelId]); + useEffect(loadData, [channelId, page]); const signatureMap = useMemo(() => new Map(signatures.map((item) => [item.id, item])), [signatures]); - const visibleTasks = useMemo(() => tasks.filter((task) => { - const signature = signatureMap.get(task.signatureId); - const drainage = drainageItems(signature).find((item) => String(item.id) === task.drainageItemId); - const matchesKeyword = !keyword.trim() || [signature?.name, signature?.tenant?.name, signature?.application?.name, drainage?.siteName, drainage?.url].some((value) => String(value ?? '').includes(keyword.trim())); - return matchesKeyword && (status === 'all' || task.status === status); - }), [keyword, signatureMap, status, tasks]); + const visibleTasks = tasks; + + async function openMaterial(task: ReportTask) { + try { + setMaterial( + await adminApi.getSingleReportMaterialDetail({ + reportType: task.reportType, + signatureId: task.signatureId, + channelId: task.channelId, + carrier: task.carrier ?? undefined, + drainageItemId: task.drainageItemId ?? undefined, + batchItemId: task.exportItems?.[0]?.batchItem.id, + }), + ); + } catch (failure) { + setError(failure instanceof Error ? failure.message : '报备资料加载失败'); + } + } + + async function exportMaterial(task: ReportTask) { + try { + const blob = await adminApi.exportSingleReportMaterial({ + reportType: task.reportType, + signatureId: task.signatureId, + channelId: task.channelId, + carrier: task.carrier ?? undefined, + drainageItemId: task.drainageItemId ?? undefined, + batchItemId: task.exportItems?.[0]?.batchItem.id, + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `${task.signature?.name ?? '签名'}-${task.channel?.name ?? '通道'}.xlsx`; + anchor.click(); + URL.revokeObjectURL(url); + } catch (failure) { + setError(failure instanceof Error ? failure.message : '单条资料导出失败'); + } + } function approvedRecord(taskId: string) { return records.find((record) => record.taskId === taskId && record.statusAfter === 'approved'); @@ -147,8 +315,26 @@ export function AdminChannelReportPage() { function saveTaskStatus() { if (!statusTask) return; - adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, carrier: statusTask.carrier ?? undefined, reportType: statusTask.reportType, drainageItemId: statusTask.drainageItemId ?? undefined, status: nextStatus }], reason: statusReason, sourceEntry: 'channel_report' }) - .then(() => { setStatusTask(undefined); setStatusReason(''); loadData(); }) + adminApi + .changeReportTaskStatuses({ + items: [ + { + signatureId: statusTask.signatureId, + channelId: statusTask.channelId, + carrier: statusTask.carrier ?? undefined, + reportType: statusTask.reportType, + drainageItemId: statusTask.drainageItemId ?? undefined, + status: nextStatus, + }, + ], + reason: statusReason, + sourceEntry: 'channel_report', + }) + .then(() => { + setStatusTask(undefined); + setStatusReason(''); + loadData(); + }) .catch((failure: Error) => setError(failure.message || '报备状态保存失败')); } @@ -157,48 +343,287 @@ export function AdminChannelReportPage() {
- +

{channel?.name ?? '通道报备详情'}

- - + +
-
通道编号:{channel?.code ?? channelId} · 已配置字段 {fields.length} 个
+
+ 通道编号:{channel?.code ?? channelId} · 已配置字段 {fields.length} 个 +
{error ?

{error}

: null}
- setKeyword(event.target.value)} placeholder="请输入关键词" prefix={} value={keyword} /> - setKeyword(event.target.value)} + placeholder="请输入关键词" + prefix={} + value={keyword} + /> + setCarrier(event.target.value)} + options={[ + { label: '全部运营商', value: 'all' }, + { label: '移动', value: 'mobile' }, + { label: '联通', value: 'unicom' }, + { label: '电信', value: 'telecom' }, + ]} + value={carrier} + /> + setTodaySendMin(event.target.value)} + type="number" + value={todaySendMin} + /> + setTodaySendMax(event.target.value)} + type="number" + value={todaySendMax} + /> +
+
+ 共 {total} 条报备任务,按今日发送条数从大到小 +
+ + +
-
共 {visibleTasks.length} 条报备任务
-
短信签名 / 引流信息报备状态提交报备时间报备成功时间上次发送成功时间今日发送操作
- {visibleTasks.length === 0 ?
当前通道暂无真实报备任务
: visibleTasks.map((task) => { - const signature = signatureMap.get(task.signatureId); - const drainage = task.reportType === 'drainage' ? drainageItems(signature).find((item) => String(item.id) === task.drainageItemId) : undefined; - const reportedAt = task.approvedAt ?? approvedRecord(task.id)?.createdAt; - return
- -
{drainage ? : null}{drainage ? String(drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}{drainage ? formatSignatureName(signature?.name ?? task.signature?.name) : <>{signature?.tenant?.name ?? task.tenantId} · {task.carrier ? : '历史通道级(未拆分)'}}
- - - - - -
-
; - })} +
+ + 短信签名 / 引流信息 + 报备状态 + 提交报备时间 + 报备成功时间 + 上次发送成功时间 + 今日发送 + 操作 +
+ {visibleTasks.length === 0 ? ( +
当前通道暂无真实报备任务
+ ) : ( + visibleTasks.map((task) => { + const signature = signatureMap.get(task.signatureId); + const drainage = + task.reportType === 'drainage' + ? drainageItems(signature).find((item) => String(item.id) === task.drainageItemId) + : undefined; + const reportedAt = task.approvedAt ?? approvedRecord(task.id)?.createdAt; + return ( +
+ +
+ {drainage ? : null} + + + {drainage + ? String(drainage.url || '引流信息') + : formatSignatureName(signature?.name ?? task.signature?.name)} + + + {drainage ? ( + formatSignatureName(signature?.name ?? task.signature?.name) + ) : ( + <> + {signature?.tenant?.name ?? task.tenantId} ·{' '} + {task.carrier ? : '历史通道级(未拆分)'} + + )} + + +
+ + + + + +
+ + {task.reportType !== 'drainage' ? ( + + ) : null} + +
+
+ ); + }) + )}
+ = total} + onNext={() => setPage((value) => value + 1)} + onPageChange={setPage} + onPrevious={() => setPage((value) => Math.max(1, value - 1))} + page={page} + previousDisabled={page <= 1} + total={total} + totalPages={Math.max(1, Math.ceil(total / pageSize))} + /> {detail ? setDetail(undefined)} /> : null} - } onClose={() => setStatusTask(undefined)} open={Boolean(statusTask)} title="修改当前通道报备状态">