feat: build reporting workbench workflow

This commit is contained in:
hectorzhao
2026-09-02 14:30:15 +08:00
parent 53d3668306
commit 9b8196ecab
31 changed files with 7651 additions and 1973 deletions
+457 -81
View File
@@ -1,13 +1,99 @@
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import IORedis from 'ioredis'; import IORedis from 'ioredis';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { assertMoneyUnits, moneyToNumber } from '../common/money'; import { assertMoneyUnits, moneyToNumber } from '../common/money';
import { PrismaService } from '../prisma/prisma.service'; 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 {
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'; 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. */ /** R5 channel domain service composed behind ChannelsService. */
export class ChannelReportingService { 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) => { return tasks.map((task) => {
const taskRows = rows.filter((row) => ( const taskRows = rows.filter(
row.channelId === task.channelId (row) =>
&& row.signatureId === task.signatureId row.channelId === task.channelId &&
&& ((task.reportType ?? 'signature') === 'signature' || row.drainageInfoId === task.drainageItemId) row.signatureId === task.signatureId &&
)); ((task.reportType ?? 'signature') === 'signature' || row.drainageInfoId === task.drainageItemId),
);
const deliveryStats = summarizeChannelReportDelivery(taskRows); const deliveryStats = summarizeChannelReportDelivery(taskRows);
return { return {
...task, ...task,
@@ -261,10 +352,65 @@ export class ChannelReportingService {
async listReportTasksPage(query: { async listReportTasksPage(query: {
tenantId?: string; tenantId?: string;
applicationId?: string;
status?: string; status?: string;
channelId?: string; channelId?: string;
reportType?: string; reportType?: string;
keyword?: 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; createdAtFrom?: string;
createdAtTo?: string; createdAtTo?: string;
page?: number; page?: number;
@@ -272,32 +418,19 @@ export class ChannelReportingService {
}) { }) {
const page = Math.max(1, Math.floor(Number(query.page) || 1)); 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 pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
const keyword = query.keyword?.trim(); const signatures = await this.prisma.smsSignature.findMany({
const where: Prisma.ChannelSignatureReportTaskWhereInput = { where: {
id: query.signatureId,
tenantId: query.tenantId, tenantId: query.tenantId,
status: query.status, applicationId: query.applicationId,
channelId: query.channelId, auditStatus: 'approved',
reportType: query.reportType, },
signature: { auditStatus: { not: 'deleted' } }, include: {
createdAt: query.createdAtFrom || query.createdAtTo ? { tenant: true,
gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined, application: true,
lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined, drainageItems: { where: { auditStatus: 'approved' } },
} : undefined, reportTasks: {
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: { include: {
signature: { include: { tenant: true, application: true } },
channel: true, channel: true,
drainageInfo: true, drainageInfo: true,
exportItems: { exportItems: {
@@ -307,13 +440,101 @@ export class ChannelReportingService {
}, },
records: { orderBy: { createdAt: 'desc' }, take: 20 }, records: { orderBy: { createdAt: 'desc' }, take: 20 },
}, },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], },
skip: (page - 1) * pageSize, },
take: 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,
};
}), }),
this.prisma.channelSignatureReportTask.count({ where }), );
]); const drainageDetails = signature.drainageItems.flatMap((drainageInfo) =>
return { items, total, page, pageSize }; 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) { 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' && !data.drainageItemId) throw new BadRequestException('drainageItemId is required');
if (reportType === 'drainage') { if (reportType === 'drainage') {
const drainageInfo = await this.prisma.smsDrainageInfo.findUnique({ where: { id: data.drainageItemId! } }); 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('引流信息审核通过后才能进入通道报备'); if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
throw new BadRequestException('引流信息通道报备任务由运营审核通过后按应用路由自动生成'); throw new BadRequestException('引流信息通道报备任务由运营审核通过后按应用路由自动生成');
} }
@@ -333,7 +555,13 @@ export class ChannelReportingService {
throw new BadRequestException('报备运营商不在通道支持范围内'); throw new BadRequestException('报备运营商不在通道支持范围内');
} }
const existing = await this.prisma.channelSignatureReportTask.findFirst({ 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('该签名在当前通道和运营商下已存在报备任务'); if (existing) throw new BadRequestException('该签名在当前通道和运营商下已存在报备任务');
const task = await this.prisma.channelSignatureReportTask.create({ const task = await this.prisma.channelSignatureReportTask.create({
@@ -355,7 +583,15 @@ export class ChannelReportingService {
async changeReportTaskStatuses(data: ChangeReportTaskStatusesDto) { async changeReportTaskStatuses(data: ChangeReportTaskStatusesDto) {
if (!data.items.length) throw new BadRequestException('items is required'); 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) { for (const item of data.items) {
if (!allowed.has(item.status)) throw new BadRequestException('unsupported report task status'); 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'); throw new BadRequestException('unsupported report task source entry');
} }
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const signatureIds = [...new Set(data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId))]; const signatureIds = [
const drainageResults: Array<{ signatureId: string; reportType: 'drainage'; drainageItemId: string; channelId: string; status: string }> = []; ...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) { for (const item of data.items) {
const reportType = item.reportType ?? 'signature'; 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 signature = await tx.smsSignature.findUnique({ where: { id: item.signatureId } });
const channel = await tx.smsChannel.findUnique({ where: { id: item.channelId } }); const channel = await tx.smsChannel.findUnique({ where: { id: item.channelId } });
if (!signature || !channel) throw new NotFoundException('Signature or channel not found'); if (!signature || !channel) throw new NotFoundException('Signature or channel not found');
if (reportType === 'drainage') { if (reportType === 'drainage') {
const drainageInfo = await tx.smsDrainageInfo.findUnique({ where: { id: item.drainageItemId! } }); 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 || drainageInfo.signatureId !== item.signatureId)
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能修改通道报备状态'); throw new NotFoundException('Drainage info not found');
if (drainageInfo.auditStatus !== 'approved')
throw new BadRequestException('引流信息审核通过后才能修改通道报备状态');
} }
const carrier = reportType === 'signature' && item.carrier ? normalizeBusinessCarrier(item.carrier) : null; const carrier = reportType === 'signature' && item.carrier ? normalizeBusinessCarrier(item.carrier) : null;
if (carrier && !normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)) { if (carrier && !normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)) {
throw new BadRequestException('报备运营商不在通道支持范围内'); throw new BadRequestException('报备运营商不在通道支持范围内');
} }
const existing = await tx.channelSignatureReportTask.findFirst({ where: { const existing = await tx.channelSignatureReportTask.findFirst({
where: {
signatureId: item.signatureId, signatureId: item.signatureId,
channelId: item.channelId, channelId: item.channelId,
reportType, reportType,
drainageItemId: reportType === 'drainage' ? item.drainageItemId : null, drainageItemId: reportType === 'drainage' ? item.drainageItemId : null,
carrier: reportType === 'signature' ? carrier : null, carrier: reportType === 'signature' ? carrier : null,
} }); },
if (reportType === 'drainage' && !existing) throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核'); });
if (reportType === 'signature' && !carrier && !existing) throw new BadRequestException('签名报备状态必须指定运营商'); if (reportType === 'drainage' && !existing)
const approvedAt = item.status === 'approved' throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核');
? existing?.status === 'approved' ? existing.approvedAt ?? new Date() : new Date() if (reportType === 'signature' && !carrier && !existing)
throw new BadRequestException('签名报备状态必须指定运营商');
const approvedAt =
item.status === 'approved'
? existing?.status === 'approved'
? (existing.approvedAt ?? new Date())
: new Date()
: null; : null;
const task = existing const task = existing
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: item.status, reason: data.reason, ...(reportType === 'signature' ? { approvedAt } : {}) } }) ? await tx.channelSignatureReportTask.update({
: 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 } }); where: { id: existing.id },
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 } }); data: { status: item.status, reason: data.reason, ...(reportType === 'signature' ? { approvedAt } : {}) },
if (reportType === 'drainage') drainageResults.push({ signatureId: item.signatureId, reportType, drainageItemId: item.drainageItemId!, channelId: item.channelId, status: item.status }); })
: 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 = []; 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]; return [...summaries, ...drainageResults];
}); });
} }
@@ -408,28 +700,55 @@ export class ChannelReportingService {
async recomputeSignatureReportSummary(tx: Prisma.TransactionClient, signatureId: string) { async recomputeSignatureReportSummary(tx: Prisma.TransactionClient, signatureId: string) {
const signature = await tx.smsSignature.findUnique({ where: { id: signatureId } }); const signature = await tx.smsSignature.findUnique({ where: { id: signatureId } });
if (!signature) throw new NotFoundException('Signature not found'); if (!signature) throw new NotFoundException('Signature not found');
const routes = signature.applicationId ? await tx.channelRouteRule.findMany({ const routes = signature.applicationId
? await tx.channelRouteRule.findMany({
where: { applicationId: signature.applicationId, status: 'active' }, where: { applicationId: signature.applicationId, status: 'active' },
include: { group: { include: { items: { include: { channel: true } } } } }, 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 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 channels = configuredChannels.length ? configuredChannels : tasks.map((task) => task.channel);
const uniqueChannels = [...new Map(channels.map((channel) => [channel.id, channel])).values()]; const uniqueChannels = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
const carrierReportSummary = Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => { const carrierReportSummary = Object.fromEntries(
const targets = uniqueChannels.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)); ['mobile', 'unicom', 'telecom'].map((carrier) => {
const targets = uniqueChannels.filter((channel) =>
normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier),
);
const statuses = targets.map((channel) => { const statuses = targets.map((channel) => {
const task = tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier) const task =
?? tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === null && candidate.approvalScope === 'legacy_channel'); 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 task?.status ?? 'pending';
}); });
return [carrier, summarizeReportStatuses(statuses)]; return [carrier, summarizeReportStatuses(statuses)];
})); }),
);
const allStatuses = ['mobile', 'unicom', 'telecom'].flatMap((carrier) => { const allStatuses = ['mobile', 'unicom', 'telecom'].flatMap((carrier) => {
const targets = uniqueChannels.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)); const targets = uniqueChannels.filter((channel) =>
return targets.map((channel) => tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier)?.status normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier),
?? tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === null && candidate.approvalScope === 'legacy_channel')?.status );
?? 'pending'); 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; const reportStatus = summarizeReportStatuses(allStatuses).status;
await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } }); await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } });
@@ -487,6 +806,11 @@ export class ChannelReportingService {
async listReportRecordsPage(query: { async listReportRecordsPage(query: {
taskId?: string; taskId?: string;
channelId?: string; channelId?: string;
batchNo?: string;
statusAfter?: string;
action?: string;
sourceEntry?: string;
operatorKeyword?: string;
keyword?: string; keyword?: string;
reportType?: string; reportType?: string;
createdAtFrom?: string; createdAtFrom?: string;
@@ -497,15 +821,42 @@ export class ChannelReportingService {
const page = Math.max(1, Math.floor(Number(query.page) || 1)); 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 pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
const keyword = query.keyword?.trim(); 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 = { const where: Prisma.ChannelSignatureReportRecordWhereInput = {
taskId: query.taskId, taskId: query.taskId,
channelId: query.channelId, channelId: query.channelId,
task: query.reportType ? { reportType: query.reportType } : undefined, statusAfter: query.statusAfter,
createdAt: query.createdAtFrom || query.createdAtTo ? { 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, 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, lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined,
} : undefined, }
OR: keyword ? [ : undefined,
OR: keyword
? [
{ taskId: { contains: keyword } }, { taskId: { contains: keyword } },
{ action: { contains: keyword } }, { action: { contains: keyword } },
{ reason: { contains: keyword } }, { reason: { contains: keyword } },
@@ -513,7 +864,8 @@ export class ChannelReportingService {
{ task: { signature: { name: { contains: keyword } } } }, { task: { signature: { name: { contains: keyword } } } },
{ task: { drainageInfo: { siteName: { contains: keyword } } } }, { task: { drainageInfo: { siteName: { contains: keyword } } } },
{ task: { drainageInfo: { url: { contains: keyword } } } }, { task: { drainageInfo: { url: { contains: keyword } } } },
] : undefined, ]
: undefined,
}; };
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.channelSignatureReportRecord.findMany({ this.prisma.channelSignatureReportRecord.findMany({
@@ -525,11 +877,30 @@ export class ChannelReportingService {
}), }),
this.prisma.channelSignatureReportRecord.count({ where }), 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) { 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) { if (!task) {
throw new NotFoundException('Report task not found'); throw new NotFoundException('Report task not found');
} }
@@ -552,8 +923,13 @@ export class ChannelReportingService {
data: { data: {
status: statusAfter, status: statusAfter,
reason, reason,
...((await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId }, select: { reportType: true, status: true, approvedAt: true } }))?.reportType === 'signature' ...((
? { approvedAt: statusAfter === 'approved' ? statusBefore === 'approved' ? undefined : new Date() : null } await this.prisma.channelSignatureReportTask.findUnique({
where: { id: taskId },
select: { reportType: true, status: true, approvedAt: true },
})
)?.reportType === 'signature'
? { approvedAt: statusAfter === 'approved' ? (statusBefore === 'approved' ? undefined : new Date()) : null }
: {}), : {}),
}, },
}); });
+135 -10
View File
@@ -27,10 +27,19 @@ import { ChannelsService } from './channels.service';
@ApiTags('channels') @ApiTags('channels')
@Controller('admin') @Controller('admin')
export class ChannelsController { export class ChannelsController {
constructor(private readonly channels: ChannelsService, private readonly deletions: DeletionGovernanceService) {} constructor(
private readonly channels: ChannelsService,
private readonly deletions: DeletionGovernanceService,
) {}
@Get('channels') @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 return page || pageSize
? this.channels.listChannelsPage({ keyword, carrier, status, page: Number(page), pageSize: Number(pageSize) }) ? this.channels.listChannelsPage({ keyword, carrier, status, page: Number(page), pageSize: Number(pageSize) })
: this.channels.listChannels(); : this.channels.listChannels();
@@ -68,7 +77,11 @@ export class ChannelsController {
@Delete('channels/:id') @Delete('channels/:id')
@RequireRecentAuthentication() @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 }); return this.deletions.delete('channel', channelId, { ...body, operatorId });
} }
@@ -159,7 +172,11 @@ export class ChannelsController {
@Put('channels/:channelId/report-fields/:reportType') @Put('channels/:channelId/report-fields/:reportType')
@RequireRecentAuthentication() @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); return this.channels.replaceReportFields(channelId, reportType, body);
} }
@@ -174,12 +191,82 @@ export class ChannelsController {
} }
@Get('report-tasks') @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) { listReportTasks(
return page || pageSize || keyword || createdAtFrom || createdAtTo @Query('tenantId') tenantId?: string,
? this.channels.listReportTasksPage({ tenantId, status, channelId, reportType, keyword, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) }) @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); : 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') @Post('report-tasks/generate')
createReportTask(@Body() body: CreateReportTaskDto) { createReportTask(@Body() body: CreateReportTaskDto) {
return this.channels.createReportTask(body); return this.channels.createReportTask(body);
@@ -202,9 +289,47 @@ export class ChannelsController {
} }
@Get('report-records') @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) { listReportRecords(
return page || pageSize || keyword || reportType || createdAtFrom || createdAtTo @Query('taskId') taskId?: string,
? this.channels.listReportRecordsPage({ taskId, channelId, keyword, reportType, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) }) @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); : this.channels.listReportRecords(taskId, channelId);
} }
} }
+54 -3
View File
@@ -1,6 +1,24 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; 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 { ChannelConfigurationService } from './channel-configuration.service';
import { ChannelConnectionService } from './channel-connection.service'; import { ChannelConnectionService } from './channel-connection.service';
import { ChannelCopyService } from './channel-copy.service'; import { ChannelCopyService } from './channel-copy.service';
@@ -42,7 +60,13 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
return this.configuration.listChannels(); 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); return this.configuration.listChannelsPage(query);
} }
@@ -152,16 +176,38 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
async listReportTasksPage(query: { async listReportTasksPage(query: {
tenantId?: string; tenantId?: string;
applicationId?: string;
status?: string; status?: string;
channelId?: string; channelId?: string;
reportType?: string; reportType?: string;
keyword?: 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; createdAtFrom?: string;
createdAtTo?: string; createdAtTo?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
}) { }) {
return this.reporting.listReportTasksPage(query); return this.reporting.listReportDetailsPage(query);
} }
async createReportTask(data: CreateReportTaskDto) { async createReportTask(data: CreateReportTaskDto) {
@@ -187,6 +233,11 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
async listReportRecordsPage(query: { async listReportRecordsPage(query: {
taskId?: string; taskId?: string;
channelId?: string; channelId?: string;
batchNo?: string;
statusAfter?: string;
action?: string;
sourceEntry?: string;
operatorKeyword?: string;
keyword?: string; keyword?: string;
reportType?: string; reportType?: string;
createdAtFrom?: string; createdAtFrom?: string;
@@ -6,14 +6,65 @@ import { extname } from 'node:path';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.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 {
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'; 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 { ReportBatchOperationService } from './batch-operation.service';
import { ReportChannelExportService } from './channel-export.service'; import { ReportChannelExportService } from './channel-export.service';
import { normalizeChannelCarriers } from '../channels/channels.helpers';
/** R4 report-materials domain service composed behind ReportMaterialsService. */ /** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportBatchGenerationService { 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 = {}) { async listBatches(query: PagedQuery = {}) {
const page = normalizePage(query.page); const page = normalizePage(query.page);
@@ -57,11 +108,155 @@ export class ReportBatchGenerationService {
}; };
} }
async getBatch(batchId: string) {
const batch = await this.prisma.reportMaterialBatch.findUnique({
where: { id: batchId },
include: { exportFiles: true, items: true },
});
if (!batch) throw new NotFoundException('报备批次不存在');
const tasks = await this.collectBatchTasks(batchId);
const successCount = tasks.filter((task) => task.status === 'approved').length;
return {
...batch,
reportTotal: tasks.length,
successCount,
successRate: tasks.length ? successCount / tasks.length : 0,
};
}
async listBatchTasks(
batchId: string,
query: PagedQuery & { reportType?: string; status?: string; channelId?: string } = {},
) {
const page = normalizePage(query.page);
const pageSize = normalizePageSize(query.pageSize);
const keyword = query.keyword?.trim();
const tasks = (await this.collectBatchTasks(batchId)).filter((task) => {
if (query.reportType && task.reportType !== query.reportType) return false;
if (query.status && task.status !== query.status) return false;
if (query.channelId && task.channelId !== query.channelId) return false;
if (!keyword) return true;
return [
task.id,
task.signature?.name,
task.signature?.tenant?.name,
task.signature?.application?.name,
task.drainageInfo?.url,
task.channel?.name,
].some((value) => String(value ?? '').includes(keyword));
});
return { items: tasks.slice((page - 1) * pageSize, page * pageSize), total: tasks.length, page, pageSize };
}
private async collectBatchTasks(batchId: string) {
const batch = await this.prisma.reportMaterialBatch.findUnique({
where: { id: batchId },
include: {
items: true,
exportFiles: { include: { items: true } },
},
});
if (!batch) throw new NotFoundException('报备批次不存在');
const batchItemById = new Map(batch.items.map((item) => [item.id, item]));
const scopes = batch.exportFiles
.flatMap((file) =>
file.items.map((entry) => {
const batchItem = batchItemById.get(entry.batchItemId);
return batchItem && file.channelId ? { batchItem, file, rowNumber: entry.rowNumber } : null;
}),
)
.filter((scope): scope is NonNullable<typeof scope> => Boolean(scope));
if (!scopes.length) return [];
const signatureIds = [...new Set(scopes.map((scope) => scope.batchItem.signatureId))];
const channelIds = [...new Set(scopes.map((scope) => scope.file.channelId!))];
const tasks = await this.prisma.channelSignatureReportTask.findMany({
where: { signatureId: { in: signatureIds }, channelId: { in: channelIds } },
include: {
signature: { include: { tenant: true, application: true } },
channel: true,
drainageInfo: true,
records: { orderBy: { createdAt: 'desc' }, take: 20 },
},
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
});
const result = [];
const seen = new Set<string>();
for (const scope of scopes) {
const snapshot = jsonRecord(scope.batchItem.snapshot);
const businessKeys = jsonStringArray(snapshot.businessKeys).filter((key) =>
key.includes(`:channel:${scope.file.channelId}:`),
);
const carriers = new Set(
businessKeys.flatMap(
(key) =>
key
.match(/:carrier:([^:]+)$/)?.[1]
.split(',')
.map((carrier) => carrier.trim()) ?? [],
),
);
for (const task of tasks) {
if (
task.signatureId !== scope.batchItem.signatureId ||
task.channelId !== scope.file.channelId ||
task.reportType !== scope.batchItem.reportType
)
continue;
if (scope.batchItem.reportType === 'drainage' && task.drainageItemId !== scope.batchItem.drainageItemId)
continue;
if (scope.batchItem.reportType === 'signature' && carriers.size && task.carrier && !carriers.has(task.carrier))
continue;
const key = `${scope.batchItem.id}:${task.id}`;
if (seen.has(key)) continue;
seen.add(key);
result.push({
...task,
exportItems: [
{
id: `${scope.file.id}:${scope.batchItem.id}:${task.id}`,
rowNumber: scope.rowNumber,
exportFile: {
id: scope.file.id,
fileObjectId: scope.file.fileObjectId,
fileName: scope.file.fileName,
rowCount: scope.file.rowCount,
batchId,
},
batchItem: {
id: scope.batchItem.id,
materialVersion: scope.batchItem.materialVersion,
batch: { id: batch.id, batchNo: batch.batchNo, createdAt: batch.createdAt },
},
},
],
});
}
}
return result;
}
async createBatch(data: CreateReportBatchDto) { async createBatch(data: CreateReportBatchDto) {
if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息'); if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息');
const idempotencyKey = normalizeBatchIdempotencyKey(data.idempotencyKey); const idempotencyKey = normalizeBatchIdempotencyKey(data.idempotencyKey);
const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()]; const uniqueItems = [
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'); ...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); const claimed = await this.operations.claimBatchOperation(idempotencyKey, fingerprint, data.createdById);
if (claimed.replayed) return claimed.result; if (claimed.replayed) return claimed.result;
@@ -69,21 +264,36 @@ export class ReportBatchGenerationService {
try { try {
preflight = await this.preflightBatch({ items: uniqueItems }); preflight = await this.preflightBatch({ items: uniqueItems });
} catch (error) { } catch (error) {
await this.operations.failBatchOperation(claimed.operationId, error instanceof Error ? error.message : '报备资格预检失败'); await this.operations.failBatchOperation(
claimed.operationId,
error instanceof Error ? error.message : '报备资格预检失败',
);
throw error; throw error;
} }
if (preflight.eligibleTargetCount === 0) { if (preflight.eligibleTargetCount === 0) {
await this.operations.failBatchOperation(claimed.operationId, '没有可生成的报备目标'); await this.operations.failBatchOperation(claimed.operationId, '没有可生成的报备目标');
throw new BadRequestException({ code: 'REPORT_BATCH_NOT_ELIGIBLE', message: '所选资料没有可生成的通道,请按资格检查补充后重试', preflight }); throw new BadRequestException({
code: 'REPORT_BATCH_NOT_ELIGIBLE',
message: '所选资料没有可生成的通道,请按资格检查补充后重试',
preflight,
});
} }
const eligibleInspections = preflight.items.filter((item) => item.targets.some((target) => target.eligible)); const eligibleInspections = preflight.items.filter((item) => item.targets.some((target) => target.eligible));
const batch = await this.prisma.reportMaterialBatch.create({ 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 }, data: {
batchNo: `RB${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}${randomUUID().slice(0, 4).toUpperCase()}`,
createdById: data.createdById,
selectedCount: eligibleInspections.length,
},
}); });
try { try {
const prepared = []; const prepared = [];
for (const inspection of eligibleInspections) { for (const inspection of eligibleInspections) {
const selected = uniqueItems.find((item) => item.reportType === inspection.reportType && (item.drainageItemId ?? item.signatureId) === (inspection.drainageItemId ?? inspection.signatureId))!; 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)); prepared.push(await this.prepareBatchItem(batch.id, selected, inspection));
} }
const channelMap = new Map<string, Array<(typeof prepared)[number]>>(); const channelMap = new Map<string, Array<(typeof prepared)[number]>>();
@@ -95,7 +305,9 @@ export class ReportBatchGenerationService {
} }
} }
const exportedFiles = []; const exportedFiles = [];
const incomplete = new Set<string>(prepared.filter((item) => item.channels.length === 0).map((item) => item.batchItem.id)); const incomplete = new Set<string>(
prepared.filter((item) => item.channels.length === 0).map((item) => item.batchItem.id),
);
let failedTargetCount = 0; let failedTargetCount = 0;
for (const [channelId, items] of channelMap) { for (const [channelId, items] of channelMap) {
const result = await this.channelExport.exportChannelBatch(batch.id, channelId, items); const result = await this.channelExport.exportChannelBatch(batch.id, channelId, items);
@@ -105,10 +317,24 @@ export class ReportBatchGenerationService {
} }
for (const item of prepared) { for (const item of prepared) {
if (incomplete.has(item.batchItem.id) || item.channels.length === 0) continue; 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 } }); if (item.reportType === 'signature')
else await this.prisma.smsDrainageInfo.update({ where: { id: item.drainageInfo!.id }, data: { pendingReport: false } }); 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 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 = { const result = {
...completed, ...completed,
operationId: claimed.operationId, operationId: claimed.operationId,
@@ -123,8 +349,19 @@ export class ReportBatchGenerationService {
await this.operations.completeBatchOperation(claimed.operationId, batch.id, result); await this.operations.completeBatchOperation(claimed.operationId, batch.id, result);
return result; return result;
} catch (error) { } 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.prisma.reportMaterialBatch.update({
await this.operations.failBatchOperation(claimed.operationId, error instanceof Error ? error.message : '生成报备批次失败', batch.id); 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; throw error;
} }
} }
@@ -132,107 +369,283 @@ export class ReportBatchGenerationService {
async preflightBatch(data: Pick<CreateReportBatchDto, 'items'>) { async preflightBatch(data: Pick<CreateReportBatchDto, 'items'>) {
if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息'); if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息');
for (const item of data.items) { 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 (!['signature', 'drainage'].includes(item.reportType) || !item.signatureId)
if (item.reportType === 'drainage' && !item.drainageItemId) throw new BadRequestException({ code: 'REPORT_BATCH_ITEM_INVALID', message: '引流资料必须包含引流资料ID' }); 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 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))); const items = await Promise.all(uniqueItems.map((item) => this.inspectBatchItem(item)));
return { return {
checkedAt: new Date().toISOString(), checkedAt: new Date().toISOString(),
eligible: items.some((item) => item.eligible), eligible: items.some((item) => item.eligible),
eligibleItemCount: items.filter((item) => item.eligible).length, eligibleItemCount: items.filter((item) => item.eligible).length,
blockedItemCount: 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), eligibleTargetCount: items.reduce(
skippedTargetCount: items.reduce((sum, item) => sum + item.targets.filter((target) => !target.eligible).length, 0), (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, items,
}; };
} }
async prepareBatchItem(batchId: string, selected: CreateReportBatchDto['items'][number], inspection: ReportBatchInspection) { async prepareBatchItem(
const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } }); 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('签名不存在或未审核通过'); if (!signature || signature.auditStatus !== 'approved') throw new BadRequestException('签名不存在或未审核通过');
const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId const drainageInfo =
? await this.prisma.smsDrainageInfo.findUnique({ where: { id: selected.drainageItemId } }) : null; selected.reportType === 'drainage' && selected.drainageItemId
if (selected.reportType === 'drainage' && (!drainageInfo || drainageInfo.signatureId !== signature.id || drainageInfo.auditStatus !== 'approved')) throw new BadRequestException('引流信息不存在或未审核通过'); ? await this.prisma.smsDrainageInfo.findUnique({ where: { id: selected.drainageItemId } })
const routes = signature.applicationId ? await this.prisma.channelRouteRule.findMany({ : 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' }, where: { applicationId: signature.applicationId, status: 'active' },
include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } }, include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } },
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 eligibleChannelIds = new Set(
const snapshot = selected.reportType === 'signature' inspection.targets.filter((target) => target.eligible).map((target) => target.channelId),
? { 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 channels = [
const materialVersion = selected.reportType === 'signature' ? signature.materialVersion : drainageInfo!.materialVersion; ...new Map(
const batchItem = await this.prisma.reportMaterialBatchItem.create({ data: { batchId, signatureId: signature.id, drainageItemId: drainageInfo?.id, reportType: selected.reportType, materialVersion, snapshot: snapshot as Prisma.InputJsonValue } }); routes
return { batchItem, signature, drainageInfo, reportType: selected.reportType, snapshot, channels }; .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,
eligibleTargets: inspection.targets.filter((target) => target.eligible),
};
} }
async inspectBatchItem(selected: CreateReportBatchDto['items'][number]): Promise<ReportBatchInspection> { async inspectBatchItem(selected: CreateReportBatchDto['items'][number]): Promise<ReportBatchInspection> {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } }); const signature = await this.prisma.smsSignature.findUnique({
where: { id: selected.signatureId },
include: { tenant: true, application: true },
});
if (!signature) throw new NotFoundException('签名不存在'); if (!signature) throw new NotFoundException('签名不存在');
const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId const drainageInfo =
? await this.prisma.smsDrainageInfo.findUnique({ where: { id: selected.drainageItemId } }) : null; selected.reportType === 'drainage' && selected.drainageItemId
const materialVersion = selected.reportType === 'signature' ? signature.materialVersion : drainageInfo?.materialVersion ?? 0; ? await this.prisma.smsDrainageInfo.findUnique({ where: { id: selected.drainageItemId } })
: null;
const materialVersion =
selected.reportType === 'signature' ? signature.materialVersion : (drainageInfo?.materialVersion ?? 0);
const blockedReasons: string[] = []; const blockedReasons: string[] = [];
if (signature.auditStatus !== 'approved') blockedReasons.push('签名尚未审核通过'); if (signature.auditStatus !== 'approved') blockedReasons.push('签名尚未审核通过');
if (!signature.pendingReport) blockedReasons.push('该签名版本已不在待报备池'); if (!signature.pendingReport) blockedReasons.push('该签名版本已不在待报备池');
if (!signature.applicationId || !signature.application) blockedReasons.push('未绑定短信应用'); if (!signature.applicationId || !signature.application) blockedReasons.push('未绑定短信应用');
else if (signature.application.status !== 'active') blockedReasons.push('短信应用未启用'); else if (signature.application.status !== 'active') blockedReasons.push('短信应用未启用');
if (selected.materialVersion !== undefined && selected.materialVersion !== materialVersion) blockedReasons.push(`资料版本已变化(当前 V${materialVersion}`); if (selected.materialVersion !== undefined && selected.materialVersion !== materialVersion)
blockedReasons.push(`资料版本已变化(当前 V${materialVersion}`);
if (selected.reportType === 'drainage') { if (selected.reportType === 'drainage') {
if (!drainageInfo || drainageInfo.signatureId !== signature.id) blockedReasons.push('引流资料不存在或不属于当前签名'); if (!drainageInfo || drainageInfo.signatureId !== signature.id)
blockedReasons.push('引流资料不存在或不属于当前签名');
else { else {
if (drainageInfo.auditStatus !== 'approved') blockedReasons.push('引流资料尚未审核通过'); if (drainageInfo.auditStatus !== 'approved') blockedReasons.push('引流资料尚未审核通过');
if (!drainageInfo.pendingReport) blockedReasons.push('该引流资料版本已不在待报备池'); if (!drainageInfo.pendingReport) blockedReasons.push('该引流资料版本已不在待报备池');
} }
} }
const snapshot = selected.reportType === 'signature' const snapshot =
? { signature: { name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) } selected.reportType === 'signature'
: { 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({ 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' }, where: { applicationId: signature.applicationId, status: 'active' },
include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } }, include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } },
orderBy: { priority: 'asc' }, orderBy: { priority: 'asc' },
}) : []; })
const channelCarriers = new Map<string, { channel: { id: string; name: string; status: string; carrier?: string | null }; carriers: Set<string> }>(); : [];
const channelCarriers = new Map<
string,
{
channel: { id: string; name: string; status: string; carrier?: string | null; carriers: string[] };
carriers: Set<string>;
}
>();
for (const route of routes) { for (const route of routes) {
if (route.group.status !== 'active') continue; if (route.group.status !== 'active') continue;
for (const entry of route.group.items) { for (const entry of route.group.items) {
if (entry.channel.status !== 'active') continue; if (entry.channel.status !== 'active') continue;
const current = channelCarriers.get(entry.channel.id) ?? { channel: entry.channel, carriers: new Set<string>() }; const current = channelCarriers.get(entry.channel.id) ?? {
current.carriers.add(route.carrier || entry.carrier || entry.channel.carrier || 'all'); channel: entry.channel,
carriers: new Set<string>(),
};
const routeCarrier = route.carrier || entry.carrier;
const supported = normalizeChannelCarriers(entry.channel.carriers, entry.channel.carrier);
if (routeCarrier && ['mobile', 'unicom', 'telecom'].includes(routeCarrier)) {
if (supported.includes(routeCarrier as 'mobile' | 'unicom' | 'telecom')) current.carriers.add(routeCarrier);
} else {
for (const carrier of supported) current.carriers.add(carrier);
}
channelCarriers.set(entry.channel.id, current); channelCarriers.set(entry.channel.id, current);
} }
} }
if (blockedReasons.length === 0 && channelCarriers.size === 0) blockedReasons.push('当前应用没有启用且可路由的通道'); if (blockedReasons.length === 0 && channelCarriers.size === 0)
blockedReasons.push('当前应用没有启用且可路由的通道');
const previous = await this.prisma.reportMaterialBatchItem.findMany({ 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'] } } }, where: {
select: { batchId: true, snapshot: true, exportItems: { select: { exportFile: { select: { channelId: true } } } } }, 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' }, orderBy: { createdAt: 'desc' },
}); });
const priorKeys = new Map<string, string>(); const priorKeys = new Map<string, string>();
for (const item of previous) { for (const item of previous) {
const exportedChannelIds = new Set(item.exportItems.map((entry) => entry.exportFile.channelId).filter((value): value is string => Boolean(value))); 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)) { 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); 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[] = []; const targets: ReportBatchTarget[] = [];
for (const { channel, carriers } of channelCarriers.values()) { for (const { channel, carriers } of channelCarriers.values()) {
const carrier = [...carriers].sort().join(','); 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 businessKey = `${selected.reportType}:${selected.drainageItemId ?? signature.id}:v${materialVersion}:app:${signature.applicationId}:channel:${channel.id}:carrier:${carrier}`;
const targetReasons = [...blockedReasons]; 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('通道未配置当前资料类型的报备字段'); if (fields.length === 0) targetReasons.push('通道未配置当前资料类型的报备字段');
else { else {
const missing = fields.filter((field) => field.required && !hasValue(resolveExportValue(snapshot, field.code, field.name) ?? field.defaultValue)); const missing = fields.filter(
if (missing.length) targetReasons.push(`缺少必填字段:${missing.map((field) => field.exportName || field.name).join('、')}`); (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); const duplicateBatchId = priorKeys.get(businessKey);
if (duplicateBatchId) targetReasons.push(`同一资料版本已在批次 ${duplicateBatchId} 生成`); if (duplicateBatchId) targetReasons.push(`同一资料版本已在批次 ${duplicateBatchId} 生成`);
targets.push({ id: channel.id, name: channel.name, carrier, businessKey, eligible: targetReasons.length === 0, blockedReasons: targetReasons, duplicateBatchId }); targets.push({
id: `${channel.id}:${carrier}`,
channelId: channel.id,
name: channel.name,
carrier,
businessKey,
eligible: targetReasons.length === 0,
blockedReasons: targetReasons,
duplicateBatchId,
});
}
} }
return { return {
id: `${selected.reportType}:${selected.drainageItemId ?? signature.id}`, id: `${selected.reportType}:${selected.drainageItemId ?? signature.id}`,
@@ -240,12 +653,14 @@ export class ReportBatchGenerationService {
signatureId: signature.id, signatureId: signature.id,
drainageItemId: drainageInfo?.id, drainageItemId: drainageInfo?.id,
materialVersion, materialVersion,
name: selected.reportType === 'signature' ? signature.name : drainageInfo?.url ?? '引流资料', name: selected.reportType === 'signature' ? signature.name : (drainageInfo?.url ?? '引流资料'),
tenantName: signature.tenant.name, tenantName: signature.tenant.name,
applicationId: signature.applicationId ?? undefined, applicationId: signature.applicationId ?? undefined,
applicationName: signature.application?.name ?? '未指定应用', applicationName: signature.application?.name ?? '未指定应用',
eligible: targets.some((target) => target.eligible), eligible: targets.some((target) => target.eligible),
blockedReasons: targets.length ? [...new Set(targets.flatMap((target) => target.blockedReasons))] : blockedReasons, blockedReasons: targets.length
? [...new Set(targets.flatMap((target) => target.blockedReasons))]
: blockedReasons,
targets, targets,
}; };
} }
@@ -6,16 +6,68 @@ import { extname } from 'node:path';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.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 {
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'; 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 type { ReportBatchGenerationService } from './batch-generation.service';
import { normalizeChannelCarriers } from '../channels/channels.helpers';
/** R4 report-materials domain service composed behind ReportMaterialsService. */ /** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportChannelExportService { export class ReportChannelExportService {
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {} constructor(
private readonly prisma: PrismaService,
private readonly files: FilesService,
private readonly smsConfig: SmsConfigService,
) {}
async exportChannelBatch(batchId: string, channelId: string, items: Array<Awaited<ReturnType<ReportBatchGenerationService['prepareBatchItem']>>>) { async exportChannelBatch(
batchId: string,
channelId: string,
items: Array<Awaited<ReturnType<ReportBatchGenerationService['prepareBatchItem']>>>,
) {
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } }); const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
if (!channel) throw new NotFoundException('通道不存在'); if (!channel) throw new NotFoundException('通道不存在');
const reportTypes = [...new Set(items.map((item) => item.reportType))]; const reportTypes = [...new Set(items.map((item) => item.reportType))];
@@ -24,48 +76,118 @@ export class ReportChannelExportService {
const incompleteBatchItemIds: string[] = []; const incompleteBatchItemIds: string[] = [];
let totalRows = 0; let totalRows = 0;
for (const reportType of reportTypes) { 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 fields = await this.prisma.channelReportField.findMany({
const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名报备' : '引流信息报备', { views: [{ state: 'frozen', ySplit: 1 }] }); 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.properties.defaultRowHeight = 22;
sheet.columns = fields.map((field) => ({ header: field.exportName || field.name, key: field.code, width: field.columnWidth })); sheet.columns = fields.map((field) => ({
header: field.exportName || field.name,
key: field.code,
width: field.columnWidth,
}));
styleHeader(sheet.getRow(1)); styleHeader(sheet.getRow(1));
for (const item of items.filter((current) => current.reportType === reportType)) { 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 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 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 missingReason =
const reportCarriers = reportType === 'signature' ? normalizeChannelCarriers(channel.carriers, channel.carrier) : [null]; fields.length === 0
const tasks: Array<{ task: { id: string; reason: string | null }; existingTask: { status: string } | null }> = []; ? '通道未配置当前资料类型的报备字段'
: 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) { 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 existingTask = await this.prisma.channelSignatureReportTask.findFirst({
where: {
signatureId: item.signature.id,
channelId,
carrier,
reportType,
drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null,
},
});
const task = existingTask 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.update({
: 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 } }); 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 }); tasks.push({ task, existingTask });
} }
const task = tasks[0].task; const task = tasks[0].task;
if (missingReason) { if (missingReason) {
incompleteBatchItemIds.push(item.batchItem.id); 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); for (const entry of tasks)
await this.recordTask(
entry.task.id,
channelId,
entry.existingTask?.status,
'waiting_material',
task.reason ?? undefined,
);
continue; continue;
} }
const row = sheet.addRow(values.map((value, index) => isFileRef(value) ? value.fileName : applyExportTransform(value, fields[index]?.transform))); const row = sheet.addRow(
values.map((value, index) =>
isFileRef(value) ? value.fileName : applyExportTransform(value, fields[index]?.transform),
),
);
totalRows += 1; totalRows += 1;
let targetHeight = 22; let targetHeight = 22;
for (const [index, value] of values.entries()) { for (const [index, value] of values.entries()) {
if (!isFileRef(value)) continue; if (!isFileRef(value)) continue;
const downloaded = await this.files.getDownload(value.fileObjectId); const downloaded = await this.files.getDownload(value.fileObjectId);
if (!downloaded.fileObject.contentType.startsWith('image/')) continue; if (!downloaded.fileObject.contentType.startsWith('image/')) continue;
const extension = normalizeImageExtension(extname(downloaded.fileObject.fileName).slice(1) || downloaded.fileObject.contentType.split('/')[1]); const extension = normalizeImageExtension(
extname(downloaded.fileObject.fileName).slice(1) || downloaded.fileObject.contentType.split('/')[1],
);
if (!['png', 'jpeg', 'gif'].includes(extension)) continue; 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 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 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); 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); 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); targetHeight = Math.max(targetHeight, fields[index].imageHeight * 0.75 + 8);
} }
row.height = targetHeight; row.height = targetHeight;
fileRows.push({ item, taskId: task.id, rowNumber: row.number }); 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'); for (const entry of tasks)
await this.recordTask(entry.task.id, channelId, entry.existingTask?.status, 'exporting');
} }
} }
if (workbook.worksheets.every((sheet) => sheet.rowCount <= 1)) { if (workbook.worksheets.every((sheet) => sheet.rowCount <= 1)) {
@@ -76,13 +198,219 @@ export class ReportChannelExportService {
} }
const buffer = Buffer.from(await workbook.xlsx.writeBuffer()); const buffer = Buffer.from(await workbook.xlsx.writeBuffer());
const fileName = `${safeFileName(channel.name)}-${batchId.slice(-8)}.xlsx`; 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 uploaded = await this.files.upload(
const file = await this.prisma.reportExportFile.create({ data: { batchId, channelId, fileObjectId: uploaded.id, fileName, rowCount: totalRows } }); { purpose: 'report_export', prefix: `report-exports/${batchId}` },
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 })) }); {
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 }; return { file: { ...file, fileObject: uploaded }, incompleteBatchItemIds };
} }
recordTask(taskId: string, channelId: string, statusBefore: string | undefined, statusAfter: string, reason?: string) { async getSingleMaterialDetail(data: SingleReportMaterialDto) {
return this.prisma.channelSignatureReportRecord.create({ data: { taskId, channelId, action: 'batch_export', statusBefore, statusAfter, reason, sourceEntry: 'report_task' } }); const reportType = data.reportType ?? 'signature';
if (!data.signatureId || !data.channelId) throw new BadRequestException('签名和通道不能为空');
if (reportType === 'drainage' && !data.drainageItemId) throw new BadRequestException('引流信息不能为空');
const [signature, channel] = await Promise.all([
this.prisma.smsSignature.findUnique({
where: { id: data.signatureId },
include: { tenant: true, application: true },
}),
this.prisma.smsChannel.findUnique({ where: { id: data.channelId } }),
]);
if (!signature || signature.auditStatus === 'deleted') throw new NotFoundException('签名不存在');
if (!channel || channel.status === 'deleted') throw new NotFoundException('通道不存在');
let materialVersion = signature.materialVersion;
let snapshot: Record<string, unknown>;
if (data.batchItemId) {
const batchItem = await this.prisma.reportMaterialBatchItem.findFirst({
where: {
id: data.batchItemId,
signatureId: signature.id,
exportItems: { some: { exportFile: { channelId: channel.id } } },
},
});
if (!batchItem) throw new NotFoundException('当前批次资料快照不存在');
if (batchItem.reportType !== reportType) throw new BadRequestException('批次资料类型不一致');
materialVersion = batchItem.materialVersion;
snapshot = jsonRecord(batchItem.snapshot);
} else if (reportType === 'signature') {
snapshot = {
reportType,
signature: {
id: signature.id,
name: signature.name,
purpose: signature.purpose,
tenantName: signature.tenant.name,
applicationName: signature.application?.name,
},
values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues),
};
} else {
const drainage = await this.prisma.smsDrainageInfo.findFirst({
where: { id: data.drainageItemId, signatureId: signature.id },
});
if (!drainage || drainage.auditStatus === 'deleted') throw new NotFoundException('引流信息不存在');
materialVersion = drainage.materialVersion;
snapshot = {
reportType,
signature: {
id: signature.id,
name: signature.name,
tenantName: signature.tenant.name,
applicationName: signature.application?.name,
},
drainage: { id: drainage.id, siteName: drainage.siteName, url: drainage.url, remark: drainage.remark },
values: jsonRecord(drainage.reportValues),
};
}
const fields = await this.prisma.channelReportField.findMany({
where: { channelId: channel.id, status: 'active', reportType: { in: [reportType, 'both'] } },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
});
const configuredCodes = new Set(fields.map((field) => field.code));
const values = jsonRecord(snapshot.values);
const materialFields = fields.map((field) => {
const submittedValue = resolveExportValue(snapshot, field.code, field.name);
const value = hasValue(submittedValue) ? submittedValue : field.defaultValue;
return {
id: field.id,
code: field.code,
name: field.name,
exportName: field.exportName,
fieldType: field.fieldType,
required: field.required,
columnWidth: field.columnWidth,
imageWidth: field.imageWidth,
imageHeight: field.imageHeight,
transform: field.transform,
value: value ?? null,
submitted: hasValue(submittedValue),
missing: field.required && !hasValue(value),
};
});
const historicalFields = Object.entries(values)
.filter(([code, value]) => !configuredCodes.has(code) && hasValue(value))
.sort(([left], [right]) => left.localeCompare(right, 'zh-CN'))
.map(([code, value]) => ({ code, name: code, value }));
return {
reportType,
signatureId: signature.id,
signatureName: signature.name,
tenant: { id: signature.tenant.id, name: signature.tenant.name },
application: signature.application ? { id: signature.application.id, name: signature.application.name } : null,
channel: { id: channel.id, name: channel.name, code: channel.code },
carrier: data.carrier ?? null,
materialVersion,
batchItemId: data.batchItemId ?? null,
fields: materialFields,
historicalFields,
missingFields: materialFields.filter((field) => field.missing).map((field) => field.exportName || field.name),
};
}
async exportSingleMaterial(data: SingleReportMaterialDto, operatorId?: string) {
if ((data.reportType ?? 'signature') !== 'signature')
throw new BadRequestException('首版仅支持单条签名报备资料导出');
const detail = await this.getSingleMaterialDetail(data);
if (detail.missingFields.length)
throw new BadRequestException({
code: 'REPORT_MATERIAL_INCOMPLETE',
message: `缺少必填字段:${detail.missingFields.join('、')}`,
missingFields: detail.missingFields,
});
const workbook = new ExcelJS.Workbook();
workbook.creator = 'CMPP短信平台';
const sheet = workbook.addWorksheet('签名报备', { views: [{ state: 'frozen', ySplit: 1 }] });
sheet.columns = detail.fields.map((field) => ({
header: field.exportName || field.name,
key: field.code,
width: field.columnWidth,
}));
styleHeader(sheet.getRow(1));
const row = sheet.addRow(
detail.fields.map((field) =>
isFileRef(field.value) ? field.value.fileName : applyExportTransform(field.value, field.transform),
),
);
let targetHeight = 22;
for (const [index, field] of detail.fields.entries()) {
if (!isFileRef(field.value)) continue;
const downloaded = await this.files.getDownload(field.value.fileObjectId);
if (!downloaded.fileObject.contentType.startsWith('image/')) continue;
const extension = normalizeImageExtension(
extname(downloaded.fileObject.fileName).slice(1) || downloaded.fileObject.contentType.split('/')[1],
);
if (!['png', 'jpeg', 'gif'].includes(extension)) continue;
const imageId = workbook.addImage({
base64: `data:${downloaded.fileObject.contentType};base64,${downloaded.content.toString('base64')}`,
extension: extension as 'png' | 'jpeg' | 'gif',
});
const widthCells = Math.max(0.8, field.imageWidth / Math.max(60, field.columnWidth * 7));
const heightRows = Math.max(0.8, field.imageHeight / 20);
sheet.addImage(imageId, {
tl: { col: index + 0.08, row: row.number - 1 + 0.08 },
br: { col: index + Math.min(0.95, widthCells), row: row.number - 1 + Math.min(0.95, heightRows) },
editAs: 'oneCell',
} as never);
targetHeight = Math.max(targetHeight, field.imageHeight * 0.75 + 8);
}
row.height = targetHeight;
const fileName = `${safeFileName(detail.channel.name)}-${safeFileName(detail.signatureName)}-V${detail.materialVersion}.xlsx`;
const content = Buffer.from(await workbook.xlsx.writeBuffer());
await this.prisma.operationLog.create({
data: {
tenantId: detail.tenant.id,
userId: operatorId,
action: 'report_material.single_export',
resource: 'report_material',
resourceId: detail.signatureId,
detail: {
fileName,
channelId: detail.channel.id,
carrier: detail.carrier,
materialVersion: detail.materialVersion,
batchItemId: detail.batchItemId,
successCount: 1,
failedCount: 0,
} as Prisma.InputJsonValue,
},
});
return { fileName, content };
}
recordTask(
taskId: string,
channelId: string,
statusBefore: string | undefined,
statusAfter: string,
reason?: string,
) {
return this.prisma.channelSignatureReportRecord.create({
data: {
taskId,
channelId,
action: 'batch_export',
statusBefore,
statusAfter,
reason,
sourceEntry: 'report_task',
},
});
} }
} }
+216 -53
View File
@@ -6,20 +6,75 @@ import { extname } from 'node:path';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.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 {
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'; 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'; import { ReportImportParserService } from './import-parser.service';
/** R4 report-materials domain service composed behind ReportMaterialsService. */ /** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportImportReviewService { 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) { async commitImport(batchId: string, data: ImportCommitDto) {
const batch = await this.prisma.reportMaterialImportBatch.findUnique({ where: { id: batchId } }); const batch = await this.prisma.reportMaterialImportBatch.findUnique({ where: { id: batchId } });
if (!batch) throw new NotFoundException('导入批次不存在'); if (!batch) throw new NotFoundException('导入批次不存在');
if (batch.status !== 'analyzed') throw new ConflictException('该导入批次已提交审核,不能重复导入'); if (batch.status !== 'analyzed') throw new ConflictException('该导入批次已提交审核,不能重复导入');
if (!data.mappings?.length) throw new BadRequestException('请至少配置一个导入字段映射'); 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 }); 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 { content } = await this.files.getDownload(batch.fileObjectId);
const workbook = await loadWorkbook(content); const workbook = await loadWorkbook(content);
assertSafeWorkbook(workbook); assertSafeWorkbook(workbook);
@@ -36,22 +91,33 @@ export class ReportImportReviewService {
for (const mapping of data.mappings) { for (const mapping of data.mappings) {
const image = imageByCell.get(`${rowNumber}:${mapping.sourceColumnIndex}`); const image = imageByCell.get(`${rowNumber}:${mapping.sourceColumnIndex}`);
if (image && mapping.fieldType !== 'string') { if (image && mapping.fieldType !== 'string') {
const uploaded = await this.files.upload({ tenantId: batch.tenantId, purpose: 'report_material', prefix: `report-materials/import-${batch.id}` }, { 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)}`, originalname: `${mapping.targetFieldCode}-row-${rowNumber}.${normalizeImageExtension(image.extension)}`,
mimetype: imageContentType(image.extension), mimetype: imageContentType(image.extension),
size: image.buffer.length, size: image.buffer.length,
buffer: image.buffer, buffer: image.buffer,
}); },
values[mapping.targetFieldCode] = { fileObjectId: uploaded.id, fileName: uploaded.fileName, contentType: uploaded.contentType }; );
values[mapping.targetFieldCode] = {
fileObjectId: uploaded.id,
fileName: uploaded.fileName,
contentType: uploaded.contentType,
};
} else { } else {
values[mapping.targetFieldCode] = transformValue(cellText(worksheet.getCell(rowNumber, mapping.sourceColumnIndex)), mapping.transform); values[mapping.targetFieldCode] = transformValue(
cellText(worksheet.getCell(rowNumber, mapping.sourceColumnIndex)),
mapping.transform,
);
} }
} }
if (!Object.values(values).some(hasValue)) continue; if (!Object.values(values).some(hasValue)) continue;
for (const mapping of data.mappings.filter((item) => item.required)) { for (const mapping of data.mappings.filter((item) => item.required)) {
if (!hasValue(values[mapping.targetFieldCode])) throw new Error(`缺少必填字段:${mapping.sourceHeader}`); if (!hasValue(values[mapping.targetFieldCode])) throw new Error(`缺少必填字段:${mapping.sourceHeader}`);
} }
const staged = batch.reportType === 'signature' const staged =
batch.reportType === 'signature'
? await this.stageSignatureRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values) ? await this.stageSignatureRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values)
: await this.stageDrainageRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values); : await this.stageDrainageRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values);
stagedItems.push({ stagedItems.push({
@@ -93,10 +159,21 @@ export class ReportImportReviewService {
include: { items: { orderBy: { rowNumber: 'asc' } } }, include: { items: { orderBy: { rowNumber: 'asc' } } },
}); });
}); });
await this.prisma.operationLog.create({ data: { await this.prisma.operationLog.create({
tenantId: batch.tenantId, userId: data.operatorId, action: 'report_material.import_committed', resource: 'report_material_import', resourceId: batch.id, data: {
detail: { fileName: batch.fileName, filters: { applicationId: batch.applicationId, reportType: batch.reportType, sheetName: batch.sheetName }, successCount, failedCount: failures.length } as Prisma.InputJsonValue, 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; return updated;
} }
@@ -107,10 +184,9 @@ export class ReportImportReviewService {
reportType: query.reportType, reportType: query.reportType,
status: query.status && query.status !== 'all' ? query.status : undefined, status: query.status && query.status !== 'all' ? query.status : undefined,
createdAt: dateRange(query.startAt, query.endAt), createdAt: dateRange(query.startAt, query.endAt),
OR: query.keyword?.trim() ? [ OR: query.keyword?.trim()
{ fileName: { contains: query.keyword.trim() } }, ? [{ fileName: { contains: query.keyword.trim() } }, { id: { contains: query.keyword.trim() } }]
{ id: { contains: query.keyword.trim() } }, : undefined,
] : undefined,
}; };
const [batches, total] = await Promise.all([ const [batches, total] = await Promise.all([
this.prisma.reportMaterialImportBatch.findMany({ this.prisma.reportMaterialImportBatch.findMany({
@@ -123,15 +199,32 @@ export class ReportImportReviewService {
this.prisma.reportMaterialImportBatch.count({ where }), this.prisma.reportMaterialImportBatch.count({ where }),
]); ]);
const tenantIds = [...new Set(batches.map((batch) => batch.tenantId))]; 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 applicationIds = [
const reviewerIds = [...new Set(batches.flatMap((batch) => [ ...new Set(batches.map((batch) => batch.applicationId).filter((id): id is string => Boolean(id))),
batch.reviewedById, ];
...batch.items.map((item) => item.reviewedById), const reviewerIds = [
]).filter((id): id is string => Boolean(id)))]; ...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([ const [tenants, applications, reviewers] = await Promise.all([
tenantIds.length ? this.prisma.tenant.findMany({ where: { id: { in: tenantIds } }, select: { id: true, name: true } }) : [], tenantIds.length
applicationIds.length ? this.prisma.smsApplication.findMany({ where: { id: { in: applicationIds } }, select: { id: true, name: true } }) : [], ? this.prisma.tenant.findMany({ where: { id: { in: tenantIds } }, select: { id: true, name: true } })
reviewerIds.length ? this.prisma.user.findMany({ where: { id: { in: reviewerIds } }, select: { id: true, username: true, displayName: 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 tenantById = new Map(tenants.map((item) => [item.id, item]));
const applicationById = new Map(applications.map((item) => [item.id, item])); const applicationById = new Map(applications.map((item) => [item.id, item]));
@@ -140,11 +233,11 @@ export class ReportImportReviewService {
items: batches.map((batch) => ({ items: batches.map((batch) => ({
...batch, ...batch,
tenant: tenantById.get(batch.tenantId) ?? null, tenant: tenantById.get(batch.tenantId) ?? null,
application: batch.applicationId ? applicationById.get(batch.applicationId) ?? null : null, application: batch.applicationId ? (applicationById.get(batch.applicationId) ?? null) : null,
reviewer: batch.reviewedById ? reviewerById.get(batch.reviewedById) ?? null : null, reviewer: batch.reviewedById ? (reviewerById.get(batch.reviewedById) ?? null) : null,
items: batch.items.map((item) => ({ items: batch.items.map((item) => ({
...item, ...item,
reviewer: item.reviewedById ? reviewerById.get(item.reviewedById) ?? null : null, reviewer: item.reviewedById ? (reviewerById.get(item.reviewedById) ?? null) : null,
})), })),
})), })),
total, total,
@@ -155,10 +248,16 @@ export class ReportImportReviewService {
async reviewImportItems(batchId: string, data: ReviewImportItemsDto) { async reviewImportItems(batchId: string, data: ReviewImportItemsDto) {
if (!data.reviewerId) throw new BadRequestException('Reviewer session is required'); if (!data.reviewerId) throw new BadRequestException('Reviewer session is required');
if (!['approve', 'reject'].includes(data.decision)) throw new BadRequestException('Unsupported import review decision'); if (!['approve', 'reject'].includes(data.decision))
throw new BadRequestException('Unsupported import review decision');
const batch = await this.prisma.reportMaterialImportBatch.findUnique({ const batch = await this.prisma.reportMaterialImportBatch.findUnique({
where: { id: batchId }, where: { id: batchId },
include: { items: { where: { id: data.itemIds?.length ? { in: data.itemIds } : undefined, status: 'pending_review' }, orderBy: { rowNumber: 'asc' } } }, include: {
items: {
where: { id: data.itemIds?.length ? { in: data.itemIds } : undefined, status: 'pending_review' },
orderBy: { rowNumber: 'asc' },
},
},
}); });
if (!batch) throw new NotFoundException('导入审核批次不存在'); if (!batch) throw new NotFoundException('导入审核批次不存在');
if (!batch.items.length) throw new BadRequestException('没有可审核的导入明细'); if (!batch.items.length) throw new BadRequestException('没有可审核的导入明细');
@@ -169,7 +268,12 @@ export class ReportImportReviewService {
if (data.decision === 'reject') { if (data.decision === 'reject') {
await this.prisma.reportMaterialImportItem.update({ await this.prisma.reportMaterialImportItem.update({
where: { id: item.id }, where: { id: item.id },
data: { status: 'rejected', reviewReason: data.reason?.trim(), reviewedById: data.reviewerId, reviewedAt: new Date() }, data: {
status: 'rejected',
reviewReason: data.reason?.trim(),
reviewedById: data.reviewerId,
reviewedAt: new Date(),
},
}); });
rejectedCount += 1; rejectedCount += 1;
continue; continue;
@@ -178,7 +282,14 @@ export class ReportImportReviewService {
const targetId = await this.applyImportItem(batch, item, data.reviewerId); const targetId = await this.applyImportItem(batch, item, data.reviewerId);
await this.prisma.reportMaterialImportItem.update({ await this.prisma.reportMaterialImportItem.update({
where: { id: item.id }, where: { id: item.id },
data: { targetId, status: 'approved', reviewReason: data.reason?.trim(), reviewedById: data.reviewerId, reviewedAt: new Date(), errorMessage: null }, data: {
targetId,
status: 'approved',
reviewReason: data.reason?.trim(),
reviewedById: data.reviewerId,
reviewedAt: new Date(),
errorMessage: null,
},
}); });
approvedCount += 1; approvedCount += 1;
} catch (error) { } catch (error) {
@@ -221,12 +332,19 @@ export class ReportImportReviewService {
return { batchId, status, approvedCount, rejectedCount, failedCount: failures.length, failures }; return { batchId, status, approvedCount, rejectedCount, failedCount: failures.length, failures };
} }
async stageSignatureRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record<string, unknown>) { async stageSignatureRow(
tenantId: string,
applicationId: string | undefined,
mappings: ImportMapping[],
values: Record<string, unknown>,
) {
const name = mappedCoreValue(mappings, values, 'signatureName'); const name = mappedCoreValue(mappings, values, 'signatureName');
if (!name) throw new Error('缺少短信签名'); if (!name) throw new Error('缺少短信签名');
const purpose = mappedCoreValue(mappings, values, 'purpose'); const purpose = mappedCorePatchValue(mappings, values, 'purpose');
const signatureReportValues = dynamicValues(mappings, values); const signatureReportValues = dynamicValues(mappings, values);
const existing = await this.prisma.smsSignature.findFirst({ where: { tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } } }); const existing = await this.prisma.smsSignature.findFirst({
where: { tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } },
});
return { return {
operation: existing ? 'update' : 'create', operation: existing ? 'update' : 'create',
targetId: existing?.id, targetId: existing?.id,
@@ -234,10 +352,11 @@ export class ReportImportReviewService {
tenantId, tenantId,
applicationId, applicationId,
name, name,
purpose, ...(purpose !== undefined ? { purpose } : {}),
drainageInfo: { ...jsonRecord(existing?.drainageInfo), signatureReportValues }, drainageInfo: { signatureReportValues },
}, },
originalSnapshot: existing ? { originalSnapshot: existing
? {
id: existing.id, id: existing.id,
applicationId: existing.applicationId, applicationId: existing.applicationId,
name: existing.name, name: existing.name,
@@ -245,24 +364,44 @@ export class ReportImportReviewService {
drainageInfo: existing.drainageInfo, drainageInfo: existing.drainageInfo,
auditStatus: existing.auditStatus, auditStatus: existing.auditStatus,
updatedAt: existing.updatedAt, updatedAt: existing.updatedAt,
} : undefined, }
: undefined,
}; };
} }
async stageDrainageRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record<string, unknown>) { async stageDrainageRow(
tenantId: string,
applicationId: string | undefined,
mappings: ImportMapping[],
values: Record<string, unknown>,
) {
const signatureName = mappedCoreValue(mappings, values, 'signatureName'); const signatureName = mappedCoreValue(mappings, values, 'signatureName');
const url = mappedCoreValue(mappings, values, 'url'); const url = mappedCoreValue(mappings, values, 'url');
if (!signatureName || !url) throw new Error('引流信息必须包含短信签名和引流 URL 或号码'); if (!signatureName || !url) throw new Error('引流信息必须包含短信签名和引流 URL 或号码');
const signature = await this.prisma.smsSignature.findFirst({ where: { tenantId, applicationId: applicationId ?? null, name: signatureName, auditStatus: 'approved' } }); const signature = await this.prisma.smsSignature.findFirst({
where: { tenantId, applicationId: applicationId ?? null, name: signatureName, auditStatus: 'approved' },
});
if (!signature) throw new Error(`未找到已审核签名:${signatureName}`); if (!signature) throw new Error(`未找到已审核签名:${signatureName}`);
const remark = mappedCoreValue(mappings, values, 'remark'); const remark = mappedCoreValue(mappings, values, 'remark');
const reportValues = dynamicValues(mappings, values); const reportValues = dynamicValues(mappings, values);
const existing = await this.prisma.smsDrainageInfo.findFirst({ where: { signatureId: signature.id, url, auditStatus: { not: 'deleted' } } }); const existing = await this.prisma.smsDrainageInfo.findFirst({
where: { signatureId: signature.id, url, auditStatus: { not: 'deleted' } },
});
return { return {
operation: existing ? 'update' : 'create', operation: existing ? 'update' : 'create',
targetId: existing?.id, targetId: existing?.id,
payload: { tenantId, applicationId, signatureId: signature.id, signatureName, siteName: url, url, remark, reportValues }, payload: {
originalSnapshot: existing ? { tenantId,
applicationId,
signatureId: signature.id,
signatureName,
siteName: url,
url,
remark,
reportValues,
},
originalSnapshot: existing
? {
id: existing.id, id: existing.id,
siteName: existing.siteName, siteName: existing.siteName,
url: existing.url, url: existing.url,
@@ -270,7 +409,8 @@ export class ReportImportReviewService {
reportValues: existing.reportValues, reportValues: existing.reportValues,
auditStatus: existing.auditStatus, auditStatus: existing.auditStatus,
updatedAt: existing.updatedAt, updatedAt: existing.updatedAt,
} : undefined, }
: undefined,
}; };
} }
@@ -283,26 +423,44 @@ export class ReportImportReviewService {
if (item.reportType === 'signature') { if (item.reportType === 'signature') {
const name = String(payload.name ?? ''); const name = String(payload.name ?? '');
const applicationId = typeof payload.applicationId === 'string' ? payload.applicationId : undefined; const applicationId = typeof payload.applicationId === 'string' ? payload.applicationId : undefined;
const body = { const importedDrainage = jsonRecord(payload.drainageInfo);
const importedReportValues = jsonRecord(importedDrainage.signatureReportValues);
const buildBody = (current?: { drainageInfo: Prisma.JsonValue | null }) => {
const currentDrainage = jsonRecord(current?.drainageInfo);
return {
applicationId, applicationId,
name, name,
purpose: typeof payload.purpose === 'string' ? payload.purpose : undefined, ...(Object.prototype.hasOwnProperty.call(payload, 'purpose')
drainageInfo: jsonRecord(payload.drainageInfo), ? { purpose: String(payload.purpose ?? '') }
: {}),
drainageInfo: {
...currentDrainage,
signatureReportValues: {
...jsonRecord(currentDrainage.signatureReportValues),
...importedReportValues,
},
},
};
}; };
let targetId = item.targetId; let targetId = item.targetId;
if (targetId) { if (targetId) {
const current = await this.prisma.smsSignature.findUnique({ where: { id: targetId } }); const current = await this.prisma.smsSignature.findUnique({ where: { id: targetId } });
if (!current || current.auditStatus === 'deleted') throw new Error('原签名已删除,不能应用导入修改'); if (!current || current.auditStatus === 'deleted') throw new Error('原签名已删除,不能应用导入修改');
await this.smsConfig.updateSignature(targetId, body, batch.tenantId); await this.smsConfig.updateSignature(targetId, buildBody(current), batch.tenantId);
} else { } else {
const duplicate = await this.prisma.smsSignature.findFirst({ const duplicate = await this.prisma.smsSignature.findFirst({
where: { tenantId: batch.tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } }, where: {
tenantId: batch.tenantId,
applicationId: applicationId ?? null,
name,
auditStatus: { not: 'deleted' },
},
}); });
if (duplicate) { if (duplicate) {
targetId = duplicate.id; targetId = duplicate.id;
await this.smsConfig.updateSignature(targetId, body, batch.tenantId); await this.smsConfig.updateSignature(targetId, buildBody(duplicate), batch.tenantId);
} else { } else {
const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...body }); const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...buildBody() });
targetId = created.id; targetId = created.id;
} }
} }
@@ -329,7 +487,12 @@ export class ReportImportReviewService {
targetId = duplicate.id; targetId = duplicate.id;
await this.smsConfig.updateDrainageInfo(targetId, body, { initialAuditStatus: 'pending' }, batch.tenantId); await this.smsConfig.updateDrainageInfo(targetId, body, { initialAuditStatus: 'pending' }, batch.tenantId);
} else { } else {
const created = await this.smsConfig.createDrainageInfo(signatureId, body, { initialAuditStatus: 'pending' }, batch.tenantId); const created = await this.smsConfig.createDrainageInfo(
signatureId,
body,
{ initialAuditStatus: 'pending' },
batch.tenantId,
);
targetId = created.id; targetId = created.id;
} }
} }
+146 -16
View File
@@ -6,15 +6,65 @@ import { extname } from 'node:path';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.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 {
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'; 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. */ /** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportPendingQueryService { 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) { async listPending(
query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery,
) {
const items = await this.findPendingItems(query); const items = await this.findPendingItems(query);
const page = normalizePage(query.page); const page = normalizePage(query.page);
const pageSize = normalizePageSize(query.pageSize); const pageSize = normalizePageSize(query.pageSize);
@@ -26,48 +76,128 @@ export class ReportPendingQueryService {
}; };
} }
async findPendingItems(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery) { async findPendingItems(
query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery,
) {
const changedAt = dateRange(query.startAt, query.endAt); const changedAt = dateRange(query.startAt, query.endAt);
const keyword = query.keyword?.trim(); const keyword = query.keyword?.trim();
const [signatures, drainageInfos] = await Promise.all([ const [signatures, drainageInfos] = await Promise.all([
query.reportType === 'drainage' ? Promise.resolve([]) : this.prisma.smsSignature.findMany({ query.reportType === 'drainage'
? Promise.resolve([])
: this.prisma.smsSignature.findMany({
where: { where: {
pendingReport: true, pendingReport: true,
auditStatus: 'approved', auditStatus: 'approved',
tenantId: query.tenantId, tenantId: query.tenantId,
applicationId: query.applicationId, applicationId: query.applicationId,
reportChangedAt: changedAt, reportChangedAt: changedAt,
OR: keyword ? [ OR: keyword
? [
{ name: { contains: keyword } }, { name: { contains: keyword } },
{ tenant: { name: { contains: keyword } } }, { tenant: { name: { contains: keyword } } },
{ application: { name: { contains: keyword } } }, { application: { name: { contains: keyword } } },
] : undefined, ]
: undefined,
}, },
include: { tenant: true, application: true }, include: { tenant: true, application: true, reportTasks: { where: { reportType: 'signature' } } },
orderBy: { reportChangedAt: 'desc' }, orderBy: { reportChangedAt: 'desc' },
}), }),
query.reportType === 'signature' ? Promise.resolve([]) : this.prisma.smsDrainageInfo.findMany({ query.reportType === 'signature'
? Promise.resolve([])
: this.prisma.smsDrainageInfo.findMany({
where: { where: {
pendingReport: true, pendingReport: true,
auditStatus: 'approved', auditStatus: 'approved',
tenantId: query.tenantId, tenantId: query.tenantId,
applicationId: query.applicationId, applicationId: query.applicationId,
reportChangedAt: changedAt, reportChangedAt: changedAt,
OR: keyword ? [ OR: keyword
? [
{ siteName: { contains: keyword } }, { siteName: { contains: keyword } },
{ url: { contains: keyword } }, { url: { contains: keyword } },
{ signature: { name: { contains: keyword } } }, { signature: { name: { contains: keyword } } },
{ tenant: { name: { contains: keyword } } }, { tenant: { name: { contains: keyword } } },
{ application: { name: { contains: keyword } } }, { application: { name: { contains: keyword } } },
] : undefined, ]
: undefined,
}, },
include: { tenant: true, application: true, signature: true }, include: { tenant: true, application: true, signature: true, reportTasks: true },
orderBy: { reportChangedAt: 'desc' }, 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 [ 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 })), ...signatures.map((item) => {
...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 })), 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()); ].sort((left, right) => new Date(right.changedAt).getTime() - new Date(left.changedAt).getTime());
} }
} }
@@ -49,10 +49,33 @@ export type PagedQuery = {
export interface CreateReportBatchDto { export interface CreateReportBatchDto {
createdById?: string; createdById?: string;
idempotencyKey?: 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 = { export type ReportBatchInspection = {
id: string; id: string;
@@ -1,10 +1,28 @@
import { BadRequestException, Body, Controller, Get, Param, Post, Put, Query, Res, UploadedFile, UseInterceptors } from '@nestjs/common'; import {
BadRequestException,
Body,
Controller,
Get,
Param,
Post,
Put,
Query,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator'; import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { ReportMaterialsService } from './report-materials.service'; 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 UploadedWorkbook = { originalname: string; mimetype: string; size: number; buffer: Buffer };
type DownloadResponse = { setHeader(name: string, value: string): void; send(content: Buffer): void }; type DownloadResponse = { setHeader(name: string, value: string): void; send(content: Buffer): void };
@@ -25,17 +43,37 @@ export class ReportMaterialsController {
@Query('page') page?: string, @Query('page') page?: string,
@Query('pageSize') pageSize?: 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') @Get('templates/:reportType')
async downloadTemplate(@Param('reportType') reportType: 'signature' | 'drainage', @CurrentSessionUserId() operatorId: string | undefined, @Res() response: DownloadResponse) { async downloadTemplate(
if (!['signature', 'drainage'].includes(reportType)) throw new BadRequestException('reportType must be signature or drainage'); @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)); this.sendWorkbook(response, await this.service.buildOfficialTemplate(reportType, operatorId));
} }
@Get('pending/export') @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)); this.sendWorkbook(response, await this.service.exportPending({ reportType, tenantId, applicationId }, operatorId));
} }
@@ -52,7 +90,11 @@ export class ReportMaterialsController {
@Post('imports/analyze') @Post('imports/analyze')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 12, parts: 13 } })) @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 12, parts: 13 } }))
analyzeImport(@UploadedFile() file: UploadedWorkbook, @Body() body: Record<string, string>, @CurrentSessionUserId() operatorId?: string) { analyzeImport(
@UploadedFile() file: UploadedWorkbook,
@Body() body: Record<string, string>,
@CurrentSessionUserId() operatorId?: string,
) {
if (!file) throw new BadRequestException('请选择 XLSX 文件'); if (!file) throw new BadRequestException('请选择 XLSX 文件');
return this.service.analyzeImport(file, { return this.service.analyzeImport(file, {
tenantId: body.tenantId, tenantId: body.tenantId,
@@ -82,12 +124,24 @@ export class ReportMaterialsController {
@Query('page') page?: string, @Query('page') page?: string,
@Query('pageSize') pageSize?: 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') @Post('imports/:id/review')
@RequireRecentAuthentication() @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 }); 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) }); 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') @Post('batches/preflight')
preflightBatch(@Body() body: CreateReportBatchDto) { preflightBatch(@Body() body: CreateReportBatchDto) {
return this.service.preflightBatch(body); return this.service.preflightBatch(body);
@@ -113,6 +192,20 @@ export class ReportMaterialsController {
return this.service.createBatch({ ...body, createdById: operatorId }); 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 }) { private sendWorkbook(response: DownloadResponse, exported: { fileName: string; content: Buffer }) {
response.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); response.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
@@ -6,7 +6,16 @@ import type { CreateImportProfileDto, EmbeddedImage, ImportMapping } from './rep
/** Pure workbook, mapping, pagination and export helpers shared by R4 domains. */ /** Pure workbook, mapping, pagination and export helpers shared by R4 domains. */
export function profileData(data: CreateImportProfileDto) { 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) { export function validateProfile(data: CreateImportProfileDto) {
@@ -24,7 +33,8 @@ export async function loadWorkbook(buffer: Buffer) {
export function assertSafeWorkbook(workbook: ExcelJS.Workbook) { export function assertSafeWorkbook(workbook: ExcelJS.Workbook) {
for (const worksheet of workbook.worksheets) { for (const worksheet of workbook.worksheets) {
worksheet.eachRow((row) => row.eachCell((cell) => { worksheet.eachRow((row) =>
row.eachCell((cell) => {
const value = cell.value; const value = cell.value;
if (value && typeof value === 'object' && ('formula' in value || 'sharedFormula' in value)) { if (value && typeof value === 'object' && ('formula' in value || 'sharedFormula' in value)) {
throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`); throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`);
@@ -33,7 +43,8 @@ export function assertSafeWorkbook(workbook: ExcelJS.Workbook) {
if (/^[=+@]/.test(text) || /^-[^\d.]/.test(text)) { if (/^[=+@]/.test(text) || /^-[^\d.]/.test(text)) {
throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`); throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`);
} }
})); }),
);
} }
} }
@@ -43,63 +54,130 @@ export function safeSpreadsheetText(value: unknown) {
} }
export function readEmbeddedImages(workbook: ExcelJS.Workbook, worksheet: ExcelJS.Worksheet): EmbeddedImage[] { 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 []; if (!getImages) return [];
return getImages.call(worksheet).flatMap((drawing) => { 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 []; if (!image) return [];
const row = (drawing.range.tl.nativeRow ?? drawing.range.tl.row ?? 0) + 1; 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 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 }] : []; 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) => { return columns.flatMap((column, index) => {
const normalized = normalizeHeader(`${column.sourceHeaderPath}/${column.sourceHeader}`); const normalized = normalizeHeader(`${column.sourceHeaderPath}/${column.sourceHeader}`);
const core = reportType === 'signature' ? signatureCoreMapping(normalized) : drainageCoreMapping(normalized); const core = reportType === 'signature' ? signatureCoreMapping(normalized) : drainageCoreMapping(normalized);
if (!core && !column.imageCount) return []; if (!core && !column.imageCount) return [];
return [{ sourceHeader: column.sourceHeader, sourceHeaderPath: column.sourceHeaderPath, sourceColumnIndex: column.sourceColumnIndex, targetFieldCode: core?.code ?? normalizeFieldCode(column.sourceHeader), targetKind: core?.kind ?? 'dynamic', fieldType: column.imageCount ? 'image' : 'string', required: Boolean(core?.required), sortOrder: (index + 1) * 10 }]; return [
{
sourceHeader: column.sourceHeader,
sourceHeaderPath: column.sourceHeaderPath,
sourceColumnIndex: column.sourceColumnIndex,
targetFieldCode: core?.code ?? normalizeFieldCode(column.sourceHeader),
targetKind: core?.kind ?? 'dynamic',
fieldType: column.imageCount ? 'image' : 'string',
required: Boolean(core?.required),
sortOrder: (index + 1) * 10,
},
];
}); });
} }
export function remapProfileColumns(profileColumns: ImportMapping[], sourceColumns: Array<{ sourceColumnIndex: number; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>): ImportMapping[] { export function remapProfileColumns(
profileColumns: ImportMapping[],
sourceColumns: Array<{
sourceColumnIndex: number;
sourceHeader: string;
sourceHeaderPath: string;
imageCount: number;
}>,
): ImportMapping[] {
const used = new Set<number>(); const used = new Set<number>();
return profileColumns.flatMap((profileColumn) => { return profileColumns.flatMap((profileColumn) => {
const headerPath = normalizeHeader(profileColumn.sourceHeaderPath || profileColumn.sourceHeader); const headerPath = normalizeHeader(profileColumn.sourceHeaderPath || profileColumn.sourceHeader);
const header = normalizeHeader(profileColumn.sourceHeader); const header = normalizeHeader(profileColumn.sourceHeader);
const source = sourceColumns.find((column) => !used.has(column.sourceColumnIndex) && normalizeHeader(column.sourceHeaderPath) === headerPath) const source =
?? sourceColumns.find((column) => !used.has(column.sourceColumnIndex) && normalizeHeader(column.sourceHeader) === header); 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 []; if (!source) return [];
used.add(source.sourceColumnIndex); 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: 'signature_name', kind: 'signatureName', required: true };
if (/用途|签名依据/.test(header)) return { code: 'purpose', kind: 'purpose' }; if (/用途|签名依据/.test(header)) return { code: 'purpose', kind: 'purpose' };
return undefined; 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 (/短信签名|签名名称/.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: 'site_name', kind: 'siteName' };
if (/备注|说明/.test(header)) return { code: 'remark', kind: 'remark' }; if (/备注|说明/.test(header)) return { code: 'remark', kind: 'remark' };
return undefined; 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) { export function dateRange(startAt?: string, endAt?: string) {
const start = startAt ? new Date(`${startAt}T00:00:00+08:00`) : undefined; 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 === 'number') return Number.isInteger(value) ? String(value) : String(value);
if (typeof value === 'string' || typeof value === 'boolean') return String(value).trim(); if (typeof value === 'string' || typeof value === 'boolean') return String(value).trim();
if ('result' in value && value.result !== undefined) return String(value.result ?? '').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(); if ('text' in value) return String(value.text).trim();
return cell.text.trim(); return cell.text.trim();
} }
@@ -128,20 +210,51 @@ export function transformValue(value: string, transform?: string) {
return value.trim(); return value.trim();
} }
export function mappedCoreValue(mappings: ImportMapping[], values: Record<string, unknown>, kind: ImportMapping['targetKind']) { export function mappedCoreValue(
mappings: ImportMapping[],
values: Record<string, unknown>,
kind: ImportMapping['targetKind'],
) {
const mapping = mappings.find((item) => item.targetKind === kind); const mapping = mappings.find((item) => item.targetKind === kind);
return mapping ? String(values[mapping.targetFieldCode] ?? '').trim() : ''; return mapping ? String(values[mapping.targetFieldCode] ?? '').trim() : '';
} }
export function dynamicValues(mappings: ImportMapping[], values: Record<string, unknown>) { export function mappedCorePatchValue(
return Object.fromEntries(mappings.filter((item) => item.targetKind === 'dynamic').map((item) => [item.targetFieldCode, values[item.targetFieldCode]])); mappings: ImportMapping[],
values: Record<string, unknown>,
kind: ImportMapping['targetKind'],
) {
const mapping = mappings.find((item) => item.targetKind === kind);
if (!mapping || !hasValue(values[mapping.targetFieldCode])) return undefined;
return String(values[mapping.targetFieldCode]).trim();
} }
export function jsonRecord(value: unknown): Record<string, unknown> { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}; } export function dynamicValues(mappings: ImportMapping[], values: Record<string, unknown>) {
return Object.fromEntries(
mappings
.filter((item) => item.targetKind === 'dynamic' && hasValue(values[item.targetFieldCode]))
.map((item) => [item.targetFieldCode, values[item.targetFieldCode]]),
);
}
export function hasValue(value: unknown) { return isFileRef(value) ? Boolean(value.fileObjectId) : value !== null && value !== undefined && String(value).trim().length > 0; } export function jsonRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
export function isFileRef(value: unknown): value is { fileObjectId: string; fileName: string; contentType?: string } { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && typeof (value as Record<string, unknown>).fileObjectId === 'string'; } export function hasValue(value: unknown) {
return isFileRef(value)
? Boolean(value.fileObjectId)
: value !== null && value !== undefined && String(value).trim().length > 0;
}
export function isFileRef(value: unknown): value is { fileObjectId: string; fileName: string; contentType?: string } {
return (
Boolean(value) &&
typeof value === 'object' &&
!Array.isArray(value) &&
typeof (value as Record<string, unknown>).fileObjectId === 'string'
);
}
export function resolveExportValue(snapshot: Record<string, unknown>, code: string, name?: string) { export function resolveExportValue(snapshot: Record<string, unknown>, code: string, name?: string) {
const values = jsonRecord(snapshot.values); const values = jsonRecord(snapshot.values);
@@ -149,9 +262,16 @@ export function resolveExportValue(snapshot: Record<string, unknown>, code: stri
const signature = jsonRecord(snapshot.signature); const signature = jsonRecord(snapshot.signature);
const drainage = jsonRecord(snapshot.drainage); const drainage = jsonRecord(snapshot.drainage);
const aliases: Record<string, unknown> = { const aliases: Record<string, unknown> = {
signature_name: signature.name, sign_name: signature.name, signatureName: signature.name, signature_name: signature.name,
purpose: signature.purpose, enterprise_name: signature.tenantName, company_name: signature.tenantName, sign_name: signature.name,
application_name: signature.applicationName, site_name: drainage.siteName, url: drainage.url, remark: drainage.remark, 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]; if (hasValue(aliases[code])) return aliases[code];
const semantic = normalizeHeader(`${code}/${name ?? ''}`); 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) { export function normalizeBatchIdempotencyKey(value?: string) {
const key = value?.trim(); 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; return key;
} }
@@ -1,7 +1,26 @@
import ExcelJS from 'exceljs'; import ExcelJS from 'exceljs';
import { ReportMaterialsService } from './report-materials.service'; import { ReportMaterialsService } from './report-materials.service';
import { mappedCorePatchValue } from './report-materials.helpers';
describe('ReportMaterialsService', () => { 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 () => { it('builds an official XLSX import template with documented signature columns', async () => {
const operationLog = { create: jest.fn().mockResolvedValue({ id: 'log-template' }) }; const operationLog = { create: jest.fn().mockResolvedValue({ id: 'log-template' }) };
const service = new ReportMaterialsService({ operationLog } as never, {} as never, {} as never); 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' }; sheet.getCell('A2').value = { formula: 'HYPERLINK("https://invalid.example","click")', result: 'click' };
const buffer = Buffer.from(await workbook.xlsx.writeBuffer()); const buffer = Buffer.from(await workbook.xlsx.writeBuffer());
const files = { upload: jest.fn() }; 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( await expect(
{ originalname: 'unsafe.xlsx', mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: buffer.length, buffer }, 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 }, { tenantId: 'tenant-1', reportType: 'signature', headerRowCount: 1, dataStartRow: 2 },
)).rejects.toThrow('公式或可执行单元格'); ),
).rejects.toThrow('公式或可执行单元格');
expect(files.upload).not.toHaveBeenCalled(); expect(files.upload).not.toHaveBeenCalled();
}); });
@@ -45,69 +75,232 @@ describe('ReportMaterialsService', () => {
const sheet = workbook.addWorksheet('签名资料'); const sheet = workbook.addWorksheet('签名资料');
sheet.addRow(['短信签名', '营业执照']); sheet.addRow(['短信签名', '营业执照']);
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 } }); sheet.addImage(imageId, { tl: { col: 1, row: 1 }, ext: { width: 80, height: 60 } });
const buffer = Buffer.from(await workbook.xlsx.writeBuffer()); const buffer = Buffer.from(await workbook.xlsx.writeBuffer());
const prisma = { const prisma = {
reportMaterialImportProfile: { findUnique: jest.fn().mockResolvedValue({ sheetName: '签名资料', columns: [{ sourceHeader: '短信签名', sourceHeaderPath: '短信签名', sourceColumnIndex: 9, targetFieldCode: 'signature_name', targetKind: 'signatureName', fieldType: 'string', required: true, sortOrder: 10 }] }) }, reportMaterialImportProfile: {
reportMaterialImportBatch: { create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'import-1', ...data })) }, findUnique: jest
.fn()
.mockResolvedValue({
sheetName: '签名资料',
columns: [
{
sourceHeader: '短信签名',
sourceHeaderPath: '短信签名',
sourceColumnIndex: 9,
targetFieldCode: 'signature_name',
targetKind: 'signatureName',
fieldType: 'string',
required: true,
sortOrder: 10,
},
],
}),
},
reportMaterialImportBatch: {
create: jest
.fn()
.mockImplementation(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'import-1', ...data }),
),
},
operationLog: { create: jest.fn().mockResolvedValue({ id: 'log-1' }) }, 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 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.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.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 () => { it('expands one selected signature to every routed channel and embeds images in each XLSX', async () => {
const uploadedWorkbooks: Buffer[] = []; const uploadedWorkbooks: Buffer[] = [];
let batchItemSequence = 0; let batchItemSequence = 0;
let exportSequence = 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 = { const prisma = {
$transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)), $transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)),
$executeRaw: jest.fn().mockResolvedValue(1), $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: { reportMaterialBatch: {
create: jest.fn().mockResolvedValue({ id: 'batch-1', batchNo: 'RB001' }), create: jest.fn().mockResolvedValue({ id: 'batch-1', batchNo: 'RB001' }),
update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'batch-1', batchNo: 'RB001', ...data })), update: jest
.fn()
.mockImplementation(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'batch-1', batchNo: 'RB001', ...data }),
),
}, },
smsSignature: { 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({}), update: jest.fn().mockResolvedValue({}),
}, },
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() }, 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 })) } }]) }, channelRouteRule: {
reportMaterialBatchItem: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn().mockImplementation(() => Promise.resolve({ id: `batch-item-${++batchItemSequence}` })) }, findMany: jest
smsChannel: { findUnique: jest.fn().mockImplementation(({ where }: { where: { id: string } }) => Promise.resolve(channels.find((channel) => channel.id === where.id))) }, .fn()
channelReportField: { findMany: jest.fn().mockResolvedValue([ .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 }, carrier: 'mobile',
]) }, group: {
channelSignatureReportTask: { findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: `task-${String(data.channelId)}`, ...data })), update: jest.fn() }, status: 'active',
items: channels.map((channel, index) => ({ priority: index, carrier: 'mobile', channel })),
},
},
]),
},
reportMaterialBatchItem: {
findMany: jest.fn().mockResolvedValue([]),
create: jest.fn().mockImplementation(() => Promise.resolve({ id: `batch-item-${++batchItemSequence}` })),
},
smsChannel: {
findUnique: jest
.fn()
.mockImplementation(({ where }: { where: { id: string } }) =>
Promise.resolve(channels.find((channel) => channel.id === where.id)),
),
},
channelReportField: {
findMany: jest.fn().mockResolvedValue([
{
code: 'sign',
name: '短信签名',
exportName: '通道签名',
required: true,
columnWidth: 18,
imageWidth: 120,
imageHeight: 80,
transform: null,
defaultValue: null,
},
{
code: 'license',
name: '营业执照',
exportName: '营业执照图片',
required: true,
columnWidth: 24,
imageWidth: 120,
imageHeight: 80,
transform: null,
defaultValue: null,
},
]),
},
channelSignatureReportTask: {
findMany: jest.fn().mockResolvedValue([]),
findFirst: jest.fn().mockResolvedValue(null),
create: jest
.fn()
.mockImplementation(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: `task-${String(data.channelId)}`, ...data }),
),
update: jest.fn(),
},
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({}) }, channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({}) },
reportExportFile: { create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: `export-${++exportSequence}`, ...data })) }, reportExportFile: {
create: jest
.fn()
.mockImplementation(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: `export-${++exportSequence}`, ...data }),
),
},
reportExportFileItem: { createMany: jest.fn().mockResolvedValue({ count: 1 }) }, reportExportFileItem: { createMany: jest.fn().mockResolvedValue({ count: 1 }) },
}; };
const files = { const files = {
getDownload: jest.fn().mockResolvedValue({ fileObject: { fileName: 'license.png', contentType: 'image/png' }, content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZsmAAAAAASUVORK5CYII=', 'base64') }), getDownload: jest
upload: jest.fn().mockImplementation((_options: unknown, file: { originalname: string; mimetype: string; buffer: Buffer }) => { .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); uploadedWorkbooks.push(file.buffer);
return Promise.resolve({ id: `file-${uploadedWorkbooks.length}`, fileName: file.originalname, contentType: file.mimetype }); 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 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(result).toMatchObject({ status: 'completed', channelCount: 2, fileCount: 2 });
expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledTimes(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); expect(uploadedWorkbooks).toHaveLength(2);
for (const buffer of uploadedWorkbooks) { for (const buffer of uploadedWorkbooks) {
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
@@ -123,24 +316,83 @@ describe('ReportMaterialsService', () => {
const prisma = { const prisma = {
$transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)), $transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)),
$executeRaw: jest.fn().mockResolvedValue(1), $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({}) }, operationLog: {
reportMaterialBatch: { create: jest.fn().mockResolvedValue({ id: 'batch-2' }), update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve(data)) }, findFirst: jest.fn().mockResolvedValue(null),
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() }, findUnique: jest.fn().mockResolvedValue({ detail: { fingerprint: 'fingerprint' } }),
create: jest.fn().mockResolvedValue({ id: 'operation-2' }),
update: jest.fn().mockResolvedValue({}),
},
reportMaterialBatch: {
create: jest.fn().mockResolvedValue({ id: 'batch-2' }),
update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve(data)),
},
smsSignature: {
findUnique: jest
.fn()
.mockResolvedValue({
id: 'signature-2',
tenantId: 'tenant-1',
applicationId: 'app-1',
name: '测试签名',
auditStatus: 'approved',
pendingReport: true,
materialVersion: 1,
drainageInfo: {},
tenant: { name: '企业' },
application: { name: '应用', status: 'active' },
}),
update: jest.fn(),
},
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() }, 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' } }] } }]) }, channelRouteRule: {
reportMaterialBatchItem: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn().mockResolvedValue({ id: 'batch-item-2' }) }, 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' }) }, smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-a', name: '通道A' }) },
channelReportField: { findMany: jest.fn().mockResolvedValue([]) }, channelReportField: { findMany: jest.fn().mockResolvedValue([]) },
channelSignatureReportTask: { findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'task-2', ...data })) }, channelSignatureReportTask: {
findMany: jest.fn().mockResolvedValue([]),
findFirst: jest.fn().mockResolvedValue(null),
create: jest
.fn()
.mockImplementation(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'task-2', ...data }),
),
},
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({}) }, channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({}) },
reportExportFile: { create: jest.fn().mockResolvedValue({ id: 'export-2' }) }, reportExportFile: { create: jest.fn().mockResolvedValue({ id: 'export-2' }) },
reportExportFileItem: { createMany: jest.fn() }, 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); 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 }] })) await expect(
.rejects.toMatchObject({ response: expect.objectContaining({ code: 'REPORT_BATCH_NOT_ELIGIBLE' }) }); 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.channelSignatureReportTask.create).not.toHaveBeenCalled();
expect(prisma.smsSignature.update).not.toHaveBeenCalled(); expect(prisma.smsSignature.update).not.toHaveBeenCalled();
@@ -150,15 +402,47 @@ describe('ReportMaterialsService', () => {
const prisma = { const prisma = {
$transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)), $transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)),
$executeRaw: jest.fn().mockResolvedValue(1), $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() }, reportMaterialBatch: { create: jest.fn() },
}; };
const service = new ReportMaterialsService(prisma as never, {} as never, {} as never); const service = new ReportMaterialsService(prisma as never, {} as never, {} as never);
const items = [{ reportType: 'signature' as const, signatureId: 'signature-1', materialVersion: 3 }]; const items = [{ reportType: 'signature' as const, signatureId: 'signature-1', materialVersion: 3 }];
const fingerprint = createFingerprint(items); 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(); expect(prisma.reportMaterialBatch.create).not.toHaveBeenCalled();
}); });
@@ -182,7 +466,11 @@ describe('ReportMaterialsService', () => {
sheetName: '签名资料', sheetName: '签名资料',
dataStartRow: 2, dataStartRow: 2,
}), }),
update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'import-review-1', ...data, items: stagedRows })), update: jest
.fn()
.mockImplementation(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'import-review-1', ...data, items: stagedRows }),
),
}, },
reportMaterialImportItem: { reportMaterialImportItem: {
createMany: jest.fn().mockImplementation(({ data }: { data: Array<Record<string, unknown>> }) => { createMany: jest.fn().mockImplementation(({ data }: { data: Array<Record<string, unknown>> }) => {
@@ -205,19 +493,34 @@ describe('ReportMaterialsService', () => {
const result = await service.commitImport('import-review-1', { const result = await service.commitImport('import-review-1', {
operatorId: 'operator-1', operatorId: 'operator-1',
mappings: [ 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(result).toMatchObject({ status: 'pending_review', successCount: 1, failedCount: 0 });
expect(stagedRows).toEqual([expect.objectContaining({ expect(stagedRows).toEqual([
expect.objectContaining({
rowNumber: 2, rowNumber: 2,
reportType: 'signature', reportType: 'signature',
operation: 'create', operation: 'create',
status: 'pending_review', status: 'pending_review',
payload: expect.objectContaining({ name: '待审签名', purpose: '验证码' }), payload: expect.objectContaining({ name: '待审签名', purpose: '验证码' }),
})]); }),
]);
expect(smsConfig.createSignature).not.toHaveBeenCalled(); expect(smsConfig.createSignature).not.toHaveBeenCalled();
expect(smsConfig.updateSignature).not.toHaveBeenCalled(); expect(smsConfig.updateSignature).not.toHaveBeenCalled();
expect(smsConfig.approveSignature).not.toHaveBeenCalled(); expect(smsConfig.approveSignature).not.toHaveBeenCalled();
@@ -239,11 +542,13 @@ describe('ReportMaterialsService', () => {
}; };
const service = new ReportMaterialsService(prisma as never, {} as never, {} as never); const service = new ReportMaterialsService(prisma as never, {} as never, {} as never);
await expect(service.reviewImportItems('import-review-2', { await expect(
service.reviewImportItems('import-review-2', {
decision: 'reject', decision: 'reject',
itemIds: ['item-1'], itemIds: ['item-1'],
reviewerId: 'reviewer-1', reviewerId: 'reviewer-1',
})).resolves.toMatchObject({ status: 'rejected', rejectedCount: 1, failedCount: 0 }); }),
).resolves.toMatchObject({ status: 'rejected', rejectedCount: 1, failedCount: 0 });
expect(prisma.reportMaterialImportItem.update).toHaveBeenCalledWith({ expect(prisma.reportMaterialImportItem.update).toHaveBeenCalledWith({
where: { id: 'item-1' }, where: { id: 'item-1' },
data: expect.objectContaining({ status: 'rejected', reviewReason: undefined, reviewedById: 'reviewer-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 () => { it('calculates generated batch totals and success rate from per-channel report tasks', async () => {
const prisma = { const prisma = {
reportMaterialBatch: { reportMaterialBatch: {
findMany: jest.fn().mockResolvedValue([{ findMany: jest.fn().mockResolvedValue([
{
id: 'batch-stats-1', id: 'batch-stats-1',
batchNo: 'RB-STATS-1', batchNo: 'RB-STATS-1',
exportFiles: [ exportFiles: [
{ items: [{ task: { id: 'task-1', status: 'approved' } }, { task: { id: 'task-2', status: 'rejected' } }] }, {
items: [{ task: { id: 'task-1', status: 'approved' } }, { task: { id: 'task-2', status: 'rejected' } }],
},
{ items: [{ task: { id: 'task-3', status: 'approved' } }] }, { items: [{ task: { id: 'task-3', status: 'approved' } }] },
], ],
items: [], items: [],
}]), },
]),
count: jest.fn().mockResolvedValue(1), 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 () => { it('rejects malformed preflight items as a readable 400 before Prisma is called', async () => {
const prisma = { smsSignature: { findUnique: jest.fn() } }; const prisma = { smsSignature: { findUnique: jest.fn() } };
const service = new ReportMaterialsService(prisma as never, {} as never, {} as never); 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(); expect(prisma.smsSignature.findUnique).not.toHaveBeenCalled();
}); });
}); });
function createFingerprint(items: Array<{ reportType: string; signatureId: string; materialVersion: number }>) { function createFingerprint(items: Array<{ reportType: string; signatureId: string; materialVersion: number }>) {
const { createHash } = require('node:crypto') as typeof import('node:crypto'); const { createHash } = require('node:crypto') as typeof import('node:crypto');
return createHash('sha256').update(JSON.stringify(items.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: null, materialVersion: item.materialVersion })).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))))).digest('hex'); return createHash('sha256')
.update(
JSON.stringify(
items
.map((item) => ({
reportType: item.reportType,
signatureId: item.signatureId,
drainageItemId: null,
materialVersion: item.materialVersion,
}))
.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))),
),
)
.digest('hex');
} }
@@ -2,7 +2,19 @@ import { Injectable } from '@nestjs/common';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.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 { ReportBatchGenerationService } from './batch-generation.service';
import { ReportBatchOperationService } from './batch-operation.service'; import { ReportBatchOperationService } from './batch-operation.service';
import { ReportChannelExportService } from './channel-export.service'; import { ReportChannelExportService } from './channel-export.service';
@@ -29,18 +41,29 @@ export class ReportMaterialsService {
this.importReview = new ReportImportReviewService(prisma, files, smsConfig, this.importParser); this.importReview = new ReportImportReviewService(prisma, files, smsConfig, this.importParser);
this.batchOperation = new ReportBatchOperationService(prisma, files, smsConfig); this.batchOperation = new ReportBatchOperationService(prisma, files, smsConfig);
this.channelExport = new ReportChannelExportService(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) { async buildOfficialTemplate(reportType: 'signature' | 'drainage', operatorId?: string) {
return this.officialExport.buildOfficialTemplate(reportType, operatorId); 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); 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); return this.pendingQuery.listPending(query);
} }
@@ -52,7 +75,10 @@ export class ReportMaterialsService {
return this.importParser.saveImportProfile(data); 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); return this.importParser.analyzeImport(file, options);
} }
@@ -72,6 +98,17 @@ export class ReportMaterialsService {
return this.batchGeneration.listBatches(query); 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) { async createBatch(data: CreateReportBatchDto) {
return this.batchGeneration.createBatch(data); return this.batchGeneration.createBatch(data);
} }
@@ -79,4 +116,12 @@ export class ReportMaterialsService {
async preflightBatch(data: Pick<CreateReportBatchDto, 'items'>) { async preflightBatch(data: Pick<CreateReportBatchDto, 'items'>) {
return this.batchGeneration.preflightBatch(data); 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);
}
} }
+379 -95
View File
@@ -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 { Prisma } from '@prisma/client';
import { randomInt, randomUUID } from 'node:crypto'; import { randomInt, randomUUID } from 'node:crypto';
import { isIpAllowed } from '../common/ip-allowlist'; import { isIpAllowed } from '../common/ip-allowlist';
import { assertMoneyUnits } from '../common/money'; import { assertMoneyUnits } from '../common/money';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { automaticDeliveryMode } from '../open-api/delivery-mode'; 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 type {
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'; 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 { SmsReportValidationService } from './report-validation.service';
import { SmsAuditService } from './audit.service'; import { SmsAuditService } from './audit.service';
import { shanghaiDateRange } from '../common/shanghai-date-range'; import { shanghaiDateRange } from '../common/shanghai-date-range';
@@ -15,10 +67,15 @@ import { normalizeChannelCarriers } from '../channels/channels.helpers';
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ /** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
export class SmsSignatureService { 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) { async listSignatures(queryOrTenantId?: string | SignatureListQuery) {
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {}; const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : (queryOrTenantId ?? {});
const signatureSort = query.signatureSort === 'asc' || query.signatureSort === 'desc' ? query.signatureSort : undefined; const signatureSort =
query.signatureSort === 'asc' || query.signatureSort === 'desc' ? query.signatureSort : undefined;
const signatures = await this.prisma.smsSignature.findMany({ const signatures = await this.prisma.smsSignature.findMany({
where: { where: {
tenantId: query.tenantId, tenantId: query.tenantId,
@@ -27,7 +84,8 @@ export class SmsSignatureService {
application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined,
name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined, name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined,
updatedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo), updatedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo),
drainageItems: query.drainageKeyword ? { drainageItems: query.drainageKeyword
? {
some: { some: {
auditStatus: { not: 'deleted' }, auditStatus: { not: 'deleted' },
OR: [ OR: [
@@ -36,13 +94,16 @@ export class SmsSignatureService {
{ remark: { contains: query.drainageKeyword } }, { remark: { contains: query.drainageKeyword } },
], ],
}, },
} : undefined, }
OR: query.keyword ? [ : undefined,
OR: query.keyword
? [
{ name: { contains: query.keyword } }, { name: { contains: query.keyword } },
{ purpose: { contains: query.keyword } }, { purpose: { contains: query.keyword } },
{ tenant: { name: { contains: query.keyword } } }, { tenant: { name: { contains: query.keyword } } },
{ application: { name: { contains: query.keyword } } }, { application: { name: { contains: query.keyword } } },
] : undefined, ]
: undefined,
}, },
include: { include: {
materials: true, materials: true,
@@ -50,25 +111,90 @@ export class SmsSignatureService {
application: true, application: true,
drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } }, drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } },
reportTasks: { include: { channel: true, drainageInfo: true } }, 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' }] orderBy: signatureSort ? [{ name: signatureSort }, { id: 'asc' }] : { createdAt: 'desc' },
: { createdAt: 'desc' }, ...(query.page && query.pageSize
...(query.page && query.pageSize ? { ? {
skip: (query.page - 1) * query.pageSize, skip: (query.page - 1) * query.pageSize,
take: query.pageSize, take: query.pageSize,
} : {}), }
: {}),
}); });
const applicationIds = signatures.map((signature) => signature.applicationId).filter((id): id is string => Boolean(id)); const applicationIds = signatures
const routes = applicationIds.length ? await this.prisma.channelRouteRule.findMany({ .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' }, where: { applicationId: { in: applicationIds }, status: 'active' },
include: { group: { include: { items: { include: { channel: { include: { reportFields: true } } } } } } }, include: { group: { include: { items: { include: { channel: { include: { reportFields: true } } } } } } },
}) : []; })
const hasCommonDrainageFields = await this.prisma.commonReportField.count({ : [];
const hasCommonDrainageFields = await this.prisma.commonReportField
.count({
where: { status: 'active', reportType: 'drainage', drainageField: { status: 'active' } }, where: { status: 'active', reportType: 'drainage', drainageField: { status: 'active' } },
}).then((count) => count > 0); })
.then((count) => count > 0);
return signatures.map((signature) => { return signatures.map((signature) => {
const { reportBatchItems: _reportBatchItems, ...signatureView } = signature;
const legacyPayload = isRecord(signature.drainageInfo) ? signature.drainageInfo : {}; 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<string>();
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) => ({ const drainageLinks = signature.drainageItems.map((item) => ({
id: item.id, id: item.id,
siteName: item.siteName, siteName: item.siteName,
@@ -83,55 +209,126 @@ export class SmsSignatureService {
updatedAt: item.updatedAt.toISOString(), updatedAt: item.updatedAt.toISOString(),
})); }));
return { return {
...signature, ...signatureView,
name: normalizeSmsSignature(signature.name), name: normalizeSmsSignature(signature.name),
drainageInfo: { ...legacyPayload, links: drainageLinks }, drainageInfo: { ...legacyPayload, links: drainageLinks },
reportTargets: (() => { 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 = signatureTasks;
const tasks = (signature.reportTasks ?? []).filter((task) => task.reportType === 'signature'); return applicationChannels.flatMap((channel) =>
return [...new Map(channels.map((channel) => [channel.id, channel])).values()].flatMap((channel) => (
normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => { normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => {
const task = tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier) const task =
?? tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === null && candidate.approvalScope === 'legacy_channel'); tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier) ??
return { channel, channelId: channel.id, carrier, status: task?.status ?? 'pending', taskId: task?.id, approvedAt: task?.approvedAt, approvalScope: task?.approvalScope ?? 'carrier_specific' }; 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) => { 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 drainageItemId = drainageItem.id;
const channels = routes const channels = routes
.filter((route) => route.applicationId === signature.applicationId && route.group) .filter((route) => route.applicationId === signature.applicationId && route.group)
.flatMap((route) => route.group!.items.map((item) => item.channel)) .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)))); .filter(
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task])); (channel) =>
return [drainageItemId, [...new Map(channels.map((channel) => [channel.id, channel])).values()].flatMap((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); const task = taskByChannel.get(channel.id);
return task ? [{ channel, channelId: channel.id, status: task.status, taskId: task.id }] : []; return task ? [{ channel, channelId: channel.id, status: task.status, taskId: task.id }] : [];
})]; }),
})), ];
drainageCarrierReportSummary: Object.fromEntries(signature.drainageItems.map((drainageItem) => { }),
),
drainageCarrierReportSummary: Object.fromEntries(
signature.drainageItems.map((drainageItem) => {
const drainageItemId = drainageItem.id; const drainageItemId = drainageItem.id;
const channels = routes const channels = routes
.filter((route) => route.applicationId === signature.applicationId && route.group) .filter((route) => route.applicationId === signature.applicationId && route.group)
.flatMap((route) => route.group!.items.map((item) => item.channel)) .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)))); .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 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])); const taskByChannel = new Map(
return [drainageItemId, Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => { (signature.reportTasks ?? [])
const carrierTargets = targets.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)); .filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId)
const statuses = carrierTargets.flatMap((channel) => taskByChannel.get(channel.id)?.status ? [taskByChannel.get(channel.id)!.status] : []); .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)]; 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)); }),
),
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 targets = [...new Map(configured.map((channel) => [channel.id, channel])).values()];
const signatureTasks = (signature.reportTasks ?? []).filter((task) => task.reportType === 'signature'); 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 const statuses = targets.map(
?? signatureTasks.find((task) => task.channelId === channel.id && task.carrier === null && task.approvalScope === 'legacy_channel')?.status (channel) =>
?? 'pending'); 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)]; return [carrier, summarizeReportStatuses(statuses)];
})), }),
),
}; };
}); });
} }
@@ -146,7 +343,8 @@ export class SmsSignatureService {
application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined,
name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined, name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined,
updatedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo), updatedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo),
drainageItems: query.drainageKeyword ? { drainageItems: query.drainageKeyword
? {
some: { some: {
auditStatus: { not: 'deleted' }, auditStatus: { not: 'deleted' },
OR: [ OR: [
@@ -155,13 +353,16 @@ export class SmsSignatureService {
{ remark: { contains: query.drainageKeyword } }, { remark: { contains: query.drainageKeyword } },
], ],
}, },
} : undefined, }
OR: query.keyword ? [ : undefined,
OR: query.keyword
? [
{ name: { contains: query.keyword } }, { name: { contains: query.keyword } },
{ purpose: { contains: query.keyword } }, { purpose: { contains: query.keyword } },
{ tenant: { name: { contains: query.keyword } } }, { tenant: { name: { contains: query.keyword } } },
{ application: { name: { contains: query.keyword } } }, { application: { name: { contains: query.keyword } } },
] : undefined, ]
: undefined,
}; };
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.listSignatures({ ...query, page, pageSize }), this.listSignatures({ ...query, page, pageSize }),
@@ -178,18 +379,27 @@ export class SmsSignatureService {
}); });
} }
async listClientSignatures(tenantId?: string, signatureId?: string, query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}) { async listClientSignatures(
tenantId?: string,
signatureId?: string,
query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {},
) {
const signatures = await this.prisma.smsSignature.findMany({ const signatures = await this.prisma.smsSignature.findMany({
where: { where: {
id: signatureId, id: signatureId,
tenantId, tenantId,
applicationId: query.applicationId, applicationId: query.applicationId,
auditStatus: { notIn: ['deleted', 'disabled'], ...(query.status && query.status !== 'all' ? { equals: query.status } : {}) }, auditStatus: {
OR: query.keyword?.trim() ? [ notIn: ['deleted', 'disabled'],
...(query.status && query.status !== 'all' ? { equals: query.status } : {}),
},
OR: query.keyword?.trim()
? [
{ name: { contains: query.keyword.trim() } }, { name: { contains: query.keyword.trim() } },
{ purpose: { contains: query.keyword.trim() } }, { purpose: { contains: query.keyword.trim() } },
{ application: { name: { contains: query.keyword.trim() } } }, { application: { name: { contains: query.keyword.trim() } } },
] : undefined, ]
: undefined,
}, },
select: { select: {
id: true, id: true,
@@ -227,7 +437,14 @@ export class SmsSignatureService {
}, },
}, },
reportTasks: { reportTasks: {
select: { channelId: true, carrier: true, status: true, approvalScope: true, reportType: true, drainageItemId: true }, select: {
channelId: true,
carrier: true,
status: true,
approvalScope: true,
reportType: true,
drainageItemId: true,
},
}, },
_count: { select: { reportMaterials: true } }, _count: { select: { reportMaterials: true } },
}, },
@@ -235,38 +452,77 @@ export class SmsSignatureService {
skip: query.page && query.pageSize ? (query.page - 1) * query.pageSize : undefined, skip: query.page && query.pageSize ? (query.page - 1) * query.pageSize : undefined,
take: query.pageSize, take: query.pageSize,
}); });
const applicationIds = signatures.map((signature) => signature.applicationId).filter((id): id is string => Boolean(id)); const applicationIds = signatures
const routes = applicationIds.length ? await this.prisma.channelRouteRule.findMany({ .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' }, where: { applicationId: { in: applicationIds }, status: 'active' },
include: { group: { include: { items: { include: { channel: { include: { reportFields: true } } } } } } }, include: { group: { include: { items: { include: { channel: { include: { reportFields: true } } } } } } },
}) : []; })
const hasCommonDrainageFields = await this.prisma.commonReportField.count({ : [];
const hasCommonDrainageFields = await this.prisma.commonReportField
.count({
where: { status: 'active', reportType: 'drainage', drainageField: { status: 'active' } }, where: { status: 'active', reportType: 'drainage', drainageField: { status: 'active' } },
}).then((count) => count > 0); })
.then((count) => count > 0);
return signatures.map((signature) => { return signatures.map((signature) => {
const stored = isRecord(signature.drainageInfo) ? signature.drainageInfo : {}; const stored = isRecord(signature.drainageInfo) ? signature.drainageInfo : {};
const applicationChannels = [...new Map(routes const applicationChannels = [
...new Map(
routes
.filter((route) => route.applicationId === signature.applicationId && route.group) .filter((route) => route.applicationId === signature.applicationId && route.group)
.flatMap((route) => route.group!.items.map((item) => item.channel)) .flatMap((route) => route.group!.items.map((item) => item.channel))
.filter((channel) => channel.status !== 'deleted') .filter((channel) => channel.status !== 'deleted')
.map((channel) => [channel.id, channel])).values()]; .map((channel) => [channel.id, channel]),
).values(),
];
const signatureTasks = signature.reportTasks.filter((task) => task.reportType === 'signature'); const signatureTasks = signature.reportTasks.filter((task) => task.reportType === 'signature');
const carrierReportSummary = Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => { const carrierReportSummary = Object.fromEntries(
const targets = applicationChannels.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)); ['mobile', 'unicom', 'telecom'].map((carrier) => {
const statuses = targets.map((channel) => signatureTasks.find((task) => task.channelId === channel.id && task.carrier === carrier)?.status const targets = applicationChannels.filter((channel) =>
?? signatureTasks.find((task) => task.channelId === channel.id && task.carrier === null && task.approvalScope === 'legacy_channel')?.status normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier),
?? 'pending'); );
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)]; 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 drainageCarrierReportSummary = Object.fromEntries(
const tasks = signature.reportTasks.filter((task) => task.reportType === 'drainage' && task.drainageItemId === item.id); signature.drainageItems.map((item) => {
return [item.id, Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => { const targets = applicationChannels.filter(
const carrierTargets = targets.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)); (channel) =>
const statuses = carrierTargets.map((channel) => tasks.find((task) => task.channelId === channel.id)?.status ?? 'pending'); 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 [carrier, summarizeReportStatuses(statuses)];
}))]; }),
})); ),
];
}),
);
return { return {
id: signature.id, id: signature.id,
tenantId: signature.tenantId, tenantId: signature.tenantId,
@@ -302,18 +558,26 @@ export class SmsSignatureService {
return signature; return signature;
} }
async getClientSignatureWorkspace(tenantId?: string, query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}) { 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 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 pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
const filteredWhere: Prisma.SmsSignatureWhereInput = { const filteredWhere: Prisma.SmsSignatureWhereInput = {
tenantId, tenantId,
applicationId: query.applicationId, applicationId: query.applicationId,
auditStatus: { notIn: ['deleted', 'disabled'], ...(query.status && query.status !== 'all' ? { equals: query.status } : {}) }, auditStatus: {
OR: query.keyword?.trim() ? [ notIn: ['deleted', 'disabled'],
...(query.status && query.status !== 'all' ? { equals: query.status } : {}),
},
OR: query.keyword?.trim()
? [
{ name: { contains: query.keyword.trim() } }, { name: { contains: query.keyword.trim() } },
{ purpose: { contains: query.keyword.trim() } }, { purpose: { contains: query.keyword.trim() } },
{ application: { name: { contains: query.keyword.trim() } } }, { application: { name: { contains: query.keyword.trim() } } },
] : undefined, ]
: undefined,
}; };
const [items, total, statusCounts] = await Promise.all([ const [items, total, statusCounts] = await Promise.all([
this.listClientSignatures(tenantId, undefined, { ...query, page, pageSize }), this.listClientSignatures(tenantId, undefined, { ...query, page, pageSize }),
@@ -337,7 +601,10 @@ export class SmsSignatureService {
async createSignature(data: CreateSmsSignatureDto, options: CreateSmsSignatureOptions = {}) { async createSignature(data: CreateSmsSignatureDto, options: CreateSmsSignatureOptions = {}) {
await this.reportValidation.validateSignatureReportValues(data.applicationId, data.drainageInfo); await this.reportValidation.validateSignatureReportValues(data.applicationId, data.drainageInfo);
const drainageInfo = await this.reportValidation.withReportRequirementSnapshot(data.applicationId, data.drainageInfo); const drainageInfo = await this.reportValidation.withReportRequirementSnapshot(
data.applicationId,
data.drainageInfo,
);
const name = validateCompleteSmsSignature(data.name); const name = validateCompleteSmsSignature(data.name);
const signature = await this.prisma.smsSignature.create({ const signature = await this.prisma.smsSignature.create({
data: { data: {
@@ -360,7 +627,11 @@ export class SmsSignatureService {
reason: '运营端新建签名自动审核通过', reason: '运营端新建签名自动审核通过',
}); });
} }
return signature; return {
...signature,
reportMaterialChanged: true,
reportPoolAvailableAfter: signature.auditStatus === 'approved' ? ('immediate' as const) : ('approval' as const),
};
} }
async updateSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) { async updateSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) {
@@ -368,16 +639,21 @@ export class SmsSignatureService {
if (!signature || (tenantId && signature.tenantId !== tenantId)) { if (!signature || (tenantId && signature.tenantId !== tenantId)) {
throw new NotFoundException('Signature not found'); throw new NotFoundException('Signature not found');
} }
await this.reportValidation.validateSignatureReportValues(data.applicationId ?? signature.applicationId ?? undefined, data.drainageInfo); await this.reportValidation.validateSignatureReportValues(
data.applicationId ?? signature.applicationId ?? undefined,
data.drainageInfo,
);
const applicationId = data.applicationId ?? signature.applicationId ?? undefined; const applicationId = data.applicationId ?? signature.applicationId ?? undefined;
const drainageInfo = data.drainageInfo const drainageInfo = data.drainageInfo
? await this.reportValidation.withReportRequirementSnapshot(applicationId, data.drainageInfo) ? await this.reportValidation.withReportRequirementSnapshot(applicationId, data.drainageInfo)
: undefined; : undefined;
const name = data.name === undefined ? undefined : validateCompleteSmsSignature(data.name); const name = data.name === undefined ? undefined : validateCompleteSmsSignature(data.name);
const materialChanged = (data.applicationId !== undefined && data.applicationId !== signature.applicationId) const materialChanged =
|| (name !== undefined && name !== normalizeSmsSignature(signature.name)) (data.applicationId !== undefined && data.applicationId !== signature.applicationId) ||
|| (data.purpose !== undefined && data.purpose !== signature.purpose) (name !== undefined && name !== normalizeSmsSignature(signature.name)) ||
|| (data.drainageInfo !== undefined && JSON.stringify(data.drainageInfo) !== JSON.stringify(signature.drainageInfo ?? null)); (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 auditStatus = materialChanged && signature.auditStatus === 'approved' ? 'pending' : data.auditStatus;
const updated = await this.prisma.smsSignature.update({ const updated = await this.prisma.smsSignature.update({
where: { id: signatureId }, where: { id: signatureId },
@@ -388,14 +664,22 @@ export class SmsSignatureService {
auditStatus, auditStatus,
rejectReason: auditStatus === 'pending' ? null : undefined, rejectReason: auditStatus === 'pending' ? null : undefined,
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
materialVersion: { increment: 1 }, materialVersion: materialChanged ? { increment: 1 } : undefined,
pendingReport: true, pendingReport: materialChanged ? true : undefined,
reportChangedAt: new Date(), reportChangedAt: materialChanged ? new Date() : undefined,
}, },
include: { materials: true, tenant: true, application: true }, include: { materials: true, tenant: true, application: true },
}); });
await this.reportValidation.syncSignatureReportValues(signatureId, updated.applicationId ?? undefined, drainageInfo); await this.reportValidation.syncSignatureReportValues(
return updated; 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) { async updateClientSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) {
@@ -796,13 +796,14 @@ describe('SmsConfigService', () => {
name: { contains: '签名' }, name: { contains: '签名' },
drainageItems: expect.objectContaining({ some: expect.objectContaining({ OR: expect.any(Array) }) }), drainageItems: expect.objectContaining({ some: expect.objectContaining({ OR: expect.any(Array) }) }),
}), }),
include: { include: expect.objectContaining({
materials: true, materials: true,
tenant: true, tenant: true,
application: true, application: true,
drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } }, drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } },
reportTasks: { include: { channel: true, drainageInfo: true } }, reportTasks: { include: { channel: true, drainageInfo: true } },
}, reportBatchItems: expect.any(Object),
}),
orderBy: [{ name: 'asc' }, { id: 'asc' }], orderBy: [{ name: 'asc' }, { id: 'asc' }],
})); }));
}); });
@@ -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. 状态记录性能索引、今日发送排序及待生成明细计数所需索引均以真实查询计划为准,不预先创建无证据索引或汇总表。
+16
View File
@@ -5003,3 +5003,19 @@ npm run verify:phase8
| TC-ENTERPRISE-SIGNATURE-DENSITY-005 | 查看签名及引流信息操作列 | 两级操作列都只展示“报备状态、编辑、删除”;不显示“报备详情”;三个入口继续调用原真实后端流程 | | TC-ENTERPRISE-SIGNATURE-DENSITY-005 | 查看签名及引流信息操作列 | 两级操作列都只展示“报备状态、编辑、删除”;不显示“报备详情”;三个入口继续调用原真实后端流程 |
| TC-ENTERPRISE-SIGNATURE-DENSITY-006 | 1600×1000桌面视口查看并展开首条签名 | 表头与数据列对齐,操作按钮不换行,展开表格无裁切;页面无框架错误层,控制台无相关错误;窄视口由列表容器横向滚动,不挤压错列 | | TC-ENTERPRISE-SIGNATURE-DENSITY-006 | 1600×1000桌面视口查看并展开首条签名 | 表头与数据列对齐,操作按钮不换行,展开表格无裁切;页面无框架错误层,控制台无相关错误;窄视口由列表容器横向滚动,不挤压错列 |
| TC-ENTERPRISE-SIGNATURE-DENSITY-007 | 某一运营商下所有当前目标通道的报备任务均为abandoned | 该签名及其引流信息在对应运营商列汇总为“放弃报备”,同时显示0/总通道数;仅部分通道放弃时不得误判为全部放弃 | | 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 |
+10
View File
@@ -4271,3 +4271,13 @@ git diff --check
- 工作站从测试环境实际下载主资源 `index-BNrNaR05.js``index-CMZPlsxo.css` 和企业签名页分块 `AdminEnterpriseSignaturesPage-D_Q97xUB.js`SHA-256分别为 `f93dd4a0732edd4cad1396dbc67b588b27ef1188912c67fc27f7c297498c160d``8c98d139dc3cc1846f1b114ed278f42118b0d65fa83c303d0f12086aa2ed9329``bfed281a0849e86edc2d1e7fb2d332b167643139df4effcf8a3d584849c2ca23`,与服务器产物一致。 - 工作站从测试环境实际下载主资源 `index-BNrNaR05.js``index-CMZPlsxo.css` 和企业签名页分块 `AdminEnterpriseSignaturesPage-D_Q97xUB.js`SHA-256分别为 `f93dd4a0732edd4cad1396dbc67b588b27ef1188912c67fc27f7c297498c160d``8c98d139dc3cc1846f1b114ed278f42118b0d65fa83c303d0f12086aa2ed9329``bfed281a0849e86edc2d1e7fb2d332b167643139df4effcf8a3d584849c2ca23`,与服务器产物一致。
- Browser插件不在本会话可用技能中,按前端调试流程使用工作区Playwright Chromium对本地最新构建做1600×1000视觉复核。页面身份、非空、无框架错误层、控制台和展开交互通过;可见签名及引流信息的状态+数量、“放弃报备”和上下三角排序。截图使用隔离视觉数据,仅用于设计展示,不冒充测试环境真实API/数据验收;测试环境真实鉴权页面截图未完成。 - 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/日志及真实页面验收在完成测试机认证后补记。
+344 -54
View File
@@ -1,5 +1,29 @@
import { request, requestBlob, requestForm, withQuery } from '../core/httpClient'; 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'; import { assertUploadFileSize } from '@/utils/fileUpload';
// Report generation consumes channel report fields, so these endpoints keep one // Report generation consumes channel report fields, so these endpoints keep one
@@ -8,21 +32,41 @@ export const adminChannelsReportsApi = {
listChannels: () => request<AdminChannel[]>('/admin/channels'), listChannels: () => request<AdminChannel[]>('/admin/channels'),
listChannelsPage: (query: { keyword?: string; carrier?: string; status?: string; page: number; pageSize: number }) => listChannelsPage: (query: { keyword?: string; carrier?: string; status?: string; page: number; pageSize: number }) =>
request<PagedResult<AdminChannel>>(withQuery('/admin/channels', query)), request<PagedResult<AdminChannel>>(withQuery('/admin/channels', query)),
createChannel: (body: Partial<AdminChannel> & { passwordCipher?: string; desiredConnections?: number; windowSize?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number }) => createChannel: (
request<AdminChannel>('/admin/channels', { method: 'POST', body: JSON.stringify(body) }), body: Partial<AdminChannel> & {
updateChannel: (id: string, body: Partial<AdminChannel> & { passwordCipher?: string; desiredConnections?: number; windowSize?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number }) => passwordCipher?: string;
request<AdminChannel>(`/admin/channels/${id}`, { method: 'PUT', body: JSON.stringify(body) }), desiredConnections?: number;
copyChannel: (id: string, body: { operatorId?: string } = {}) => request<AdminChannel>(`/admin/channels/${id}/copy`, { windowSize?: number;
heartbeatIntervalSeconds?: number;
heartbeatMissThreshold?: number;
},
) => request<AdminChannel>('/admin/channels', { method: 'POST', body: JSON.stringify(body) }),
updateChannel: (
id: string,
body: Partial<AdminChannel> & {
passwordCipher?: string;
desiredConnections?: number;
windowSize?: number;
heartbeatIntervalSeconds?: number;
heartbeatMissThreshold?: number;
},
) => request<AdminChannel>(`/admin/channels/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
copyChannel: (id: string, body: { operatorId?: string } = {}) =>
request<AdminChannel>(`/admin/channels/${id}/copy`, {
method: 'POST', method: 'POST',
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
testChannel: (id: string, body: { phoneNumber?: string; phones?: string[] | string; content: string; accessNo?: string }) => testChannel: (
request<ChannelTestResponse>(`/admin/channels/${id}/test`, { method: 'POST', body: JSON.stringify(body) }), id: string,
changeChannelStatus: (id: string, status: string, reason?: string) => request<AdminChannel>(`/admin/channels/${id}/status`, { body: { phoneNumber?: string; phones?: string[] | string; content: string; accessNo?: string },
) => request<ChannelTestResponse>(`/admin/channels/${id}/test`, { method: 'POST', body: JSON.stringify(body) }),
changeChannelStatus: (id: string, status: string, reason?: string) =>
request<AdminChannel>(`/admin/channels/${id}/status`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ status, reason }), body: JSON.stringify({ status, reason }),
}), }),
deleteChannel: (id: string, reason?: string) => request<AdminChannel>(`/admin/channels/${id}`, { deleteChannel: (id: string, reason?: string) =>
request<AdminChannel>(`/admin/channels/${id}`, {
method: 'DELETE', method: 'DELETE',
body: JSON.stringify({ reason }), body: JSON.stringify({ reason }),
}), }),
@@ -30,66 +74,312 @@ export const adminChannelsReportsApi = {
request<DeletionPreflight>(`/admin/deletions/${type}/${id}/preflight`), request<DeletionPreflight>(`/admin/deletions/${type}/${id}/preflight`),
deleteGovernedTarget: (type: DeletionTargetType, id: string, body: DeleteTargetRequest) => deleteGovernedTarget: (type: DeletionTargetType, id: string, body: DeleteTargetRequest) =>
request<DeletionResult>(`/admin/deletions/${type}/${id}`, { method: 'POST', body: JSON.stringify(body) }), request<DeletionResult>(`/admin/deletions/${type}/${id}`, { method: 'POST', body: JSON.stringify(body) }),
listChannelConnectionLogs: (id: string) => request<ChannelConnectionLogResponse>(`/admin/channels/${id}/connection-logs`), listChannelConnectionLogs: (id: string) =>
request<ChannelConnectionLogResponse>(`/admin/channels/${id}/connection-logs`),
listChannelGroups: () => request<ChannelGroup[]>('/admin/channel-groups'), listChannelGroups: () => request<ChannelGroup[]>('/admin/channel-groups'),
createChannelGroup: (body: { code: string; name: string; carrier: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number }) => createChannelGroup: (body: {
request<ChannelGroup>('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }), code: string;
updateChannelGroup: (id: string, body: { code?: string; name?: string; carrier?: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number; items?: Array<Record<string, unknown>> }) => name: string;
request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'PUT', body: JSON.stringify(body) }), carrier: 'mobile' | 'unicom' | 'telecom';
description?: string;
status?: string;
retryEnabled?: boolean;
retryTimeLimitHours?: number;
retryTimeLimitMinutes?: number;
}) => request<ChannelGroup>('/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<Record<string, unknown>>;
},
) => request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
getChannelGroupDeletionImpact: (id: string) => getChannelGroupDeletionImpact: (id: string) =>
request<ChannelGroupDeletionImpact>(`/admin/channel-groups/${id}/deletion-impact`), request<ChannelGroupDeletionImpact>(`/admin/channel-groups/${id}/deletion-impact`),
deleteChannelGroup: (id: string) => deleteChannelGroup: (id: string) => request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'DELETE' }),
request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'DELETE' }),
addChannelGroupItem: (body: Record<string, unknown>) => addChannelGroupItem: (body: Record<string, unknown>) =>
request<DictionaryItem>('/admin/channel-groups/items', { method: 'POST', body: JSON.stringify(body) }), request<DictionaryItem>('/admin/channel-groups/items', { method: 'POST', body: JSON.stringify(body) }),
listChannelRouteRules: () => request<DictionaryItem[]>('/admin/channel-route-rules'), listChannelRouteRules: () => request<DictionaryItem[]>('/admin/channel-route-rules'),
createChannelRouteRule: (body: { tenantId?: string; applicationId: string; groupId: string; carrier: string; priority?: number; status?: string }) => createChannelRouteRule: (body: {
request<DictionaryItem>('/admin/channel-route-rules', { method: 'POST', body: JSON.stringify(body) }), tenantId?: string;
applicationId: string;
groupId: string;
carrier: string;
priority?: number;
status?: string;
}) => request<DictionaryItem>('/admin/channel-route-rules', { method: 'POST', body: JSON.stringify(body) }),
listChannelConnections: (id: string) => request<CmppConnectionState[]>(`/admin/channels/${id}/connections`), listChannelConnections: (id: string) => request<CmppConnectionState[]>(`/admin/channels/${id}/connections`),
replaceApplicationRouteRules: (applicationId: string, body: { routes: Array<{ carrier: 'mobile' | 'unicom' | 'telecom'; groupId: string; priority?: number; status?: string }> }) => replaceApplicationRouteRules: (
request<DictionaryItem[]>(`/admin/enterprise-applications/${applicationId}/route-rules`, { method: 'PUT', body: JSON.stringify(body) }), applicationId: string,
listChannelReportFields: (channelId?: string) => request<ChannelReportField[]>(withQuery('/admin/channel-report-fields', { channelId })), body: {
routes: Array<{ carrier: 'mobile' | 'unicom' | 'telecom'; groupId: string; priority?: number; status?: string }>;
},
) =>
request<DictionaryItem[]>(`/admin/enterprise-applications/${applicationId}/route-rules`, {
method: 'PUT',
body: JSON.stringify(body),
}),
listChannelReportFields: (channelId?: string) =>
request<ChannelReportField[]>(withQuery('/admin/channel-report-fields', { channelId })),
createChannelReportField: (body: Record<string, unknown>) => createChannelReportField: (body: Record<string, unknown>) =>
request<ChannelReportField>('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }), request<ChannelReportField>('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }),
replaceChannelReportFields: (channelId: string, reportType: 'signature' | 'drainage', fields: Array<Record<string, unknown>>) => replaceChannelReportFields: (
request<ChannelReportField[]>(`/admin/channels/${channelId}/report-fields/${reportType}`, { method: 'PUT', body: JSON.stringify({ fields }) }), channelId: string,
listPendingReportMaterials: (query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string; keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}) => reportType: 'signature' | 'drainage',
request<PagedResult<ReportMaterialPendingItem>>(withQuery('/admin/report-materials/pending', query)), fields: Array<Record<string, unknown>>,
) =>
request<ChannelReportField[]>(`/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<PagedResult<ReportMaterialPendingItem>>(withQuery('/admin/report-materials/pending', query)),
listReportImportProfiles: (reportType?: 'signature' | 'drainage') => listReportImportProfiles: (reportType?: 'signature' | 'drainage') =>
request<ReportImportProfile[]>(withQuery('/admin/report-materials/import-profiles', { reportType })), request<ReportImportProfile[]>(withQuery('/admin/report-materials/import-profiles', { reportType })),
saveReportImportProfile: (body: Omit<ReportImportProfile, 'id'> & { id?: string }) => saveReportImportProfile: (body: Omit<ReportImportProfile, 'id'> & { id?: string }) =>
request<ReportImportProfile>('/admin/report-materials/import-profiles', { method: 'POST', body: JSON.stringify(body) }), request<ReportImportProfile>('/admin/report-materials/import-profiles', {
analyzeReportMaterialImport: (file: File, body: { tenantId: string; applicationId?: string; reportType: 'signature' | 'drainage'; sheetName?: string; headerRowCount?: number; dataStartRow?: number; profileId?: string }) => { 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); assertUploadFileSize(file);
const form = new FormData(); const form = new FormData();
form.set('file', file); form.set('file', file);
Object.entries(body).forEach(([key, value]) => { if (value !== undefined) form.set(key, String(value)); }); Object.entries(body).forEach(([key, value]) => {
return requestForm<Record<string, unknown> & { id: string; columns: Array<{ sourceColumnIndex: number; columnLetter: string; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>; rows: Array<Record<string, unknown>>; suggestedMappings: ReportImportMapping[] }>('/admin/report-materials/imports/analyze', form); if (value !== undefined) form.set(key, String(value));
});
return requestForm<
Record<string, unknown> & {
id: string;
columns: Array<{
sourceColumnIndex: number;
columnLetter: string;
sourceHeader: string;
sourceHeaderPath: string;
imageCount: number;
}>;
rows: Array<Record<string, unknown>>;
suggestedMappings: ReportImportMapping[];
}
>('/admin/report-materials/imports/analyze', form);
}, },
commitReportMaterialImport: (id: string, body: { mappings: ReportImportMapping[]; profile?: Omit<ReportImportProfile, 'id'> & { id?: string } }) => commitReportMaterialImport: (
request<Record<string, unknown>>(`/admin/report-materials/imports/${id}/commit`, { method: 'PUT', body: JSON.stringify(body) }), id: string,
listReportImportReviewBatches: (query: { reportType?: 'signature' | 'drainage'; status?: string; keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}) => body: { mappings: ReportImportMapping[]; profile?: Omit<ReportImportProfile, 'id'> & { id?: string } },
) =>
request<Record<string, unknown>>(`/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<PagedResult<ReportImportReviewBatch>>(withQuery('/admin/report-materials/imports/review-batches', query)), request<PagedResult<ReportImportReviewBatch>>(withQuery('/admin/report-materials/imports/review-batches', query)),
reviewReportImportItems: (id: string, body: { decision: 'approve' | 'reject'; itemIds?: string[]; reason?: string }) => reviewReportImportItems: (
request<{ batchId: string; status: string; approvedCount: number; rejectedCount: number; failedCount: number }>(`/admin/report-materials/imports/${id}/review`, { method: 'POST', body: JSON.stringify(body) }), id: string,
listReportMaterialBatches: (query: { keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}) => body: { decision: 'approve' | 'reject'; itemIds?: string[]; reason?: string },
request<PagedResult<ReportMaterialBatch>>(withQuery('/admin/report-materials/batches', query)), ) =>
preflightReportMaterialBatch: (body: { items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion?: number }> }) => request<{ batchId: string; status: string; approvedCount: number; rejectedCount: number; failedCount: number }>(
request<ReportMaterialBatchPreflight>('/admin/report-materials/batches/preflight', { method: 'POST', body: JSON.stringify(body) }), `/admin/report-materials/imports/${id}/review`,
createReportMaterialBatch: (body: { idempotencyKey: string; items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion: number }> }) => { method: 'POST', body: JSON.stringify(body) },
request<ReportMaterialBatchResult>('/admin/report-materials/batches', { method: 'POST', body: JSON.stringify(body) }), ),
listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {}) => request<ReportTask[]>(withQuery('/admin/report-tasks', query)), listReportMaterialBatches: (
listReportTasksPage: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage'; keyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) => query: { keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {},
request<PagedResult<ReportTask>>(withQuery('/admin/report-tasks', query)), ) => request<PagedResult<ReportMaterialBatch>>(withQuery('/admin/report-materials/batches', query)),
createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; carrier?: 'mobile' | 'unicom' | 'telecom'; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) => getReportMaterialBatch: (id: string) => request<ReportMaterialBatch>(`/admin/report-materials/batches/${id}`),
request<ReportTask>('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }), listReportMaterialBatchTasks: (
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' }) => id: string,
request<Array<{ signatureId: string; reportStatus: string; carrierReportSummary: Record<string, { status: string; approved: number; total: number }> }>>('/admin/report-tasks/status-change', { method: 'POST', body: JSON.stringify(body) }), query: {
keyword?: string;
reportType?: 'signature' | 'drainage';
status?: string;
channelId?: string;
page?: number;
pageSize?: number;
} = {},
) => request<PagedResult<ReportTask>>(withQuery(`/admin/report-materials/batches/${id}/tasks`, query)),
preflightReportMaterialBatch: (body: {
items: Array<{
reportType: 'signature' | 'drainage';
signatureId: string;
drainageItemId?: string;
materialVersion?: number;
}>;
}) =>
request<ReportMaterialBatchPreflight>('/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<ReportMaterialBatchResult>('/admin/report-materials/batches', {
method: 'POST',
body: JSON.stringify(body),
}),
listReportTasks: (
query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {},
) => request<ReportTask[]>(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<PagedResult<ReportTask>>(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<PagedResult<ReportTask>>(withQuery('/admin/report-details', query)),
getSingleReportMaterialDetail: (body: {
reportType?: 'signature' | 'drainage';
signatureId: string;
channelId: string;
carrier?: 'mobile' | 'unicom' | 'telecom';
drainageItemId?: string;
batchItemId?: string;
}) =>
request<SingleReportMaterialDetail>('/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<ReportTask>('/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<string, { status: string; approved: number; total: number }>;
}>
>('/admin/report-tasks/status-change', { method: 'POST', body: JSON.stringify(body) }),
createReportExport: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number }) => createReportExport: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number }) =>
request<Record<string, unknown>>(`/admin/report-tasks/${id}/export`, { method: 'POST', body: JSON.stringify(body) }), request<Record<string, unknown>>(`/admin/report-tasks/${id}/export`, {
importReportReceipt: (id: string, body: { fileObjectId?: string; fileName: string; fileContent?: string; delimiter?: ',' | '\t'; rowCount?: number; successCount?: number; failedCount?: number; statusAfter?: string; reason?: string; result?: Record<string, unknown> }) => method: 'POST',
request<Record<string, unknown>>(`/admin/report-tasks/${id}/receipt-import`, { method: 'POST', body: JSON.stringify(body) }), body: JSON.stringify(body),
listReportRecords: (query: { taskId?: string; channelId?: string } = {}) => request<ReportRecord[]>(withQuery('/admin/report-records', query)), }),
listReportRecordsPage: (query: { taskId?: string; channelId?: string; keyword?: string; reportType?: 'signature' | 'drainage'; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) => importReportReceipt: (
request<PagedResult<ReportRecord>>(withQuery('/admin/report-records', query)), id: string,
body: {
fileObjectId?: string;
fileName: string;
fileContent?: string;
delimiter?: ',' | '\t';
rowCount?: number;
successCount?: number;
failedCount?: number;
statusAfter?: string;
reason?: string;
result?: Record<string, unknown>;
},
) =>
request<Record<string, unknown>>(`/admin/report-tasks/${id}/receipt-import`, {
method: 'POST',
body: JSON.stringify(body),
}),
listReportRecords: (query: { taskId?: string; channelId?: string } = {}) =>
request<ReportRecord[]>(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<PagedResult<ReportRecord>>(withQuery('/admin/report-records', query)),
}; };
+84 -5
View File
@@ -18,7 +18,15 @@ export type AdminChannel = {
rateLimitPerSecond: number; rateLimitPerSecond: number;
unitPrice: number; unitPrice: number;
status: string; 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[]; connectionStates?: CmppConnectionState[];
}; };
@@ -118,6 +126,14 @@ export type ReportMaterialPendingItem = {
signatureName?: string; signatureName?: string;
tenant?: TenantOption; tenant?: TenantOption;
application?: ClientSmsApplication | null; application?: ClientSmsApplication | null;
statusSummary?: {
total: number;
pending: number;
reporting: number;
approved: number;
failed: number;
abandoned: number;
};
}; };
export type ReportMaterialBatch = { export type ReportMaterialBatch = {
@@ -132,7 +148,47 @@ export type ReportMaterialBatch = {
successRate: number; successRate: number;
createdAt: string; createdAt: string;
completedAt?: string | null; 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 = { export type ReportImportReviewItem = {
@@ -245,7 +301,16 @@ export type ApplicationReportField = {
description?: string | null; description?: string | null;
reportTypes: string[]; reportTypes: string[];
commonReportTypes?: Array<'signature' | 'drainage'>; 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<ApplicationReportField, 'channels' | 'commonReportTypes'>; export type ClientApplicationReportField = Omit<ApplicationReportField, 'channels' | 'commonReportTypes'>;
@@ -259,6 +324,7 @@ export type CommonReportField = DictionaryItem & {
}; };
export type ReportTask = DictionaryItem & { export type ReportTask = DictionaryItem & {
virtual?: boolean;
tenantId: string; tenantId: string;
signatureId: string; signatureId: string;
channelId: string; channelId: string;
@@ -277,14 +343,26 @@ export type ReportTask = DictionaryItem & {
application?: { id: string; name: string } | null; application?: { id: string; name: string } | null;
}; };
drainageInfo?: SmsDrainageInfo | 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; reason?: string | null;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
exportItems?: Array<{ exportItems?: Array<{
id: string; id: string;
rowNumber: number; 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 } }; batchItem: { id: string; materialVersion: number; batch: { id: string; batchNo: string; createdAt: string } };
}>; }>;
records?: Array<{ records?: Array<{
@@ -320,4 +398,5 @@ export type ReportRecord = DictionaryItem & {
sourceEntry?: 'system' | 'legacy' | 'enterprise_signature' | 'report_task' | 'channel_report'; sourceEntry?: 'system' | 'legacy' | 'enterprise_signature' | 'report_task' | 'channel_report';
channel?: AdminChannel; channel?: AdminChannel;
task?: ReportTask; task?: ReportTask;
operator?: { id: string; username: string; displayName: string };
}; };
+133 -17
View File
@@ -138,12 +138,29 @@ export type PendingAuditCounts = {
export type DashboardResponse = { export type DashboardResponse = {
taskCount: number; taskCount: number;
messageStatus: Array<{ status: string; _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } }>; messageStatus: Array<{
today: { sent: number; delivered: number; failed: number; unknown: number; successRate: number; spendCents: number; returnedCents: number; billingUnits: number }; 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; uplinkCount: number;
billing: { _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } }; billing: { _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } };
transactions: { _count: { _all: number }; _sum: { amountCents?: 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; pendingAuditCount: number;
pendingAudits: PendingAuditCounts; pendingAudits: PendingAuditCounts;
hourlySendTrend: Array<{ hour: number; label: string; submittedCount: number; successCount: number }>; hourlySendTrend: Array<{ hour: number; label: string; submittedCount: number; successCount: number }>;
@@ -157,8 +174,21 @@ export type DashboardResponse = {
recentFailed: number; recentFailed: number;
alertCount: number; alertCount: number;
}; };
accounts: Array<{ id: string; tenantId: string; balanceCents: number; creditCents: number; status: string; tenant?: TenantOption }>; accounts: Array<{
enterpriseSpendRanks: Array<{ tenantId: string; tenantName: string; todaySpendCents: number; balanceCents: number; creditCents: number }>; 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<Record<string, unknown>>; recentTasks: Array<Record<string, unknown>>;
recentRecharges: Array<RechargeOrder>; recentRecharges: Array<RechargeOrder>;
clientOverview?: { clientOverview?: {
@@ -239,22 +269,58 @@ export type ClientSmsSignature = {
tenant?: TenantOption; tenant?: TenantOption;
application?: ClientSmsApplication | null; application?: ClientSmsApplication | null;
reportStatus?: string; reportStatus?: string;
materialVersion?: number;
pendingReport?: boolean;
reportChangedAt?: string;
reportMaterialChanged?: boolean;
reportPoolAvailableAfter?: 'immediate' | 'approval';
pendingReportDetailCount?: number;
pendingReportMaterialVersion?: number | null;
pendingReportBlockedReason?: string | null;
reportTasks?: Array<ReportTask & { channel?: AdminChannel }>; reportTasks?: Array<ReportTask & { channel?: AdminChannel }>;
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 }>; carrierReportSummary?: Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>;
drainageReportTargets?: Record<string, Array<{ channel: AdminChannel; channelId: string; status: string; taskId?: string }>>; drainageReportTargets?: Record<
drainageCarrierReportSummary?: Record<string, Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>>; 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<ClientSmsSignature, export type ClientSmsSignatureView = Pick<
'id' | 'tenantId' | 'applicationId' | 'name' | 'purpose' | 'auditStatus' | 'reportStatus' | 'rejectReason' | 'createdAt' | 'updatedAt' | 'materials' | 'carrierReportSummary' | 'drainageCarrierReportSummary' ClientSmsSignature,
| 'id'
| 'tenantId'
| 'applicationId'
| 'name'
| 'purpose'
| 'auditStatus'
| 'reportStatus'
| 'rejectReason'
| 'createdAt'
| 'updatedAt'
| 'materials'
| 'carrierReportSummary'
| 'drainageCarrierReportSummary'
> & { > & {
pendingReport?: boolean; pendingReport?: boolean;
reportChangedAt?: string; reportChangedAt?: string;
application?: Pick<ClientSmsApplication, 'id' | 'name' | 'status'> | null; application?: Pick<ClientSmsApplication, 'id' | 'name' | 'status'> | null;
submittedMaterialCount: number; submittedMaterialCount: number;
reportValues: Record<string, unknown>; reportValues: Record<string, unknown>;
drainageInfo: { links: Array<{ drainageInfo: {
links: Array<{
id: string; id: string;
siteName: string; siteName: string;
url: string; url: string;
@@ -266,7 +332,8 @@ export type ClientSmsSignatureView = Pick<ClientSmsSignature,
reviewedAt?: string | null; reviewedAt?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}> }; }>;
};
}; };
export type ClientSignatureWorkspace = { export type ClientSignatureWorkspace = {
@@ -394,15 +461,64 @@ export type HttpApiConfig = {
allowClientTest: boolean; 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 = { export type EnterpriseApplication = {
id: string; id: string;
+485 -60
View File
@@ -1,14 +1,29 @@
import { useEffect, useMemo, useState } from 'react'; 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 { useNavigate, useParams } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelReportField, type ClientSmsSignature, type DictionaryItem, type ReportRecord, type ReportTask } from '@/api/adminApi'; import {
import { Breadcrumb, Button, CarrierTag, Input, Modal, Select, Tag, Textarea } from '@/components/ui'; 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 { formatDateTime } from '@/utils/dateTime';
import { successRateClassName } from '@/utils/successRate'; import { successRateClassName } from '@/utils/successRate';
import { ReportFieldMappingModal } from './ReportFieldMappingModal'; import { ReportFieldMappingModal } from './ReportFieldMappingModal';
type ReportType = 'signature' | 'drainage'; type ReportType = 'signature' | 'drainage';
type DrainageItem = Record<string, unknown> & { id?: string; url?: string; siteName?: string; submittedAt?: string; remark?: string }; type DrainageItem = Record<string, unknown> & {
id?: string;
url?: string;
siteName?: string;
submittedAt?: string;
remark?: string;
};
const statusMeta: Record<string, { label: string; tone: 'success' | 'danger' | 'warning' | 'neutral' }> = { const statusMeta: Record<string, { label: string; tone: 'success' | 'danger' | 'warning' | 'neutral' }> = {
approved: { label: '报备成功', tone: 'success' }, approved: { label: '报备成功', tone: 'success' },
@@ -25,21 +40,29 @@ const statusMeta: Record<string, { label: string; tone: 'success' | 'danger' | '
}; };
function asRecord(value: unknown): Record<string, unknown> { function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}; return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
} }
function formatSignatureName(value?: string | null) { function formatSignatureName(value?: string | null) {
const name = String(value ?? '-').trim().replace(/^[【\[]+|[】\]]+$/g, ''); const name = String(value ?? '-')
.trim()
.replace(/^[【[]+|[】\]]+$/g, '');
return `${name || '-'}`; return `${name || '-'}`;
} }
function drainageItems(signature?: ClientSmsSignature) { function drainageItems(signature?: ClientSmsSignature) {
const payload = asRecord(signature?.drainageInfo); 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 }) { function DateTime({ value }: { value?: unknown }) {
return value ? <span className="channel-report-date">{formatDateTime(String(value))}</span> : <span className="muted">-</span>; return value ? (
<span className="channel-report-date">{formatDateTime(String(value))}</span>
) : (
<span className="muted">-</span>
);
} }
function ReportStatus({ value }: { value?: string }) { function ReportStatus({ value }: { value?: string }) {
@@ -58,31 +81,118 @@ function DeliveryStats({ task }: { task: ReportTask }) {
failureCount: 0, failureCount: 0,
failureRate: 0, failureRate: 0,
}; };
return <div className="channel-report-stats"> return (
<span><strong className={successRateClassName(stats.successRate)}>{stats.successRate}%</strong><b>{stats.successCount.toLocaleString('zh-CN')}</b></span> <div className="channel-report-stats">
<span><strong>{stats.unknownRate}%</strong><b>{stats.unknownCount.toLocaleString('zh-CN')}</b></span> <span>
<span><strong>{stats.failureRate}%</strong><b>{stats.failureCount.toLocaleString('zh-CN')}</b></span> <strong className={successRateClassName(stats.successRate)}>{stats.successRate}%</strong>
<span><strong>{stats.submitFailureRate}%</strong><b>{stats.submitFailureCount.toLocaleString('zh-CN')}</b></span> <b>{stats.successCount.toLocaleString('zh-CN')}</b>
</div>; </span>
<span>
<strong>{stats.unknownRate}%</strong>
<b>{stats.unknownCount.toLocaleString('zh-CN')}</b>
</span>
<span>
<strong>{stats.failureRate}%</strong>
<b>{stats.failureCount.toLocaleString('zh-CN')}</b>
</span>
<span>
<strong>{stats.submitFailureRate}%</strong>
<b>{stats.submitFailureCount.toLocaleString('zh-CN')}</b>
</span>
</div>
);
} }
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 payload = asRecord(signature?.drainageInfo);
const profile = asRecord(payload.signatureProfile); const profile = asRecord(payload.signatureProfile);
const reportValues = asRecord(drainage ? drainage.reportValues : payload.signatureReportValues); const reportValues = asRecord(drainage ? drainage.reportValues : payload.signatureReportValues);
return ( return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title={drainage ? '查看引流信息详情' : '查看签名详情'}> <Modal
footer={<Button onClick={onClose}></Button>}
onClose={onClose}
open
size="xl"
title={drainage ? '查看引流信息详情' : '查看签名详情'}
>
<div className="channel-report-detail"> <div className="channel-report-detail">
<strong>{drainage ? String(drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong> <strong>
<p><span></span><span>{signature?.tenant?.name ?? task.tenantId}</span></p> {drainage ? String(drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}
<p><span></span><span>{signature?.application?.name ?? '-'}</span></p> </strong>
<p><span></span><DateTime value={drainage?.submittedAt ?? task.createdAt} /></p> <p>
<p><span></span><DateTime value={reportedAt} /></p> <span></span>
<p><span></span><DateTime value={task.lastSuccessfulSentAt} /></p> <span>{signature?.tenant?.name ?? task.tenantId}</span>
{!drainage ? <><p><span></span><span>{String(profile.basis ?? '-')}</span></p><p><span></span><span>{String(profile.companyName ?? '-')}</span></p><p><span></span><span>{String(profile.creditCode ?? '-')}</span></p></> : null} </p>
{drainage ? <><p><span> URL </span><span>{String(drainage.url ?? '-')}</span></p><p><span></span><span>{String(drainage.remark ?? '-')}</span></p></> : null} <p>
{Object.entries(reportValues).map(([key, value]) => <p key={key}><span>{key}</span><span>{typeof value === 'object' ? String(asRecord(value).fileName ?? asRecord(value).fileObjectId ?? '-') : String(value ?? '-')}</span></p>)} <span></span>
<section><h3></h3><DeliveryStats task={task} /></section> <span>{signature?.application?.name ?? '-'}</span>
</p>
<p>
<span></span>
<DateTime value={drainage?.submittedAt ?? task.createdAt} />
</p>
<p>
<span></span>
<DateTime value={reportedAt} />
</p>
<p>
<span></span>
<DateTime value={task.lastSuccessfulSentAt} />
</p>
{!drainage ? (
<>
<p>
<span></span>
<span>{String(profile.basis ?? '-')}</span>
</p>
<p>
<span></span>
<span>{String(profile.companyName ?? '-')}</span>
</p>
<p>
<span></span>
<span>{String(profile.creditCode ?? '-')}</span>
</p>
</>
) : null}
{drainage ? (
<>
<p>
<span> URL </span>
<span>{String(drainage.url ?? '-')}</span>
</p>
<p>
<span></span>
<span>{String(drainage.remark ?? '-')}</span>
</p>
</>
) : null}
{Object.entries(reportValues).map(([key, value]) => (
<p key={key}>
<span>{key}</span>
<span>
{typeof value === 'object'
? String(asRecord(value).fileName ?? asRecord(value).fileObjectId ?? '-')
: String(value ?? '-')}
</span>
</p>
))}
<section>
<h3></h3>
<DeliveryStats task={task} />
</section>
</div> </div>
</Modal> </Modal>
); );
@@ -99,7 +209,19 @@ export function AdminChannelReportPage() {
const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]); const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]);
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('all'); 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<SingleReportMaterialDetail>();
const [detail, setDetail] = useState<{
task: ReportTask;
reportedAt?: string | null;
signature?: ClientSmsSignature;
drainage?: DrainageItem;
}>();
const [statusTask, setStatusTask] = useState<ReportTask>(); const [statusTask, setStatusTask] = useState<ReportTask>();
const [nextStatus, setNextStatus] = useState('approved'); const [nextStatus, setNextStatus] = useState('approved');
const [statusReason, setStatusReason] = useState(''); const [statusReason, setStatusReason] = useState('');
@@ -109,31 +231,77 @@ export function AdminChannelReportPage() {
function loadData() { function loadData() {
Promise.all([ Promise.all([
adminApi.listChannels(), 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.listReportRecords({ channelId }),
adminApi.listEnterpriseSignatures(), adminApi.listEnterpriseSignatures(),
adminApi.listChannelReportFields(channelId), adminApi.listChannelReportFields(channelId),
adminApi.listDrainageFields(), adminApi.listDrainageFields(),
]).then(([channelItems, taskItems, recordItems, signatureItems, fieldItems, libraryItems]) => { ])
.then(([channelItems, taskPage, recordItems, signatureItems, fieldItems, libraryItems]) => {
setChannel(channelItems.find((item) => item.id === channelId)); setChannel(channelItems.find((item) => item.id === channelId));
setTasks(taskItems); setTasks(taskPage.items);
setTotal(taskPage.total);
setRecords(recordItems); setRecords(recordItems);
setSignatures(signatureItems); setSignatures(signatureItems);
setFields(fieldItems); setFields(fieldItems);
setLibraryFields(libraryItems.filter((item) => item.status === 'active')); setLibraryFields(libraryItems.filter((item) => item.status === 'active'));
setError(''); setError('');
}).catch((failure: Error) => setError(failure.message || '通道报备详情加载失败')); })
.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 signatureMap = useMemo(() => new Map(signatures.map((item) => [item.id, item])), [signatures]);
const visibleTasks = useMemo(() => tasks.filter((task) => { const visibleTasks = tasks;
const signature = signatureMap.get(task.signatureId);
const drainage = drainageItems(signature).find((item) => String(item.id) === task.drainageItemId); async function openMaterial(task: ReportTask) {
const matchesKeyword = !keyword.trim() || [signature?.name, signature?.tenant?.name, signature?.application?.name, drainage?.siteName, drainage?.url].some((value) => String(value ?? '').includes(keyword.trim())); try {
return matchesKeyword && (status === 'all' || task.status === status); setMaterial(
}), [keyword, signatureMap, status, tasks]); 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) { function approvedRecord(taskId: string) {
return records.find((record) => record.taskId === taskId && record.statusAfter === 'approved'); return records.find((record) => record.taskId === taskId && record.statusAfter === 'approved');
@@ -147,8 +315,26 @@ export function AdminChannelReportPage() {
function saveTaskStatus() { function saveTaskStatus() {
if (!statusTask) return; 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' }) adminApi
.then(() => { setStatusTask(undefined); setStatusReason(''); loadData(); }) .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 || '报备状态保存失败')); .catch((failure: Error) => setError(failure.message || '报备状态保存失败'));
} }
@@ -157,48 +343,287 @@ export function AdminChannelReportPage() {
<div className="surface channel-report-hero"> <div className="surface channel-report-hero">
<Breadcrumb items={['通道管理', '短信通道', '报备详情']} /> <Breadcrumb items={['通道管理', '短信通道', '报备详情']} />
<div className="channel-report-heading"> <div className="channel-report-heading">
<Button icon={<ArrowLeft size={16} />} onClick={() => navigate('/admin/channels')} variant="ghost"></Button> <Button icon={<ArrowLeft size={16} />} onClick={() => navigate('/admin/channels')} variant="ghost">
</Button>
<h1>{channel?.name ?? '通道报备详情'}</h1> <h1>{channel?.name ?? '通道报备详情'}</h1>
<div className="channel-report-config-actions"> <div className="channel-report-config-actions">
<Button icon={<FileSliders size={16} />} onClick={() => setConfigType('signature')} variant="ghost"></Button> <Button icon={<FileSliders size={16} />} onClick={() => setConfigType('signature')} variant="ghost">
<Button icon={<FileSliders size={16} />} onClick={() => setConfigType('drainage')} variant="ghost"></Button>
</Button>
<Button icon={<FileSliders size={16} />} onClick={() => setConfigType('drainage')} variant="ghost">
</Button>
</div> </div>
</div> </div>
<div className="muted">{channel?.code ?? channelId} · {fields.length} </div> <div className="muted">
{channel?.code ?? channelId} · {fields.length}
</div>
</div> </div>
{error ? <p className="form-error">{error}</p> : null} {error ? <p className="form-error">{error}</p> : null}
<div className="surface channel-report-filter"> <div className="surface channel-report-filter">
<div className="channel-report-filter-grid"> <div className="channel-report-filter-grid">
<Input label="签名/企业/应用" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键词" prefix={<Search size={16} />} value={keyword} /> <Input
<Select label="报备状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, ...Object.entries(statusMeta).filter(([value]) => ['approved', 'failed', 'pending', 'waiting_material', 'exporting', 'partial_success'].includes(value)).map(([value, meta]) => ({ label: meta.label, value }))]} value={status} /> label="签名/企业/应用"
<div><strong></strong><p className="muted"></p></div> onChange={(event) => setKeyword(event.target.value)}
placeholder="请输入关键词"
prefix={<Search size={16} />}
value={keyword}
/>
<Select
label="报备状态"
onChange={(event) => setStatus(event.target.value)}
options={[
{ label: '全部状态', value: 'all' },
...Object.entries(statusMeta)
.filter(([value]) =>
['approved', 'failed', 'pending', 'waiting_material', 'exporting', 'partial_success'].includes(value),
)
.map(([value, meta]) => ({ label: meta.label, value })),
]}
value={status}
/>
<Select
label="运营商"
onChange={(event) => setCarrier(event.target.value)}
options={[
{ label: '全部运营商', value: 'all' },
{ label: '移动', value: 'mobile' },
{ label: '联通', value: 'unicom' },
{ label: '电信', value: 'telecom' },
]}
value={carrier}
/>
<Input
label="今日发送最小条数"
min="0"
onChange={(event) => setTodaySendMin(event.target.value)}
type="number"
value={todaySendMin}
/>
<Input
label="今日发送最大条数"
min="0"
onChange={(event) => setTodaySendMax(event.target.value)}
type="number"
value={todaySendMax}
/>
</div>
<div className="channel-report-filter-footer">
<span> {total} </span>
<div>
<Button
onClick={() => {
setKeyword('');
setStatus('all');
setCarrier('all');
setTodaySendMin('');
setTodaySendMax('');
}}
variant="ghost"
>
</Button>
<Button
icon={<Search size={16} />}
onClick={() => {
if (page !== 1) setPage(1);
else loadData();
}}
>
</Button>
</div>
</div> </div>
<div className="channel-report-filter-footer"><span> {visibleTasks.length} </span><div><Button onClick={() => { setKeyword(''); setStatus('all'); }} variant="ghost"></Button><Button icon={<Search size={16} />} onClick={loadData}></Button></div></div>
</div> </div>
<div className="surface channel-report-table"> <div className="surface channel-report-table">
<div className="channel-report-table__head"><span /><span> / </span><span></span><span></span><span></span><span></span><span></span><span></span></div> <div className="channel-report-table__head">
{visibleTasks.length === 0 ? <div className="channel-report-empty"></div> : 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 <div className={`channel-report-row ${drainage ? 'channel-report-row--drainage' : 'channel-report-row--signature'}`} key={task.id}>
<span /> <span />
<div className={`channel-report-name ${drainage ? 'channel-report-name--flow' : ''}`}>{drainage ? <i /> : null}<span><strong>{drainage ? String(drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong><small>{drainage ? formatSignatureName(signature?.name ?? task.signature?.name) : <>{signature?.tenant?.name ?? task.tenantId} · {task.carrier ? <CarrierTag carrier={task.carrier} /> : '历史通道级(未拆分)'}</>}</small></span></div> <span> / </span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{visibleTasks.length === 0 ? (
<div className="channel-report-empty"></div>
) : (
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 (
<div
className={`channel-report-row ${drainage ? 'channel-report-row--drainage' : 'channel-report-row--signature'}`}
key={task.id}
>
<span />
<div className={`channel-report-name ${drainage ? 'channel-report-name--flow' : ''}`}>
{drainage ? <i /> : null}
<span>
<strong>
{drainage
? String(drainage.url || '引流信息')
: formatSignatureName(signature?.name ?? task.signature?.name)}
</strong>
<small>
{drainage ? (
formatSignatureName(signature?.name ?? task.signature?.name)
) : (
<>
{signature?.tenant?.name ?? task.tenantId} ·{' '}
{task.carrier ? <CarrierTag carrier={task.carrier} /> : '历史通道级(未拆分)'}
</>
)}
</small>
</span>
</div>
<ReportStatus value={task.status} /> <ReportStatus value={task.status} />
<DateTime value={drainage?.submittedAt ?? task.createdAt} /> <DateTime value={drainage?.submittedAt ?? task.createdAt} />
<DateTime value={reportedAt} /> <DateTime value={reportedAt} />
<DateTime value={task.lastSuccessfulSentAt} /> <DateTime value={task.lastSuccessfulSentAt} />
<DeliveryStats task={task} /> <DeliveryStats task={task} />
<div className="channel-report-actions"><button onClick={() => setDetail({ task, reportedAt, signature, drainage })} type="button"><Eye size={16} /></button><button className="is-warning" onClick={() => { setStatusTask(task); setNextStatus(task.status); }} type="button"></button></div> <div className="channel-report-actions">
</div>; <button onClick={() => void openMaterial(task)} type="button">
})} <Eye size={16} />
</button>
{task.reportType !== 'drainage' ? (
<button onClick={() => void exportMaterial(task)} type="button">
<Download size={16} />
</button>
) : null}
<button
className="is-warning"
onClick={() => {
setStatusTask(task);
setNextStatus(task.status);
}}
type="button"
>
</button>
</div> </div>
</div>
);
})
)}
</div>
<Pagination
nextDisabled={page * pageSize >= 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 ? <DetailModal {...detail} onClose={() => setDetail(undefined)} /> : null} {detail ? <DetailModal {...detail} onClose={() => setDetail(undefined)} /> : null}
<Modal footer={<><Button onClick={() => setStatusTask(undefined)} variant="ghost"></Button><Button onClick={saveTaskStatus}></Button></>} onClose={() => setStatusTask(undefined)} open={Boolean(statusTask)} title="修改当前通道报备状态"><div className="admin-system-modal-form"><Select label="报备状态" onChange={(event) => setNextStatus(event.target.value)} options={[{label:'未报备',value:'pending'},{label:'资料待补充',value:'waiting_material'},{label:'报备中',value:'reporting'},{label:'报备通过',value:'approved'},{label:'报备失败',value:'failed'},{label:'放弃报备',value:'abandoned'}]} value={nextStatus}/><Textarea label="修改原因" onChange={(event) => setStatusReason(event.target.value)} rows={3} value={statusReason}/></div></Modal> {material ? (
{configType ? <ReportFieldMappingModal fields={fields} libraryFields={libraryFields} onClose={() => setConfigType(undefined)} onSave={saveFieldMapping} reportType={configType} /> : null} <Modal
footer={<Button onClick={() => setMaterial(undefined)}></Button>}
onClose={() => setMaterial(undefined)}
open
size="xl"
title="查看报备资料"
>
<div className="page-stack">
<div className="detail-grid">
<div>
<span></span>
<strong>{material.signatureName}</strong>
</div>
<div>
<span></span>
<strong>
{material.tenant.name} · {material.application?.name ?? '-'}
</strong>
</div>
<div>
<span>/</span>
<strong>
{material.channel.name} · V{material.materialVersion}
</strong>
</div>
</div>
<div className="report-material-detail-list">
{material.fields.map((field) => (
<div className={field.missing ? 'is-missing' : ''} key={field.id}>
<span>
{field.exportName || field.name}
{field.required ? ' *' : ''}
</span>
<strong>
{typeof field.value === 'object'
? String((field.value as Record<string, unknown>)?.fileName ?? '-')
: String(field.value ?? '-')}
</strong>
</div>
))}
{material.historicalFields.map((field) => (
<div key={field.code}>
<span>{field.name}</span>
<strong>{String(field.value ?? '-')}</strong>
</div>
))}
</div>
</div>
</Modal>
) : null}
<Modal
footer={
<>
<Button onClick={() => setStatusTask(undefined)} variant="ghost">
</Button>
<Button onClick={saveTaskStatus}></Button>
</>
}
onClose={() => setStatusTask(undefined)}
open={Boolean(statusTask)}
title="修改当前通道报备状态"
>
<div className="admin-system-modal-form">
<Select
label="报备状态"
onChange={(event) => setNextStatus(event.target.value)}
options={[
{ label: '未报备', value: 'pending' },
{ label: '资料待补充', value: 'waiting_material' },
{ label: '报备中', value: 'reporting' },
{ label: '报备通过', value: 'approved' },
{ label: '报备失败', value: 'failed' },
{ label: '放弃报备', value: 'abandoned' },
]}
value={nextStatus}
/>
<Textarea
label="修改原因"
onChange={(event) => setStatusReason(event.target.value)}
rows={3}
value={statusReason}
/>
</div>
</Modal>
{configType ? (
<ReportFieldMappingModal
fields={fields}
libraryFields={libraryFields}
onClose={() => setConfigType(undefined)}
onSave={saveFieldMapping}
reportType={configType}
/>
) : null}
</section> </section>
); );
} }
+172 -30
View File
@@ -1,22 +1,36 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { FileSpreadsheet, Plus, Search } from 'lucide-react'; import { FileSpreadsheet, Plus, Search } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type TenantOption } from '@/api/adminApi'; import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Tabs } from '@/components/ui'; import { Breadcrumb, Button, Input, Modal, Tabs } from '@/components/ui';
import { ReportMaterialImportModal } from './ReportMaterialImportModal'; import { ReportMaterialImportModal } from './ReportMaterialImportModal';
import { DrainageFormModal } from './enterprise-signatures/DrainageFormModal'; import { DrainageFormModal } from './enterprise-signatures/DrainageFormModal';
import { EnterpriseSignaturesTable } from './enterprise-signatures/EnterpriseSignaturesTable'; import { EnterpriseSignaturesTable } from './enterprise-signatures/EnterpriseSignaturesTable';
import { SignatureFormModal } from './enterprise-signatures/SignatureFormModal'; import { SignatureFormModal } from './enterprise-signatures/SignatureFormModal';
import { ChannelReportStatusModal, ConfirmModal, DrainageReportStatusModal } from './enterprise-signatures/SignatureReportModals'; import {
ChannelReportStatusModal,
ConfirmModal,
DrainageReportStatusModal,
} from './enterprise-signatures/SignatureReportModals';
import { buildDrainagePayload, readDrainagePayload } from './enterprise-signatures/signature.helpers'; import { buildDrainagePayload, readDrainagePayload } from './enterprise-signatures/signature.helpers';
import type { DrainageInfo, SignatureFormState } from './enterprise-signatures/signature.types'; import type { DrainageInfo, SignatureFormState } from './enterprise-signatures/signature.types';
/** R4 page container: owns query state and coordinates focused presentation components. */ /** R4 page container: owns query state and coordinates focused presentation components. */
export function AdminEnterpriseSignaturesPage() { export function AdminEnterpriseSignaturesPage() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms'); const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms');
const [applications, setApplications] = useState<ClientSmsApplication[]>([]); const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [deleteTarget, setDeleteTarget] = useState<{ kind: 'drainage'; signatureId: string; id: string; name: string } | null>(null); const [deleteTarget, setDeleteTarget] = useState<{
kind: 'drainage';
signatureId: string;
id: string;
name: string;
} | null>(null);
const [drainageModal, setDrainageModal] = useState<{ signatureId: string; item?: DrainageInfo } | null>(null); const [drainageModal, setDrainageModal] = useState<{ signatureId: string; item?: DrainageInfo } | null>(null);
const [drainageStatusTarget, setDrainageStatusTarget] = useState<{ signature: ClientSmsSignature; item: DrainageInfo } | null>(null); const [drainageStatusTarget, setDrainageStatusTarget] = useState<{
signature: ClientSmsSignature;
item: DrainageInfo;
} | null>(null);
const [enterpriseKeyword, setEnterpriseKeyword] = useState(''); const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState(''); const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
const [applicationKeyword, setApplicationKeyword] = useState(''); const [applicationKeyword, setApplicationKeyword] = useState('');
@@ -36,10 +50,20 @@ export function AdminEnterpriseSignaturesPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [materialChangedSignature, setMaterialChangedSignature] = useState<ClientSmsSignature | null>(null);
const pageSize = 10; const pageSize = 10;
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, signatureKeyword: appliedSignatureKeyword, drainageKeyword: appliedDrainageKeyword }, targetPage = page, targetSort = signatureSort) { async function loadData(
filters = {
enterpriseKeyword: appliedEnterpriseKeyword,
applicationKeyword: appliedApplicationKeyword,
signatureKeyword: appliedSignatureKeyword,
drainageKeyword: appliedDrainageKeyword,
},
targetPage = page,
targetSort = signatureSort,
) {
try { try {
const [signatureResult, tenantItems, applicationItems] = await Promise.all([ const [signatureResult, tenantItems, applicationItems] = await Promise.all([
adminApi.listEnterpriseSignaturesPage({ ...filters, signatureSort: targetSort, page: targetPage, pageSize }), adminApi.listEnterpriseSignaturesPage({ ...filters, signatureSort: targetSort, page: targetPage, pageSize }),
@@ -57,7 +81,7 @@ export function AdminEnterpriseSignaturesPage() {
} }
useEffect(() => { useEffect(() => {
void loadData(undefined, page); queueMicrotask(() => void loadData(undefined, page));
}, [page]); }, [page]);
const filteredSignatures = signatures; const filteredSignatures = signatures;
@@ -68,21 +92,27 @@ export function AdminEnterpriseSignaturesPage() {
async function saveSignature(state: SignatureFormState) { async function saveSignature(state: SignatureFormState) {
const existing = signatureModal && signatureModal !== 'new' ? signatureModal : null; const existing = signatureModal && signatureModal !== 'new' ? signatureModal : null;
const existingPayload = existing ? readDrainagePayload(existing) : { links: [], signatureProfile: undefined }; const existingPayload = existing ? readDrainagePayload(existing) : { links: [], signatureProfile: undefined };
const drainageInfo = buildDrainagePayload({ const drainageInfo = buildDrainagePayload(
{
mobile: state.mobile, mobile: state.mobile,
unicom: state.unicom, unicom: state.unicom,
telecom: state.telecom, telecom: state.telecom,
}, existingPayload.links, existingPayload.signatureProfile, state.reportValues); },
existingPayload.links,
existingPayload.signatureProfile,
state.reportValues,
);
try { try {
let saved: ClientSmsSignature;
if (existing) { if (existing) {
await adminApi.updateEnterpriseSignature(existing.id, { saved = await adminApi.updateEnterpriseSignature(existing.id, {
applicationId: state.applicationId || null, applicationId: state.applicationId || null,
drainageInfo, drainageInfo,
name: state.name, name: state.name,
purpose: state.purpose, purpose: state.purpose,
}); });
} else { } else {
await adminApi.createEnterpriseSignature({ saved = await adminApi.createEnterpriseSignature({
applicationId: state.applicationId || undefined, applicationId: state.applicationId || undefined,
drainageInfo, drainageInfo,
name: state.name, name: state.name,
@@ -91,6 +121,7 @@ export function AdminEnterpriseSignaturesPage() {
}); });
} }
setSignatureModal(null); setSignatureModal(null);
if (saved.reportMaterialChanged) setMaterialChangedSignature(saved);
await loadData(); await loadData();
} catch (failure) { } catch (failure) {
setError(failure instanceof Error ? failure.message : '企业签名保存失败'); setError(failure instanceof Error ? failure.message : '企业签名保存失败');
@@ -154,28 +185,72 @@ export function AdminEnterpriseSignaturesPage() {
<h1></h1> <h1></h1>
</div> </div>
<div className="page-heading-actions"> <div className="page-heading-actions">
<Button icon={<FileSpreadsheet size={16} />} onClick={() => setImportOpen(true)} variant="ghost"></Button> <Button icon={<FileSpreadsheet size={16} />} onClick={() => setImportOpen(true)} variant="ghost">
<Button icon={<Plus size={16} />} onClick={() => setSignatureModal(activeTab === 'sms' ? 'new' : null)}></Button>
</Button>
<Button icon={<Plus size={16} />} onClick={() => setSignatureModal(activeTab === 'sms' ? 'new' : null)}>
</Button>
</div> </div>
</div> </div>
<div className="surface admin-split-filter"> <div className="surface admin-split-filter">
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} /> <Input
<Input label="企业应用" onChange={(event) => setApplicationKeyword(event.target.value)} placeholder="请输入企业应用名称" prefix={<Search size={16} />} value={applicationKeyword} /> label="企业名称"
<Input label="签名" onChange={(event) => setSignatureKeyword(event.target.value)} placeholder="请输入签名" prefix={<Search size={16} />} value={signatureKeyword} /> onChange={(event) => setEnterpriseKeyword(event.target.value)}
<Input label="引流信息" onChange={(event) => setDrainageKeyword(event.target.value)} placeholder="请输入引流信息、URL 或备注" prefix={<Search size={16} />} value={drainageKeyword} /> placeholder="请输入企业名称"
prefix={<Search size={16} />}
value={enterpriseKeyword}
/>
<Input
label="企业应用"
onChange={(event) => setApplicationKeyword(event.target.value)}
placeholder="请输入企业应用名称"
prefix={<Search size={16} />}
value={applicationKeyword}
/>
<Input
label="签名"
onChange={(event) => setSignatureKeyword(event.target.value)}
placeholder="请输入签名"
prefix={<Search size={16} />}
value={signatureKeyword}
/>
<Input
label="引流信息"
onChange={(event) => setDrainageKeyword(event.target.value)}
placeholder="请输入引流信息、URL 或备注"
prefix={<Search size={16} />}
value={drainageKeyword}
/>
<div className="admin-split-filter__actions"> <div className="admin-split-filter__actions">
<Button icon={<Search size={16} />} onClick={() => { <Button
const filters = { enterpriseKeyword: enterpriseKeyword.trim(), applicationKeyword: applicationKeyword.trim(), signatureKeyword: signatureKeyword.trim(), drainageKeyword: drainageKeyword.trim() }; icon={<Search size={16} />}
onClick={() => {
const filters = {
enterpriseKeyword: enterpriseKeyword.trim(),
applicationKeyword: applicationKeyword.trim(),
signatureKeyword: signatureKeyword.trim(),
drainageKeyword: drainageKeyword.trim(),
};
setAppliedEnterpriseKeyword(filters.enterpriseKeyword); setAppliedEnterpriseKeyword(filters.enterpriseKeyword);
setAppliedApplicationKeyword(filters.applicationKeyword); setAppliedApplicationKeyword(filters.applicationKeyword);
setAppliedSignatureKeyword(filters.signatureKeyword); setAppliedSignatureKeyword(filters.signatureKeyword);
setAppliedDrainageKeyword(filters.drainageKeyword); setAppliedDrainageKeyword(filters.drainageKeyword);
setPage(1); setPage(1);
void loadData(filters, 1); void loadData(filters, 1);
}}></Button> }}
<Button onClick={() => { >
const filters = { enterpriseKeyword: '', applicationKeyword: '', signatureKeyword: '', drainageKeyword: '' };
</Button>
<Button
onClick={() => {
const filters = {
enterpriseKeyword: '',
applicationKeyword: '',
signatureKeyword: '',
drainageKeyword: '',
};
setEnterpriseKeyword(''); setEnterpriseKeyword('');
setApplicationKeyword(''); setApplicationKeyword('');
setSignatureKeyword(''); setSignatureKeyword('');
@@ -186,7 +261,11 @@ export function AdminEnterpriseSignaturesPage() {
setAppliedDrainageKeyword(''); setAppliedDrainageKeyword('');
setPage(1); setPage(1);
void loadData(filters, 1); void loadData(filters, 1);
}} variant="ghost"></Button> }}
variant="ghost"
>
</Button>
</div> </div>
</div> </div>
@@ -199,7 +278,12 @@ export function AdminEnterpriseSignaturesPage() {
value={activeTab} value={activeTab}
items={[ items={[
{ label: '短信签名', value: 'sms', content: smsSignatureContent }, { label: '短信签名', value: 'sms', content: smsSignatureContent },
{ label: '彩信签名', pending: true, value: 'mms', content: <div className="ui-table__empty"></div> }, {
label: '彩信签名',
pending: true,
value: 'mms',
content: <div className="ui-table__empty"></div>,
},
]} ]}
/> />
</div> </div>
@@ -209,29 +293,87 @@ export function AdminEnterpriseSignaturesPage() {
applications={applications} applications={applications}
item={signatureModal === 'new' ? undefined : signatureModal} item={signatureModal === 'new' ? undefined : signatureModal}
onClose={() => setSignatureModal(null)} onClose={() => setSignatureModal(null)}
onSubmit={(state) => { void saveSignature(state); }} onSubmit={(state) => {
void saveSignature(state);
}}
tenants={tenants} tenants={tenants}
/> />
) : null} ) : null}
{importOpen ? <ReportMaterialImportModal onClose={() => setImportOpen(false)} onCompleted={() => { {materialChangedSignature ? (
<Modal
footer={
<>
<Button onClick={() => setMaterialChangedSignature(null)} variant="ghost">
</Button>
<Button
onClick={() => {
const id = materialChangedSignature.id;
setMaterialChangedSignature(null);
navigate(`/admin/report-materials?signatureId=${encodeURIComponent(id)}`);
}}
>
</Button>
</>
}
onClose={() => setMaterialChangedSignature(null)}
open
title="签名资料已更新"
>
<div className="signature-alert">
<FileSpreadsheet size={20} />
<span></span>
</div>
</Modal>
) : null}
{importOpen ? (
<ReportMaterialImportModal
onClose={() => setImportOpen(false)}
onCompleted={() => {
setMessage('导入解析完成,合格资料已进入审核中心的导入批次'); setMessage('导入解析完成,合格资料已进入审核中心的导入批次');
void loadData(); void loadData();
}} /> : null} }}
{reportStatusTarget ? <ChannelReportStatusModal item={reportStatusTarget} onClose={() => setReportStatusTarget(null)} onSaved={() => { setReportStatusTarget(null); void loadData(); }} /> : null} />
) : null}
{reportStatusTarget ? (
<ChannelReportStatusModal
item={reportStatusTarget}
onClose={() => setReportStatusTarget(null)}
onSaved={() => {
setReportStatusTarget(null);
void loadData();
}}
/>
) : null}
{drainageModal ? ( {drainageModal ? (
<DrainageFormModal <DrainageFormModal
applicationId={signatures.find((item) => item.id === drainageModal.signatureId)?.applicationId} applicationId={signatures.find((item) => item.id === drainageModal.signatureId)?.applicationId}
item={drainageModal.item} item={drainageModal.item}
onClose={() => setDrainageModal(null)} onClose={() => setDrainageModal(null)}
onSubmit={(item) => { void saveDrainage(drainageModal.signatureId, item); }} onSubmit={(item) => {
void saveDrainage(drainageModal.signatureId, item);
}}
/>
) : null}
{drainageStatusTarget ? (
<DrainageReportStatusModal
item={drainageStatusTarget.item}
onClose={() => setDrainageStatusTarget(null)}
onSaved={() => {
setDrainageStatusTarget(null);
void loadData();
}}
signature={drainageStatusTarget.signature}
/> />
) : null} ) : null}
{drainageStatusTarget ? <DrainageReportStatusModal item={drainageStatusTarget.item} onClose={() => setDrainageStatusTarget(null)} onSaved={() => { setDrainageStatusTarget(null); void loadData(); }} signature={drainageStatusTarget.signature} /> : null}
{deleteTarget ? ( {deleteTarget ? (
<ConfirmModal <ConfirmModal
message={`确认删除“${deleteTarget.name}”吗?删除后会写入真实后台。`} message={`确认删除“${deleteTarget.name}”吗?删除后会写入真实后台。`}
onCancel={() => setDeleteTarget(null)} onCancel={() => setDeleteTarget(null)}
onConfirm={() => { void confirmDelete(); }} onConfirm={() => {
void confirmDelete();
}}
/> />
) : null} ) : null}
</section> </section>
+339
View File
@@ -0,0 +1,339 @@
import { useEffect, useState } from 'react';
import { Download, Eye, Search } from 'lucide-react';
import { adminApi, fileDownloadUrl, type ReportMaterialBatch, type ReportTask } from '@/api/adminApi';
import {
Breadcrumb,
Button,
CarrierTag,
DateRangeInput,
Input,
Modal,
Pagination,
Select,
Table,
Tag,
Textarea,
type DateRangeValue,
type TableColumn,
} from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
const statusLabels: Record<string, string> = {
completed: '生成完成',
partial_failed: '部分生成',
failed: '生成失败',
generating: '生成中',
pending: '未报备',
waiting_material: '资料待补充',
reporting: '报备中',
approved: '报备通过',
rejected: '报备失败',
abandoned: '已放弃',
};
export function AdminReportBatchesPage() {
const [items, setItems] = useState<ReportMaterialBatch[]>([]);
const [keyword, setKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [detail, setDetail] = useState<ReportMaterialBatch>();
const [tasks, setTasks] = useState<ReportTask[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [nextStatus, setNextStatus] = useState('reporting');
const [reason, setReason] = useState('');
const [error, setError] = useState('');
const pageSize = 20;
function load(target = page) {
adminApi
.listReportMaterialBatches({
keyword: keyword.trim() || undefined,
startAt: dateRange.start,
endAt: dateRange.end,
page: target,
pageSize,
})
.then((result) => {
setItems(result.items);
setTotal(result.total);
setError('');
})
.catch((failure: Error) => setError(failure.message || '报备批次加载失败'));
}
useEffect(() => {
load(page);
}, [page]);
async function openBatch(batch: ReportMaterialBatch) {
try {
const [batchDetail, taskPage] = await Promise.all([
adminApi.getReportMaterialBatch(batch.id),
adminApi.listReportMaterialBatchTasks(batch.id, { page: 1, pageSize: 100 }),
]);
setDetail(batchDetail);
setTasks(taskPage.items);
setSelected(new Set());
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '批次明细加载失败');
}
}
async function saveStatuses() {
const chosen = tasks.filter((task) => selected.has(task.id));
if (!chosen.length) return;
try {
await adminApi.changeReportTaskStatuses({
items: chosen.map((task) => ({
signatureId: task.signatureId,
channelId: task.channelId,
carrier: task.carrier ?? undefined,
reportType: task.reportType,
drainageItemId: task.drainageItemId ?? undefined,
status: nextStatus,
})),
reason: reason.trim() || undefined,
sourceEntry: 'report_task',
});
if (detail) await openBatch(detail);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '批量状态修改失败');
}
}
async function exportOne(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 : '单条资料导出失败');
}
}
const columns: Array<TableColumn<ReportMaterialBatch>> = [
{ key: 'batchNo', title: '报备批次号', render: (item) => <strong>{item.batchNo}</strong> },
{ key: 'time', title: '生成时间', render: (item) => formatDateTime(item.createdAt) },
{ key: 'count', title: '明细进度', render: (item) => `${item.successCount}/${item.reportTotal}` },
{ key: 'channels', title: '通道/文件', render: (item) => `${item.channelCount}个通道 · ${item.fileCount}份文件` },
{
key: 'status',
title: '生成状态',
render: (item) => (
<Tag tone={item.status === 'completed' ? 'success' : item.status === 'failed' ? 'danger' : 'warning'}>
{statusLabels[item.status] ?? item.status}
</Tag>
),
},
{
key: 'files',
title: '文件',
render: (item) => (
<div className="table-actions">
{item.exportFiles.map((file) =>
file.fileObjectId ? (
<a href={fileDownloadUrl(file.fileObjectId)} key={file.id}>
<Download size={14} />
{file.fileName}
</a>
) : null,
)}
</div>
),
},
{
key: 'actions',
title: '操作',
align: 'right',
render: (item) => (
<Button icon={<Eye size={14} />} onClick={() => void openBatch(item)} size="sm" variant="ghost">
</Button>
),
},
];
const taskColumns: Array<TableColumn<ReportTask>> = [
{
key: 'select',
title: '',
width: '44px',
render: (task) => (
<input
aria-label={`选择${task.signature?.name ?? task.id}`}
checked={selected.has(task.id)}
onChange={() =>
setSelected((current) => {
const next = new Set(current);
if (next.has(task.id)) next.delete(task.id);
else next.add(task.id);
return next;
})
}
type="checkbox"
/>
),
},
{
key: 'target',
title: '企业/应用/签名',
render: (task) => (
<div>
<strong>{task.signature?.name ?? '-'}</strong>
<div className="muted">
{task.signature?.tenant?.name ?? '-'} · {task.signature?.application?.name ?? '-'}
</div>
</div>
),
},
{
key: 'channel',
title: '通道/运营商',
render: (task) => (
<div>
{task.channel?.name ?? '-'}
{task.carrier ? (
<div>
<CarrierTag carrier={task.carrier} />
</div>
) : null}
</div>
),
},
{
key: 'version',
title: '资料版本',
render: (task) => `V${task.exportItems?.[0]?.batchItem.materialVersion ?? '-'}`,
},
{
key: 'status',
title: '报备状态',
render: (task) => (
<Tag
tone={
task.status === 'approved'
? 'success'
: task.status === 'failed' || task.status === 'rejected'
? 'danger'
: 'warning'
}
>
{statusLabels[task.status] ?? task.status}
</Tag>
),
},
{
key: 'actions',
title: '操作',
align: 'right',
render: (task) =>
task.reportType !== 'drainage' ? (
<Button icon={<Download size={14} />} onClick={() => void exportOne(task)} size="sm" variant="ghost">
</Button>
) : (
'-'
),
},
];
return (
<section className="page-stack report-batch-page">
<div className="page-heading">
<div>
<Breadcrumb items={['报备工作台', '报备批次']} />
<h1></h1>
<p></p>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-task-filter">
<Input label="报备批次号" onChange={(event) => setKeyword(event.target.value)} value={keyword} />
<DateRangeInput label="生成时间" onChange={setDateRange} value={dateRange} />
<div className="admin-task-filter__actions">
<Button
icon={<Search size={16} />}
onClick={() => {
if (page !== 1) setPage(1);
else load(1);
}}
>
</Button>
<Button
onClick={() => {
setKeyword('');
setDateRange({});
}}
variant="ghost"
>
</Button>
</div>
</div>
<div className="surface">
<Table columns={columns} data={items} emptyText="尚未生成报备批次" pagination={false} rowKey="id" />
</div>
<Pagination
nextDisabled={page * pageSize >= 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 ? (
<Modal
footer={<Button onClick={() => setDetail(undefined)}></Button>}
onClose={() => setDetail(undefined)}
open
size="xl"
title={`批次明细 · ${detail.batchNo}`}
>
<div className="page-stack">
<div className="report-batch-toolbar">
<strong>
{tasks.length} {selected.size}
</strong>
<Select
aria-label="批量修改状态"
onChange={(event) => setNextStatus(event.target.value)}
options={[
{ label: '未报备', value: 'pending' },
{ label: '资料待补充', value: 'waiting_material' },
{ label: '报备中', value: 'reporting' },
{ label: '报备通过', value: 'approved' },
{ label: '报备失败', value: 'failed' },
{ label: '放弃报备', value: 'abandoned' },
]}
value={nextStatus}
/>
<Textarea
aria-label="修改原因"
onChange={(event) => setReason(event.target.value)}
placeholder="修改原因"
rows={2}
value={reason}
/>
<Button disabled={!selected.size} onClick={() => void saveStatuses()}>
</Button>
</div>
<Table columns={taskColumns} data={tasks} emptyText="该批次暂无明细" pagination={false} rowKey="id" />
</div>
</Modal>
) : null}
</section>
);
}
+273 -118
View File
@@ -1,9 +1,8 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, CheckCircle2, Download, Layers3, Search, ShieldCheck } from 'lucide-react'; import { useNavigate } from 'react-router-dom';
import { AlertTriangle, CheckCircle2, Layers3, Search, ShieldCheck } from 'lucide-react';
import { import {
adminApi, adminApi,
fileDownloadUrl,
type ReportMaterialBatch,
type ReportMaterialBatchPreflight, type ReportMaterialBatchPreflight,
type ReportMaterialBatchResult, type ReportMaterialBatchResult,
type ReportMaterialPendingItem, type ReportMaterialPendingItem,
@@ -18,7 +17,6 @@ import {
Pagination, Pagination,
Select, Select,
Table, Table,
Tabs,
Tag, Tag,
type DateRangeValue, type DateRangeValue,
type TableColumn, type TableColumn,
@@ -26,29 +24,24 @@ import {
import { formatDateTime } from '@/utils/dateTime'; import { formatDateTime } from '@/utils/dateTime';
import { createUuid } from '@/utils/randomId'; import { createUuid } from '@/utils/randomId';
const batchStatusLabels: Record<string, string> = {
completed: '生成完成',
partial_failed: '部分生成',
failed: '生成失败',
generating: '生成中',
processing: '生成中',
};
export function AdminReportMaterialsPage() { export function AdminReportMaterialsPage() {
const [activeTab, setActiveTab] = useState<'pending' | 'batches'>('pending'); const navigate = useNavigate();
const [pendingData, setPendingData] = useState<{ items: ReportMaterialPendingItem[]; total: number }>({ items: [], total: 0 }); const [pendingData, setPendingData] = useState<{ items: ReportMaterialPendingItem[]; total: number }>({
const [batchData, setBatchData] = useState<{ items: ReportMaterialBatch[]; total: number }>({ items: [], total: 0 }); items: [],
total: 0,
});
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const [reportType, setReportType] = useState('all'); const [reportType, setReportType] = useState('all');
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({}); const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [pendingPage, setPendingPage] = useState(1); const [pendingPage, setPendingPage] = useState(1);
const [batchPage, setBatchPage] = useState(1);
const pageSize = 20; const pageSize = 20;
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [preflightBusy, setPreflightBusy] = useState(false); const [preflightBusy, setPreflightBusy] = useState(false);
const [preflight, setPreflight] = useState<ReportMaterialBatchPreflight | null>(null); const [preflight, setPreflight] = useState<ReportMaterialBatchPreflight | null>(null);
const [poolEligibility, setPoolEligibility] = useState<Map<string, ReportMaterialBatchPreflight['items'][number]>>(new Map()); const [poolEligibility, setPoolEligibility] = useState<Map<string, ReportMaterialBatchPreflight['items'][number]>>(
new Map(),
);
const [operationKey, setOperationKey] = useState(''); const [operationKey, setOperationKey] = useState('');
const [batchResult, setBatchResult] = useState<ReportMaterialBatchResult | null>(null); const [batchResult, setBatchResult] = useState<ReportMaterialBatchResult | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
@@ -68,7 +61,7 @@ export function AdminReportMaterialsPage() {
const nextReportType = filters.reportType ?? reportType; const nextReportType = filters.reportType ?? reportType;
try { try {
const result = await adminApi.listPendingReportMaterials({ const result = await adminApi.listPendingReportMaterials({
reportType: nextReportType === 'all' ? undefined : nextReportType as 'signature' | 'drainage', reportType: nextReportType === 'all' ? undefined : (nextReportType as 'signature' | 'drainage'),
keyword: nextKeyword.trim() || undefined, keyword: nextKeyword.trim() || undefined,
startAt: nextDateRange.start, startAt: nextDateRange.start,
endAt: nextDateRange.end, endAt: nextDateRange.end,
@@ -76,7 +69,9 @@ export function AdminReportMaterialsPage() {
pageSize, pageSize,
}); });
setPendingData({ items: result.items, total: result.total }); setPendingData({ items: result.items, total: result.total });
const eligibility = result.items.length ? await adminApi.preflightReportMaterialBatch({ items: result.items.map(toBatchItem) }) : null; const eligibility = result.items.length
? await adminApi.preflightReportMaterialBatch({ items: result.items.map(toBatchItem) })
: null;
const eligibilityMap = new Map((eligibility?.items ?? []).map((item) => [item.id, item])); const eligibilityMap = new Map((eligibility?.items ?? []).map((item) => [item.id, item]));
setPoolEligibility(eligibilityMap); setPoolEligibility(eligibilityMap);
setSelected((current) => new Set([...current].filter((id) => eligibilityMap.get(id)?.eligible))); setSelected((current) => new Set([...current].filter((id) => eligibilityMap.get(id)?.eligible)));
@@ -86,38 +81,9 @@ export function AdminReportMaterialsPage() {
} }
} }
async function loadBatches(
page = batchPage,
filters: {
keyword?: string;
dateRange?: DateRangeValue;
} = {},
) {
const nextKeyword = filters.keyword ?? keyword;
const nextDateRange = filters.dateRange ?? dateRange;
try {
const result = await adminApi.listReportMaterialBatches({
keyword: nextKeyword.trim() || undefined,
startAt: nextDateRange.start,
endAt: nextDateRange.end,
page,
pageSize,
});
setBatchData({ items: result.items, total: result.total });
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '已生成批次加载失败');
}
}
function loadActive() {
if (activeTab === 'pending') void loadPending(pendingPage);
else void loadBatches(batchPage);
}
useEffect(() => { useEffect(() => {
loadActive(); queueMicrotask(() => void loadPending(pendingPage));
}, [activeTab, pendingPage, batchPage, reportType]); }, [pendingPage, reportType]);
const eligibleItems = pendingData.items.filter((item) => poolEligibility.get(item.id)?.eligible); const eligibleItems = pendingData.items.filter((item) => poolEligibility.get(item.id)?.eligible);
const allSelected = eligibleItems.length > 0 && eligibleItems.every((item) => selected.has(item.id)); const allSelected = eligibleItems.length > 0 && eligibleItems.every((item) => selected.has(item.id));
@@ -126,7 +92,8 @@ export function AdminReportMaterialsPage() {
if (!poolEligibility.get(id)?.eligible) return; if (!poolEligibility.get(id)?.eligible) return;
setSelected((current) => { setSelected((current) => {
const next = new Set(current); const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id); if (next.has(id)) next.delete(id);
else next.add(id);
return next; return next;
}); });
} }
@@ -164,9 +131,11 @@ export function AdminReportMaterialsPage() {
items: chosen.map((item) => ({ ...toBatchItem(item), materialVersion: item.materialVersion })), items: chosen.map((item) => ({ ...toBatchItem(item), materialVersion: item.materialVersion })),
}); });
setBatchResult(batch); setBatchResult(batch);
setMessage(`批次 ${batch.batchNo} 已生成:成功 ${batch.result.successCount},跳过 ${batch.result.skippedCount},失败 ${batch.result.failedCount}`); setMessage(
`批次 ${batch.batchNo} 已生成:成功 ${batch.result.successCount},跳过 ${batch.result.skippedCount},失败 ${batch.result.failedCount}`,
);
setSelected(new Set()); setSelected(new Set());
await Promise.all([loadPending(pendingPage), loadBatches(1)]); await loadPending(pendingPage);
} catch (failure) { } catch (failure) {
setError(failure instanceof Error ? failure.message : '报备批次生成失败'); setError(failure instanceof Error ? failure.message : '报备批次生成失败');
} finally { } finally {
@@ -174,103 +143,289 @@ export function AdminReportMaterialsPage() {
} }
} }
const pendingColumns = useMemo<Array<TableColumn<ReportMaterialPendingItem>>>(() => [ const pendingColumns = useMemo<Array<TableColumn<ReportMaterialPendingItem>>>(
() => [
{ {
key: 'select', key: 'select',
title: '', title: '',
width: '48px', width: '48px',
render: (item) => { render: (item) => {
const eligible = poolEligibility.get(item.id)?.eligible; const eligible = poolEligibility.get(item.id)?.eligible;
return <input aria-label={`选择${item.name}`} checked={selected.has(item.id)} disabled={!eligible} onChange={() => toggle(item.id)} type="checkbox" />; return (
<input
aria-label={`选择${item.name}`}
checked={selected.has(item.id)}
disabled={!eligible}
onChange={() => toggle(item.id)}
type="checkbox"
/>
);
}, },
}, },
{ key: 'name', title: '资料', render: (item) => <div><strong>{item.name}</strong><div className="muted">{item.reportType === 'signature' ? '签名资料' : `引流信息 · ${item.signatureName ?? '-'}`} · {item.detail || '-'}</div></div> }, {
{ key: 'tenant', title: '企业/应用', render: (item) => <div><strong>{item.tenant?.name ?? '-'}</strong><div className="muted">{item.application?.name ?? '未指定应用'}</div></div> }, key: 'name',
{ key: 'eligibility', title: '版本/资格', render: (item) => { title: '资料',
render: (item) => (
<div>
<strong>{item.name}</strong>
<div className="muted">
{item.reportType === 'signature' ? '签名资料' : `引流信息 · ${item.signatureName ?? '-'}`} ·{' '}
{item.detail || '-'}
</div>
</div>
),
},
{
key: 'tenant',
title: '企业/应用',
render: (item) => (
<div>
<strong>{item.tenant?.name ?? '-'}</strong>
<div className="muted">{item.application?.name ?? '未指定应用'}</div>
</div>
),
},
{
key: 'eligibility',
title: '版本/资格',
render: (item) => {
const eligibility = poolEligibility.get(item.id); const eligibility = poolEligibility.get(item.id);
const eligible = eligibility?.eligible; const eligible = eligibility?.eligible;
return <div><Tag tone={eligible ? 'success' : 'warning'}>V{item.materialVersion} · {eligible ? `${eligibility.targets.filter((target) => target.eligible).length}个通道可生成` : '待补充'}</Tag>{!eligible ? <div className="muted">{eligibility?.blockedReasons[0] ?? '资格检查中'}</div> : null}</div>; return (
} }, <div>
<Tag tone={eligible ? 'success' : 'warning'}>
V{item.materialVersion} ·{' '}
{eligible ? `${eligibility.targets.filter((target) => target.eligible).length}个通道可生成` : '待补充'}
</Tag>
{!eligible ? <div className="muted">{eligibility?.blockedReasons[0] ?? '资格检查中'}</div> : null}
</div>
);
},
},
{
key: 'summary',
title: '通道明细汇总',
render: (item) =>
item.statusSummary ? (
<div className="report-material-summary">
<strong>{item.statusSummary.total} </strong>
<span>
{item.statusSummary.pending} · {item.statusSummary.reporting} · {' '}
{item.statusSummary.approved} · {item.statusSummary.failed} · {item.statusSummary.abandoned}
</span>
</div>
) : (
'-'
),
},
{ key: 'changedAt', title: '资料变更时间', render: (item) => formatDateTime(item.changedAt) }, { key: 'changedAt', title: '资料变更时间', render: (item) => formatDateTime(item.changedAt) },
], [poolEligibility, selected]); {
key: 'actions',
title: '操作',
align: 'right',
render: (item) => (
<Button
onClick={() => navigate(`/admin/report-tasks?signatureId=${encodeURIComponent(item.signatureId)}`)}
size="sm"
variant="ghost"
>
</Button>
),
},
],
[navigate, poolEligibility, selected],
);
const batchColumns = useMemo<Array<TableColumn<ReportMaterialBatch>>>(() => [ const filter = (
{ key: 'batchNo', title: '报备批次号', render: (batch) => <strong>{batch.batchNo}</strong> }, <div className="surface report-material-filter report-material-filter--pending">
{ key: 'time', title: '生成时间', render: (batch) => formatDateTime(batch.createdAt) }, <Select
{ key: 'reportTotal', title: '报备总数', render: (batch) => batch.reportTotal.toLocaleString('zh-CN') }, label="资料类型"
{ key: 'successCount', title: '成功数', render: (batch) => batch.successCount.toLocaleString('zh-CN') }, onChange={(event) => {
{ key: 'successRate', title: '成功率', render: (batch) => `${(batch.successRate * 100).toFixed(2)}%` }, setReportType(event.target.value);
{ key: 'channels', title: '通道/文件', render: (batch) => `${batch.channelCount}个通道 · ${batch.fileCount}份文件` }, setPendingPage(1);
{ key: 'status', title: '生成状态', render: (batch) => <Tag tone={batch.status === 'completed' ? 'success' : batch.status === 'failed' ? 'danger' : 'warning'}>{batchStatusLabels[batch.status] ?? batch.status}</Tag> }, }}
{ key: 'files', title: '报备文件', align: 'right', render: (batch) => <div className="table-actions">{batch.exportFiles.map((file) => file.fileObjectId ? <a href={fileDownloadUrl(file.fileObjectId)} key={file.id}><Download size={15} />{file.fileName}{file.rowCount}</a> : null)}</div> }, options={[
], []); { label: '全部资料', value: 'all' },
{ label: '签名资料', value: 'signature' },
const filter = <div className={`surface report-material-filter report-material-filter--${activeTab}`}> { label: '引流信息', value: 'drainage' },
{activeTab === 'pending' ? <Select label="资料类型" onChange={(event) => { setReportType(event.target.value); setPendingPage(1); }} options={[{ label: '全部资料', value: 'all' }, { label: '签名资料', value: 'signature' }, { label: '引流信息', value: 'drainage' }]} value={reportType} /> : null} ]}
<Input label={activeTab === 'pending' ? '企业/应用/签名/站点' : '报备批次号'} onChange={(event) => setKeyword(event.target.value)} placeholder={activeTab === 'pending' ? '搜索待生成资料' : '搜索报备批次号'} value={keyword} /> value={reportType}
<DateRangeInput label={activeTab === 'pending' ? '资料变更时间' : '批次生成时间'} onChange={setDateRange} value={dateRange} /> />
<Input
label="企业/应用/签名/站点"
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索报备资料池"
value={keyword}
/>
<DateRangeInput label="资料变更时间" onChange={setDateRange} value={dateRange} />
<div className="ui-query-actions"> <div className="ui-query-actions">
<Button icon={<Search size={16} />} onClick={() => { <Button
if (activeTab === 'pending') { icon={<Search size={16} />}
onClick={() => {
setPendingPage(1); setPendingPage(1);
void loadPending(1); void loadPending(1);
} else { }}
setBatchPage(1); >
void loadBatches(1);
} </Button>
}}></Button> <Button
<Button onClick={() => { onClick={() => {
setKeyword(''); setKeyword('');
setDateRange({}); setDateRange({});
if (activeTab === 'pending') {
setReportType('all'); setReportType('all');
setPendingPage(1); setPendingPage(1);
void loadPending(1, { keyword: '', dateRange: {}, reportType: 'all' }); void loadPending(1, { keyword: '', dateRange: {}, reportType: 'all' });
} else { }}
setBatchPage(1); variant="ghost"
void loadBatches(1, { keyword: '', dateRange: {} }); >
}
}} variant="ghost"></Button> </Button>
</div> </div>
</div>; </div>
);
return <section className="page-stack report-material-page"> return (
<section className="page-stack report-material-page">
<div className="surface page-heading"> <div className="surface page-heading">
<div><Breadcrumb items={['报备任务', '待生成报备批次']} /><h1></h1><p></p></div> <div>
{activeTab === 'pending' ? <Button disabled={busy || selected.size === 0} icon={<Layers3 size={16} />} onClick={() => void beginCreateBatch()}>{busy ? '生成中...' : `预检并生成(${selected.size}`}</Button> : null} <Breadcrumb items={['报备工作台', '报备资料池']} />
<h1></h1>
<p> × × </p>
</div>
<Button
disabled={busy || selected.size === 0}
icon={<Layers3 size={16} />}
onClick={() => void beginCreateBatch()}
>
{busy ? '生成中...' : `预检并生成(${selected.size}`}
</Button>
</div> </div>
{error ? <p className="form-error">{error}</p> : null} {error ? <p className="form-error">{error}</p> : null}
{message ? <p className="form-success">{message}</p> : null} {message ? <p className="form-success">{message}</p> : null}
<Tabs <div className="page-stack">
onChange={(value) => { {filter}
setActiveTab(value as 'pending' | 'batches'); <div className="surface">
setKeyword(''); <label className="table-actions">
setDateRange({}); <input
}} checked={allSelected}
value={activeTab} onChange={() => setSelected(allSelected ? new Set() : new Set(eligibleItems.map((item) => item.id)))}
items={[ type="checkbox"
{
label: '待生成资料',
value: 'pending',
content: <div className="page-stack">{filter}<div className="surface"><label className="table-actions"><input checked={allSelected} onChange={() => setSelected(allSelected ? new Set() : new Set(eligibleItems.map((item) => item.id)))} type="checkbox" /></label><Table columns={pendingColumns} data={pendingData.items} emptyText="暂无符合条件的待生成资料" pagination={false} rowKey="id" /></div><Pagination nextDisabled={pendingPage * pageSize >= pendingData.total} onNext={() => setPendingPage((page) => page + 1)} onPageChange={setPendingPage} onPrevious={() => setPendingPage((page) => Math.max(1, page - 1))} page={pendingPage} previousDisabled={pendingPage <= 1} total={pendingData.total} totalPages={Math.max(1, Math.ceil(pendingData.total / pageSize))} /></div>,
},
{
label: '已生成批次',
value: 'batches',
content: <div className="page-stack">{filter}<div className="surface"><Table columns={batchColumns} data={batchData.items} emptyText="尚未生成报备批次" pagination={false} rowKey="id" /></div><Pagination nextDisabled={batchPage * pageSize >= batchData.total} onNext={() => setBatchPage((page) => page + 1)} onPageChange={setBatchPage} onPrevious={() => setBatchPage((page) => Math.max(1, page - 1))} page={batchPage} previousDisabled={batchPage <= 1} total={batchData.total} totalPages={Math.max(1, Math.ceil(batchData.total / pageSize))} /></div>,
},
]}
/> />
<Modal footer={batchResult ? <Button onClick={() => setConfirmOpen(false)}></Button> : <><Button disabled={busy} onClick={() => setConfirmOpen(false)} variant="ghost"></Button><Button disabled={preflightBusy || busy || !preflight?.eligible} icon={<ShieldCheck size={16} />} onClick={() => void createBatch()}>{busy ? '生成处理中…' : '确认生成'}</Button></>} onClose={() => { if (!busy) setConfirmOpen(false); }} open={confirmOpen} size="xl" title="报备生成资格预检">
</label>
<Table
columns={pendingColumns}
data={pendingData.items}
emptyText="暂无符合条件的报备资料"
pagination={false}
rowKey="id"
/>
</div>
<Pagination
nextDisabled={pendingPage * pageSize >= pendingData.total}
onNext={() => setPendingPage((page) => page + 1)}
onPageChange={setPendingPage}
onPrevious={() => setPendingPage((page) => Math.max(1, page - 1))}
page={pendingPage}
previousDisabled={pendingPage <= 1}
total={pendingData.total}
totalPages={Math.max(1, Math.ceil(pendingData.total / pageSize))}
/>
</div>
<Modal
footer={
batchResult ? (
<Button onClick={() => setConfirmOpen(false)}></Button>
) : (
<>
<Button disabled={busy} onClick={() => setConfirmOpen(false)} variant="ghost">
</Button>
<Button
disabled={preflightBusy || busy || !preflight?.eligible}
icon={<ShieldCheck size={16} />}
onClick={() => void createBatch()}
>
{busy ? '生成处理中…' : '确认生成'}
</Button>
</>
)
}
onClose={() => {
if (!busy) setConfirmOpen(false);
}}
open={confirmOpen}
size="xl"
title="报备生成资格预检"
>
<div className="report-batch-preflight"> <div className="report-batch-preflight">
{preflightBusy ? <p role="status"></p> : null} {preflightBusy ? <p role="status"></p> : null}
{preflight ? <><div className="report-batch-summary"><span><CheckCircle2 size={17} /> {preflight.eligibleTargetCount} </span><span><AlertTriangle size={17} /> {preflight.skippedTargetCount} </span></div>{preflight.items.map((item) => <article key={item.id}><div><strong>{item.name}</strong><small>{item.tenantName} · {item.applicationName} · V{item.materialVersion}</small></div>{item.targets.length ? <ul>{item.targets.map((target) => <li className={target.eligible ? 'is-eligible' : 'is-blocked'} key={target.businessKey}><span>{target.name} · <CarrierTag carrier={target.carrier} /></span><small>{target.eligible ? '资格通过' : target.blockedReasons.join('')}</small></li>)}</ul> : <p className="form-error">{item.blockedReasons.join('')}</p>}</article>)}</> : null} {preflight ? (
{batchResult ? <div className="risk-action-result" role="status"><ShieldCheck size={20} /><div><strong> {batchResult.batchNo} </strong><span> {batchResult.result.successCount} · {batchResult.result.skippedCount} · {batchResult.result.failedCount}</span><span>{batchResult.operationId}{batchResult.replayed ? '(幂等重放)' : ''}</span></div></div> : null} <>
<div className="report-batch-summary">
<span>
<CheckCircle2 size={17} />
{preflight.eligibleTargetCount}
</span>
<span>
<AlertTriangle size={17} />
{preflight.skippedTargetCount}
</span>
</div>
{preflight.items.map((item) => (
<article key={item.id}>
<div>
<strong>{item.name}</strong>
<small>
{item.tenantName} · {item.applicationName} · V{item.materialVersion}
</small>
</div>
{item.targets.length ? (
<ul>
{item.targets.map((target) => (
<li className={target.eligible ? 'is-eligible' : 'is-blocked'} key={target.businessKey}>
<span>
{target.name} · <CarrierTag carrier={target.carrier} />
</span>
<small>{target.eligible ? '资格通过' : target.blockedReasons.join('')}</small>
</li>
))}
</ul>
) : (
<p className="form-error">{item.blockedReasons.join('')}</p>
)}
</article>
))}
</>
) : null}
{batchResult ? (
<div className="risk-action-result" role="status">
<ShieldCheck size={20} />
<div>
<strong> {batchResult.batchNo} </strong>
<span>
{batchResult.result.successCount} · {batchResult.result.skippedCount} · {' '}
{batchResult.result.failedCount}
</span>
<span>
{batchResult.operationId}
{batchResult.replayed ? '(幂等重放)' : ''}
</span>
</div>
</div>
) : null}
</div> </div>
</Modal> </Modal>
</section>; </section>
);
} }
function toBatchItem(item: ReportMaterialPendingItem) { function toBatchItem(item: ReportMaterialPendingItem) {
return { reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? undefined, materialVersion: item.materialVersion }; return {
reportType: item.reportType,
signatureId: item.signatureId,
drainageItemId: item.drainageItemId ?? undefined,
materialVersion: item.materialVersion,
};
} }
+254 -30
View File
@@ -1,7 +1,19 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Clock3, Eye, Search } from 'lucide-react'; import { Clock3, Eye, Search } from 'lucide-react';
import { adminApi, type ReportRecord } from '@/api/adminApi'; import { adminApi, type ReportRecord } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, type DateRangeValue, type TableColumn } from '@/components/ui'; import {
Breadcrumb,
Button,
DateRangeInput,
Input,
Modal,
Pagination,
Select,
Table,
Tag,
type DateRangeValue,
type TableColumn,
} from '@/components/ui';
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = { const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
pending: 'neutral', pending: 'neutral',
@@ -17,15 +29,40 @@ const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'd
}; };
const statusLabel: Record<string, string> = { const statusLabel: Record<string, string> = {
pending: '待报备', waiting_material: '待补充资料', waiting_review: '待重新审核', reporting: '报备中', exporting: '导出中', partial: '部分通过', approved: '已通过', success: '成功', completed: '已完成', failed: '失败', rejected: '已驳回', abandoned: '已废弃', imported: '已导入', deleted: '已删除', pending: '待报备',
waiting_material: '待补充资料',
waiting_review: '待重新审核',
reporting: '报备中',
exporting: '导出中',
partial: '部分通过',
approved: '已通过',
success: '成功',
completed: '已完成',
failed: '失败',
rejected: '已驳回',
abandoned: '已废弃',
imported: '已导入',
deleted: '已删除',
}; };
const actionLabel: Record<string, string> = { const actionLabel: Record<string, string> = {
create: '创建报备任务', manual_status_change: '人工修改状态', export: '导出报备资料', receipt_import: '导入回执', audit_approved_create: '引流审核通过后创建', audit_approved_reset: '引流审核通过后重置', audit_resubmit_freeze: '引流修改后冻结', audit_rejected_freeze: '引流审核驳回后冻结', drainage_deleted: '引流信息删除', create: '创建报备任务',
manual_status_change: '人工修改状态',
export: '导出报备资料',
receipt_import: '导入回执',
audit_approved_create: '引流审核通过后创建',
audit_approved_reset: '引流审核通过后重置',
audit_resubmit_freeze: '引流修改后冻结',
audit_rejected_freeze: '引流审核驳回后冻结',
drainage_deleted: '引流信息删除',
}; };
const sourceEntryLabel: Record<string, string> = { const sourceEntryLabel: Record<string, string> = {
enterprise_signature: '企业签名修改', report_task: '报备任务修改', channel_report: '通道信息修改', system: '系统自动处理', legacy: '历史记录(入口未记录)', enterprise_signature: '企业签名修改',
report_task: '报备任务修改',
channel_report: '通道信息修改',
system: '系统自动处理',
legacy: '历史记录(入口未记录)',
}; };
function translateStatus(value?: string | null) { function translateStatus(value?: string | null) {
@@ -42,22 +79,67 @@ function RecordDetailModal({ record, onClose }: { record: ReportRecord; onClose:
const isDrainage = record.task?.reportType === 'drainage'; const isDrainage = record.task?.reportType === 'drainage';
const target = isDrainage ? record.task?.drainageInfo?.url : record.task?.signature?.name; const target = isDrainage ? record.task?.drainageInfo?.url : record.task?.signature?.name;
return ( return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title={<div className="template-modal-title"><h2></h2><p>{record.id}</p></div>}> <Modal
footer={<Button onClick={onClose}></Button>}
onClose={onClose}
open
size="xl"
title={
<div className="template-modal-title">
<h2></h2>
<p>{record.id}</p>
</div>
}
>
<div className="report-record-detail"> <div className="report-record-detail">
<div className="detail-grid"> <div className="detail-grid">
<div><span></span><strong>{record.taskId}</strong></div> <div>
<div><span></span><strong>{record.channel?.name ?? '-'}</strong></div> <span></span>
<div><span></span><strong>{isDrainage ? '引流信息' : '签名'}</strong></div> <strong>{record.taskId}</strong>
<div><span></span><strong>{target ?? '-'}</strong></div> </div>
<div><span></span><strong>{actionLabel[record.action] ?? record.action}</strong></div> <div>
<div><span></span><strong>{recordSource(record)}</strong></div> <span></span>
<div><span></span><strong>{translateStatus(record.statusBefore)}</strong></div> <strong>{record.channel?.name ?? '-'}</strong>
<div><span></span><strong>{translateStatus(record.statusAfter)}</strong></div> </div>
<div className="detail-grid__wide"><span>/</span><strong>{record.reason ?? '-'}</strong></div> <div>
<span></span>
<strong>{isDrainage ? '引流信息' : '签名'}</strong>
</div>
<div>
<span></span>
<strong>{target ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{actionLabel[record.action] ?? record.action}</strong>
</div>
<div>
<span></span>
<strong>{recordSource(record)}</strong>
</div>
<div>
<span></span>
<strong>{translateStatus(record.statusBefore)}</strong>
</div>
<div>
<span></span>
<strong>{translateStatus(record.statusAfter)}</strong>
</div>
<div className="detail-grid__wide">
<span>/</span>
<strong>{record.reason ?? '-'}</strong>
</div>
</div> </div>
<section className="report-history"> <section className="report-history">
<h3><Clock3 size={17} /></h3> <h3>
<div><span>{record.createdAt ?? '-'}</span><strong>{actionLabel[record.action] ?? record.action}</strong><em>{record.reason ?? `修改入口:${recordSource(record)}`}</em></div> <Clock3 size={17} />
</h3>
<div>
<span>{record.createdAt ?? '-'}</span>
<strong>{actionLabel[record.action] ?? record.action}</strong>
<em>{record.reason ?? `修改入口:${recordSource(record)}`}</em>
</div>
</section> </section>
</div> </div>
</Modal> </Modal>
@@ -69,6 +151,10 @@ export function AdminReportRecordsPage() {
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({}); const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [reportType, setReportType] = useState('all'); const [reportType, setReportType] = useState('all');
const [batchNo, setBatchNo] = useState('');
const [operatorKeyword, setOperatorKeyword] = useState('');
const [statusAfter, setStatusAfter] = useState('all');
const [sourceEntry, setSourceEntry] = useState('all');
const [detail, setDetail] = useState<ReportRecord | null>(null); const [detail, setDetail] = useState<ReportRecord | null>(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -76,9 +162,14 @@ export function AdminReportRecordsPage() {
const pageSize = 10; const pageSize = 10;
function loadData(targetPage = page) { function loadData(targetPage = page) {
adminApi.listReportRecordsPage({ adminApi
.listReportRecordsPage({
keyword: keyword || undefined, keyword: keyword || undefined,
reportType: reportType === 'all' ? undefined : reportType as 'signature' | 'drainage', reportType: reportType === 'all' ? undefined : (reportType as 'signature' | 'drainage'),
batchNo: batchNo.trim() || undefined,
operatorKeyword: operatorKeyword.trim() || undefined,
statusAfter: statusAfter === 'all' ? undefined : statusAfter,
sourceEntry: sourceEntry === 'all' ? undefined : sourceEntry,
createdAtFrom: dateRange.start || undefined, createdAtFrom: dateRange.start || undefined,
createdAtTo: dateRange.end || undefined, createdAtTo: dateRange.end || undefined,
page: targetPage, page: targetPage,
@@ -97,35 +188,168 @@ export function AdminReportRecordsPage() {
}, [page]); }, [page]);
const columns: Array<TableColumn<ReportRecord>> = [ const columns: Array<TableColumn<ReportRecord>> = [
{ key: 'task', title: '报备任务号', width: '190px', render: (record) => <strong className="admin-task-id">{record.taskId}</strong> }, {
key: 'task',
title: '报备任务号',
width: '190px',
render: (record) => <strong className="admin-task-id">{record.taskId}</strong>,
},
{ key: 'channel', title: '通道名称', width: '180px', render: (record) => record.channel?.name ?? '-' }, { key: 'channel', title: '通道名称', width: '180px', render: (record) => record.channel?.name ?? '-' },
{ key: 'targetType', title: '变更主体', width: '110px', render: (record) => <Tag tone={record.task?.reportType === 'drainage' ? 'info' : 'neutral'}>{record.task?.reportType === 'drainage' ? '引流信息' : '签名'}</Tag> }, {
{ key: 'target', title: '主体内容', width: '260px', render: (record) => record.task?.reportType === 'drainage' ? <div className="admin-task-enterprise"><strong>{record.task?.signature?.name ?? '-'}</strong><span>{record.task?.drainageInfo?.url ?? '-'}</span>{record.task?.drainageInfo?.remark ? <span>{record.task.drainageInfo.remark}</span> : null}</div> : <div className="admin-task-enterprise"><strong>{record.task?.signature?.name ?? '-'}</strong>{record.task?.signature?.purpose ? <span>{record.task.signature.purpose}</span> : null}</div> }, key: 'targetType',
title: '变更主体',
width: '110px',
render: (record) => (
<Tag tone={record.task?.reportType === 'drainage' ? 'info' : 'neutral'}>
{record.task?.reportType === 'drainage' ? '引流信息' : '签名'}
</Tag>
),
},
{
key: 'target',
title: '主体内容',
width: '260px',
render: (record) =>
record.task?.reportType === 'drainage' ? (
<div className="admin-task-enterprise">
<strong>{record.task?.signature?.name ?? '-'}</strong>
<span>{record.task?.drainageInfo?.url ?? '-'}</span>
{record.task?.drainageInfo?.remark ? <span>{record.task.drainageInfo.remark}</span> : null}
</div>
) : (
<div className="admin-task-enterprise">
<strong>{record.task?.signature?.name ?? '-'}</strong>
{record.task?.signature?.purpose ? <span>{record.task.signature.purpose}</span> : null}
</div>
),
},
{ key: 'source', title: '修改入口', width: '150px', render: (record) => recordSource(record) }, { key: 'source', title: '修改入口', width: '150px', render: (record) => recordSource(record) },
{
key: 'operator',
title: '操作人',
width: '140px',
render: (record) => record.operator?.displayName ?? record.operator?.username ?? '系统',
},
{ key: 'action', title: '动作', width: '170px', render: (record) => actionLabel[record.action] ?? record.action }, { key: 'action', title: '动作', width: '170px', render: (record) => actionLabel[record.action] ?? record.action },
{ key: 'status', title: '状态变化', width: '210px', render: (record) => <Tag tone={statusTone[record.statusAfter ?? 'pending'] ?? 'info'}>{`${translateStatus(record.statusBefore)}${translateStatus(record.statusAfter)}`}</Tag> }, {
key: 'status',
title: '状态变化',
width: '210px',
render: (record) => (
<Tag
tone={statusTone[record.statusAfter ?? 'pending'] ?? 'info'}
>{`${translateStatus(record.statusBefore)}${translateStatus(record.statusAfter)}`}</Tag>
),
},
{ key: 'time', title: '记录时间', width: '190px', render: (record) => record.createdAt ?? '-' }, { key: 'time', title: '记录时间', width: '190px', render: (record) => record.createdAt ?? '-' },
{ key: 'reason', title: '备注', width: '320px', render: (record) => <span className="ui-table__long-text">{record.reason ?? '-'}</span> }, {
{ key: 'actions', title: '操作', align: 'right', width: '120px', render: (record) => <Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button> }, key: 'reason',
title: '备注',
width: '320px',
render: (record) => <span className="ui-table__long-text">{record.reason ?? '-'}</span>,
},
{
key: 'actions',
title: '操作',
align: 'right',
width: '120px',
render: (record) => (
<Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost">
</Button>
),
},
]; ];
return ( return (
<section className="page-stack admin-sms-task-page report-record-page"> <section className="page-stack admin-sms-task-page report-record-page">
<div className="page-heading"> <div className="page-heading">
<div> <div>
<Breadcrumb items={['报备任务', '报备记录']} /> <Breadcrumb items={['报备工作台', '状态记录']} />
<h1></h1> <h1></h1>
</div> </div>
</div> </div>
{error ? <p className="form-error">{error}</p> : null} {error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-task-filter"> <div className="surface admin-task-filter">
<Input label="报备任务号/通道/动作/备注" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入报备任务号、通道、动作或备注" value={keyword} /> <Input
<Select label="报备类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部类型', value: 'all' }, { label: '签名报备', value: 'signature' }, { label: '引流信息报备', value: 'drainage' }]} value={reportType} /> label="报备任务号/通道/动作/备注"
onChange={(event) => setKeyword(event.target.value)}
placeholder="请输入报备任务号、通道、动作或备注"
value={keyword}
/>
<Select
label="报备类型"
onChange={(event) => setReportType(event.target.value)}
options={[
{ label: '全部类型', value: 'all' },
{ label: '签名报备', value: 'signature' },
{ label: '引流信息报备', value: 'drainage' },
]}
value={reportType}
/>
<Input
label="报备批次号"
onChange={(event) => setBatchNo(event.target.value)}
placeholder="输入批次号"
value={batchNo}
/>
<Input
label="操作人"
onChange={(event) => setOperatorKeyword(event.target.value)}
placeholder="姓名或账号"
value={operatorKeyword}
/>
<Select
label="变更后状态"
onChange={(event) => setStatusAfter(event.target.value)}
options={[
{ label: '全部状态', value: 'all' },
{ label: '未报备', value: 'pending' },
{ label: '报备中', value: 'reporting' },
{ label: '报备通过', value: 'approved' },
{ label: '报备失败', value: 'failed' },
{ label: '已放弃', value: 'abandoned' },
]}
value={statusAfter}
/>
<Select
label="修改入口"
onChange={(event) => setSourceEntry(event.target.value)}
options={[
{ label: '全部入口', value: 'all' },
{ label: '企业签名修改', value: 'enterprise_signature' },
{ label: '通道报备明细', value: 'report_task' },
{ label: '通道详情', value: 'channel_report' },
{ label: '系统自动处理', value: 'system' },
]}
value={sourceEntry}
/>
<DateRangeInput label="提交时间" onChange={setDateRange} value={dateRange} /> <DateRangeInput label="提交时间" onChange={setDateRange} value={dateRange} />
<div className="admin-task-filter__actions"> <div className="admin-task-filter__actions">
<Button icon={<Search size={16} />} onClick={() => { if (page !== 1) setPage(1); else loadData(1); }}></Button> <Button
<Button onClick={() => { setKeyword(''); setDateRange({}); setReportType('all'); }} variant="ghost"></Button> icon={<Search size={16} />}
onClick={() => {
if (page !== 1) setPage(1);
else loadData(1);
}}
>
</Button>
<Button
onClick={() => {
setKeyword('');
setDateRange({});
setReportType('all');
setBatchNo('');
setOperatorKeyword('');
setStatusAfter('all');
setSourceEntry('all');
}}
variant="ghost"
>
</Button>
</div> </div>
</div> </div>
+392 -57
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Eye, Search } from 'lucide-react'; import { Download, Eye, Search } from 'lucide-react';
import { adminApi, fileDownloadUrl, type ReportTask } from '@/api/adminApi'; import { useSearchParams } from 'react-router-dom';
import { adminApi, fileDownloadUrl, type ReportTask, type SingleReportMaterialDetail } from '@/api/adminApi';
import { Breadcrumb, Button, CarrierTag, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui'; import { Breadcrumb, Button, CarrierTag, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime'; import { formatDateTime } from '@/utils/dateTime';
@@ -31,45 +32,122 @@ function taskTargetLabel(task: ReportTask) {
function TaskDetailModal({ task, onClose }: { task: ReportTask; onClose: () => void }) { function TaskDetailModal({ task, onClose }: { task: ReportTask; onClose: () => void }) {
const status = statusMeta[task.status] ?? { label: task.status, tone: 'info' as const }; const status = statusMeta[task.status] ?? { label: task.status, tone: 'info' as const };
const source = task.exportItems?.[0]; const source = task.exportItems?.[0];
return <Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title="报备明细详情"> return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title="报备明细详情">
<div className="page-stack"> <div className="page-stack">
<div className="detail-grid"> <div className="detail-grid">
<div><span></span><strong>{taskTargetLabel(task)}</strong></div> <div>
<div><span></span><strong>{task.reportType === 'drainage' ? '引流信息' : '签名'}</strong></div> <span></span>
<div><span></span><strong>{task.signature?.tenant?.name ?? task.tenantId}</strong></div> <strong>{taskTargetLabel(task)}</strong>
<div><span></span><strong>{task.signature?.application?.name ?? '未指定应用'}</strong></div>
<div><span></span><strong>{task.channel?.name ?? task.channelId}</strong></div>
{task.reportType !== 'drainage' ? <div><span></span>{task.carrier ? <CarrierTag carrier={task.carrier} /> : <strong></strong>}</div> : null}
{task.reportType !== 'drainage' ? <div><span></span><strong>{task.approvedAt ? formatDateTime(task.approvedAt) : '-'}</strong></div> : null}
<div><span></span><Tag tone={status.tone}>{status.label}</Tag></div>
<div><span></span><strong>{formatDateTime(task.createdAt)}</strong></div>
<div><span></span><strong>{formatDateTime(task.updatedAt)}</strong></div>
<div><span></span><strong>{source ? `V${source.batchItem.materialVersion}` : '-'}</strong></div>
<div><span></span><strong>{source?.batchItem.batch.batchNo ?? '-'}</strong></div>
<div><span></span><strong>{source ? `${source.rowNumber}` : '-'}</strong></div>
<div><span></span><strong>{task.reason || '-'}</strong></div>
</div> </div>
{source?.exportFile.fileObjectId ? <div className="surface" style={{ padding: 16 }}><a href={fileDownloadUrl(source.exportFile.fileObjectId)}>{source.exportFile.fileName}</a></div> : null} <div>
<span></span>
<strong>{task.reportType === 'drainage' ? '引流信息' : '签名'}</strong>
</div>
<div>
<span></span>
<strong>{task.signature?.tenant?.name ?? task.tenantId}</strong>
</div>
<div>
<span></span>
<strong>{task.signature?.application?.name ?? '未指定应用'}</strong>
</div>
<div>
<span></span>
<strong>{task.channel?.name ?? task.channelId}</strong>
</div>
{task.reportType !== 'drainage' ? (
<div>
<span></span>
{task.carrier ? <CarrierTag carrier={task.carrier} /> : <strong></strong>}
</div>
) : null}
{task.reportType !== 'drainage' ? (
<div>
<span></span>
<strong>{task.approvedAt ? formatDateTime(task.approvedAt) : '-'}</strong>
</div>
) : null}
<div>
<span></span>
<Tag tone={status.tone}>{status.label}</Tag>
</div>
<div>
<span></span>
<strong>{formatDateTime(task.createdAt)}</strong>
</div>
<div>
<span></span>
<strong>{formatDateTime(task.updatedAt)}</strong>
</div>
<div>
<span></span>
<strong>{source ? `V${source.batchItem.materialVersion}` : '-'}</strong>
</div>
<div>
<span></span>
<strong>{source?.batchItem.batch.batchNo ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{source ? `${source.rowNumber}` : '-'}</strong>
</div>
<div>
<span></span>
<strong>{task.reason || '-'}</strong>
</div>
</div>
{source?.exportFile.fileObjectId ? (
<div className="surface" style={{ padding: 16 }}>
<a href={fileDownloadUrl(source.exportFile.fileObjectId)}>{source.exportFile.fileName}</a>
</div>
) : null}
<div className="surface" style={{ padding: 16 }}> <div className="surface" style={{ padding: 16 }}>
<h3></h3> <h3></h3>
<div className="page-stack" style={{ marginTop: 12 }}> <div className="page-stack" style={{ marginTop: 12 }}>
{(task.records ?? []).length ? task.records!.map((record) => <div className="detail-grid" key={record.id}> {(task.records ?? []).length ? (
<div><span></span><strong>{formatDateTime(record.createdAt)}</strong></div> task.records!.map((record) => (
<div><span></span><strong>{actionLabels[record.action] ?? record.action}</strong></div> <div className="detail-grid" key={record.id}>
<div><span></span><strong>{statusMeta[record.statusBefore ?? '']?.label ?? record.statusBefore ?? '-'} {statusMeta[record.statusAfter]?.label ?? record.statusAfter}</strong></div> <div>
<div><span></span><strong>{record.reason || '-'}</strong></div> <span></span>
</div>) : <p className="muted"></p>} <strong>{formatDateTime(record.createdAt)}</strong>
</div>
<div>
<span></span>
<strong>{actionLabels[record.action] ?? record.action}</strong>
</div>
<div>
<span></span>
<strong>
{statusMeta[record.statusBefore ?? '']?.label ?? record.statusBefore ?? '-'} {statusMeta[record.statusAfter]?.label ?? record.statusAfter}
</strong>
</div>
<div>
<span></span>
<strong>{record.reason || '-'}</strong>
</div>
</div>
))
) : (
<p className="muted"></p>
)}
</div> </div>
</div> </div>
</div> </div>
</Modal>; </Modal>
);
} }
export function AdminReportTasksPage() { export function AdminReportTasksPage() {
const [searchParams] = useSearchParams();
const [tasks, setTasks] = useState<ReportTask[]>([]); const [tasks, setTasks] = useState<ReportTask[]>([]);
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({}); const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [reportType, setReportType] = useState('all'); const [reportType, setReportType] = useState('all');
const [status, setStatus] = useState(searchParams.get('scope') === 'pending' ? 'pending' : 'all');
const [carrier, setCarrier] = useState('all');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [material, setMaterial] = useState<SingleReportMaterialDetail | null>(null);
const [detailTask, setDetailTask] = useState<ReportTask | null>(null); const [detailTask, setDetailTask] = useState<ReportTask | null>(null);
const [statusTask, setStatusTask] = useState<ReportTask | null>(null); const [statusTask, setStatusTask] = useState<ReportTask | null>(null);
const [nextStatus, setNextStatus] = useState('approved'); const [nextStatus, setNextStatus] = useState('approved');
@@ -81,8 +159,12 @@ export function AdminReportTasksPage() {
const pageSize = 10; const pageSize = 10;
function loadData(targetPage = page) { function loadData(targetPage = page) {
adminApi.listReportTasksPage({ adminApi
reportType: reportType === 'all' ? undefined : reportType as 'signature' | 'drainage', .listReportDetailsPage({
signatureId: searchParams.get('signatureId') || undefined,
reportType: reportType === 'all' ? undefined : (reportType as 'signature' | 'drainage'),
status: status === 'all' ? undefined : status,
carrier: carrier === 'all' ? undefined : carrier,
keyword: keyword || undefined, keyword: keyword || undefined,
createdAtFrom: dateRange.start || undefined, createdAtFrom: dateRange.start || undefined,
createdAtTo: dateRange.end || undefined, createdAtTo: dateRange.end || undefined,
@@ -103,22 +185,24 @@ export function AdminReportTasksPage() {
async function saveTaskStatus() { async function saveTaskStatus() {
if (!statusTask) return; if (!statusTask) return;
const chosen = selected.size ? tasks.filter((task) => selected.has(task.id)) : [statusTask];
setBusy(true); setBusy(true);
try { try {
await adminApi.changeReportTaskStatuses({ await adminApi.changeReportTaskStatuses({
items: [{ items: chosen.map((task) => ({
signatureId: statusTask.signatureId, signatureId: task.signatureId,
channelId: statusTask.channelId, channelId: task.channelId,
carrier: statusTask.carrier ?? undefined, carrier: task.carrier ?? undefined,
reportType: statusTask.reportType, reportType: task.reportType,
drainageItemId: statusTask.drainageItemId ?? undefined, drainageItemId: task.drainageItemId ?? undefined,
status: nextStatus, status: nextStatus,
}], })),
reason: statusReason.trim() || undefined, reason: statusReason.trim() || undefined,
sourceEntry: 'report_task', sourceEntry: 'report_task',
}); });
setStatusTask(null); setStatusTask(null);
setStatusReason(''); setStatusReason('');
setSelected(new Set());
loadData(); loadData();
} catch (failure) { } catch (failure) {
setError(failure instanceof Error ? failure.message : '报备状态保存失败'); setError(failure instanceof Error ? failure.message : '报备状态保存失败');
@@ -127,56 +211,307 @@ export function AdminReportTasksPage() {
} }
} }
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 : '单条资料导出失败');
}
}
const columns: Array<TableColumn<ReportTask>> = [ const columns: Array<TableColumn<ReportTask>> = [
{ key: 'target', title: '报备对象', render: (record) => <div><strong>{taskTargetLabel(record)}</strong><div className="muted">{record.reportType === 'drainage' ? '引流信息' : '签名'} · {record.signature?.tenant?.name ?? record.tenantId}</div></div> }, {
key: 'select',
title: '',
width: '44px',
render: (record) => (
<input
aria-label={`选择${taskTargetLabel(record)}`}
checked={selected.has(record.id)}
onChange={() =>
setSelected((current) => {
const next = new Set(current);
if (next.has(record.id)) next.delete(record.id);
else next.add(record.id);
return next;
})
}
type="checkbox"
/>
),
},
{
key: 'target',
title: '报备对象',
render: (record) => (
<div>
<strong>{taskTargetLabel(record)}</strong>
<div className="muted">
{record.reportType === 'drainage' ? '引流信息' : '签名'} · {record.signature?.tenant?.name ?? record.tenantId}
</div>
</div>
),
},
{ key: 'application', title: '企业应用', render: (record) => record.signature?.application?.name ?? '未指定应用' }, { key: 'application', title: '企业应用', render: (record) => record.signature?.application?.name ?? '未指定应用' },
{ key: 'channel', title: '通道/运营商', render: (record) => <div><strong>{record.channel?.name ?? record.channelId}</strong>{record.reportType !== 'drainage' ? <div className="muted">{record.carrier ? <CarrierTag carrier={record.carrier} /> : '历史通道级(未拆分)'}</div> : null}</div> }, {
{ key: 'batch', title: '批次/版本', render: (record) => { key: 'channel',
title: '通道/运营商',
render: (record) => (
<div>
<strong>{record.channel?.name ?? record.channelId}</strong>
{record.reportType !== 'drainage' ? <div className="muted">{record.carrier ? <CarrierTag carrier={record.carrier} /> : '历史通道级(未拆分)'}</div> : null}
</div>
),
},
{
key: 'batch',
title: '批次/版本',
render: (record) => {
const source = record.exportItems?.[0]; const source = record.exportItems?.[0];
return source ? <div><strong>{source.batchItem.batch.batchNo}</strong><div className="muted">V{source.batchItem.materialVersion} · {source.rowNumber}</div></div> : '-'; return source ? (
} }, <div>
{ key: 'status', title: '状态', render: (record) => <Tag tone={(statusMeta[record.status] ?? { tone: 'info' as const }).tone}>{(statusMeta[record.status] ?? { label: record.status }).label}</Tag> }, <strong>{source.batchItem.batch.batchNo}</strong>
<div className="muted">
V{source.batchItem.materialVersion} · {source.rowNumber}
</div>
</div>
) : (
'-'
);
},
},
{
key: 'status',
title: '状态',
render: (record) => <Tag tone={(statusMeta[record.status] ?? { tone: 'info' as const }).tone}>{(statusMeta[record.status] ?? { label: record.status }).label}</Tag>,
},
{ key: 'time', title: '更新时间', render: (record) => formatDateTime(record.updatedAt ?? record.createdAt) }, { key: 'time', title: '更新时间', render: (record) => formatDateTime(record.updatedAt ?? record.createdAt) },
{ key: 'actions', title: '操作', align: 'right', render: (record) => <div className="table-actions"><Button icon={<Eye size={14} />} onClick={() => setDetailTask(record)} size="sm" variant="ghost"></Button><Button onClick={() => { {
key: 'actions',
title: '操作',
align: 'right',
render: (record) => (
<div className="table-actions">
<Button icon={<Eye size={14} />} onClick={() => void openMaterial(record)} size="sm" variant="ghost">
</Button>
{record.reportType !== 'drainage' ? (
<Button icon={<Download size={14} />} onClick={() => void exportMaterial(record)} size="sm" variant="ghost">
</Button>
) : null}
<Button
onClick={() => {
setSelected(new Set());
setStatusTask(record); setStatusTask(record);
setNextStatus(record.status); setNextStatus(record.status);
setStatusReason(''); setStatusReason('');
}} size="sm" variant="ghost"></Button></div> }, }}
size="sm"
variant="ghost"
>
</Button>
</div>
),
},
]; ];
return <section className="page-stack admin-sms-task-page report-task-page"> return (
<div className="page-heading"><div><Breadcrumb items={['报备任务', '报备明细']} /><h1></h1><p></p></div></div> <section className="page-stack admin-sms-task-page report-task-page">
<div className="page-heading">
<div>
<Breadcrumb items={['报备工作台', '通道报备明细']} />
<h1></h1>
<p> × / × × </p>
</div>
<Button
disabled={!selected.size}
onClick={() => {
const first = tasks.find((task) => selected.has(task.id));
if (first) {
setStatusTask(first);
setNextStatus('reporting');
}
}}
>
{selected.size}
</Button>
</div>
{error ? <p className="form-error">{error}</p> : null} {error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-task-filter"> <div className="surface admin-task-filter">
<Input label="企业/应用/通道/报备对象" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索报备明细" value={keyword} /> <Input label="企业/应用/通道/报备对象" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索报备明细" value={keyword} />
<Select label="报备类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部类型', value: 'all' }, { label: '签名报备', value: 'signature' }, { label: '引流信息报备', value: 'drainage' }]} value={reportType} /> <Select
label="报备类型"
onChange={(event) => setReportType(event.target.value)}
options={[
{ label: '全部类型', value: 'all' },
{ label: '签名报备', value: 'signature' },
{ label: '引流信息报备', value: 'drainage' },
]}
value={reportType}
/>
<Select
label="运营商"
onChange={(event) => setCarrier(event.target.value)}
options={[
{ label: '全部运营商', value: 'all' },
{ label: '移动', value: 'mobile' },
{ label: '联通', value: 'unicom' },
{ label: '电信', value: 'telecom' },
]}
value={carrier}
/>
<Select label="报备状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, ...Object.entries(statusMeta).map(([value, meta]) => ({ label: meta.label, value }))]} value={status} />
<DateRangeInput label="创建时间" onChange={setDateRange} value={dateRange} /> <DateRangeInput label="创建时间" onChange={setDateRange} value={dateRange} />
<div className="admin-task-filter__actions"><Button icon={<Search size={16} />} onClick={() => { if (page !== 1) setPage(1); else loadData(1); }}></Button><Button onClick={() => { <div className="admin-task-filter__actions">
<Button
icon={<Search size={16} />}
onClick={() => {
if (page !== 1) setPage(1);
else loadData(1);
}}
>
</Button>
<Button
onClick={() => {
setKeyword(''); setKeyword('');
setDateRange({}); setDateRange({});
setReportType('all'); setReportType('all');
}} variant="ghost"></Button></div> setCarrier('all');
setStatus('all');
}}
variant="ghost"
>
</Button>
</div>
</div>
<div className="surface report-task-table-card">
<Table columns={columns} data={tasks} emptyText="暂无报备明细" pagination={false} rowKey="id" />
</div> </div>
<div className="surface report-task-table-card"><Table columns={columns} data={tasks} emptyText="暂无报备明细" pagination={false} rowKey="id" /></div>
<Pagination nextDisabled={page * pageSize >= total} onNext={() => setPage((current) => current + 1)} onPageChange={setPage} onPrevious={() => setPage((current) => Math.max(1, current - 1))} page={page} previousDisabled={page <= 1} total={total} totalPages={Math.max(1, Math.ceil(total / pageSize))} /> <Pagination nextDisabled={page * pageSize >= total} onNext={() => setPage((current) => current + 1)} onPageChange={setPage} onPrevious={() => setPage((current) => Math.max(1, current - 1))} page={page} previousDisabled={page <= 1} total={total} totalPages={Math.max(1, Math.ceil(total / pageSize))} />
{detailTask ? <TaskDetailModal onClose={() => setDetailTask(null)} task={detailTask} /> : null} {detailTask ? <TaskDetailModal onClose={() => setDetailTask(null)} task={detailTask} /> : null}
<Modal footer={<><Button disabled={busy} onClick={() => setStatusTask(null)} variant="ghost"></Button><Button disabled={busy} onClick={() => void saveTaskStatus()}>{busy ? '保存中…' : '保存'}</Button></>} onClose={() => setStatusTask(null)} open={Boolean(statusTask)} title="修改报备状态"> {material ? (
{statusTask ? <div className="page-stack"> <Modal footer={<Button onClick={() => setMaterial(null)}></Button>} onClose={() => setMaterial(null)} open size="xl" title="查看报备资料">
<div className="page-stack">
<div className="detail-grid"> <div className="detail-grid">
<div><span></span><strong>{taskTargetLabel(statusTask)}</strong></div> <div>
<div><span></span><strong>{statusTask.channel?.name ?? statusTask.channelId}</strong></div> <span></span>
<div><span></span><strong>{statusMeta[statusTask.status]?.label ?? statusTask.status}</strong></div> <strong>{material.signatureName}</strong>
</div> </div>
<Select label="修改为" onChange={(event) => setNextStatus(event.target.value)} options={[ <div>
<span>/</span>
<strong>
{material.tenant.name} · {material.application?.name ?? '-'}
</strong>
</div>
<div>
<span>/</span>
<strong>
{material.channel.name} · V{material.materialVersion}
</strong>
</div>
</div>
<div className="report-material-detail-list">
{material.fields.map((field) => (
<div className={field.missing ? 'is-missing' : ''} key={field.id}>
<span>
{field.exportName || field.name}
{field.required ? ' *' : ''}
</span>
<strong>{typeof field.value === 'object' ? String((field.value as Record<string, unknown>)?.fileName ?? '-') : String(field.value ?? '-')}</strong>
</div>
))}
{material.historicalFields.map((field) => (
<div key={field.code}>
<span>{field.name}</span>
<strong>{String(field.value ?? '-')}</strong>
</div>
))}
</div>
</div>
</Modal>
) : null}
<Modal
footer={
<>
<Button disabled={busy} onClick={() => setStatusTask(null)} variant="ghost">
</Button>
<Button disabled={busy} onClick={() => void saveTaskStatus()}>
{busy ? '保存中…' : '保存'}
</Button>
</>
}
onClose={() => setStatusTask(null)}
open={Boolean(statusTask)}
title="修改报备状态"
>
{statusTask ? (
<div className="page-stack">
<div className="detail-grid">
<div>
<span></span>
<strong>{selected.size ? `已选择 ${selected.size} 条明细` : taskTargetLabel(statusTask)}</strong>
</div>
<div>
<span></span>
<strong>{statusTask.channel?.name ?? statusTask.channelId}</strong>
</div>
<div>
<span></span>
<strong>{statusMeta[statusTask.status]?.label ?? statusTask.status}</strong>
</div>
</div>
<Select
label="修改为"
onChange={(event) => setNextStatus(event.target.value)}
options={[
{ label: '未报备', value: 'pending' }, { label: '未报备', value: 'pending' },
{ label: '资料待补充', value: 'waiting_material' }, { label: '资料待补充', value: 'waiting_material' },
{ label: '报备中', value: 'reporting' }, { label: '报备中', value: 'reporting' },
{ label: '报备通过', value: 'approved' }, { label: '报备通过', value: 'approved' },
{ label: '报备失败', value: 'failed' }, { label: '报备失败', value: 'failed' },
{ label: '放弃报备', value: 'abandoned' }, { label: '放弃报备', value: 'abandoned' },
]} value={nextStatus} /> ]}
value={nextStatus}
/>
<Textarea label="修改原因(选填)" onChange={(event) => setStatusReason(event.target.value)} placeholder="可填写供应商反馈或人工处理说明" rows={3} value={statusReason} /> <Textarea label="修改原因(选填)" onChange={(event) => setStatusReason(event.target.value)} placeholder="可填写供应商反馈或人工处理说明" rows={3} value={statusReason} />
</div> : null} </div>
) : null}
</Modal> </Modal>
</section>; </section>
);
} }
@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import type { ClientSmsSignature } from '@/api/adminApi'; import type { ClientSmsSignature } from '@/api/adminApi';
import { EnterpriseSignaturesTable } from './EnterpriseSignaturesTable'; import { EnterpriseSignaturesTable } from './EnterpriseSignaturesTable';
@@ -12,6 +13,7 @@ const signature = {
auditStatus: 'approved', auditStatus: 'approved',
createdAt: '2026-09-01T00:00:00.000Z', createdAt: '2026-09-01T00:00:00.000Z',
updatedAt: '2026-09-01T01:00:00.000Z', updatedAt: '2026-09-01T01:00:00.000Z',
pendingReportDetailCount: 4,
tenant: { id: 'tenant-1', name: '深圳市聆界科技有限公司', status: 'active' }, tenant: { id: 'tenant-1', name: '深圳市聆界科技有限公司', status: 'active' },
application: { id: 'app-1', tenantId: 'tenant-1', name: '营销通知应用', status: 'active' }, application: { id: 'app-1', tenantId: 'tenant-1', name: '营销通知应用', status: 'active' },
carrierReportSummary: { carrierReportSummary: {
@@ -19,7 +21,9 @@ const signature = {
unicom: { status: 'reporting', approved: 2, total: 3 }, unicom: { status: 'reporting', approved: 2, total: 3 },
telecom: { status: 'abandoned', approved: 0, total: 3 }, telecom: { status: 'abandoned', approved: 0, total: 3 },
}, },
drainageInfo: { links: [{ id: 'drainage-1', siteName: 'www.lisglo.com', url: 'www.lisglo.com', auditStatus: 'approved' }] }, drainageInfo: {
links: [{ id: 'drainage-1', siteName: 'www.lisglo.com', url: 'www.lisglo.com', auditStatus: 'approved' }],
},
drainageCarrierReportSummary: { drainageCarrierReportSummary: {
'drainage-1': { 'drainage-1': {
mobile: { status: 'approved', approved: 3, total: 3 }, mobile: { status: 'approved', approved: 3, total: 3 },
@@ -31,14 +35,33 @@ const signature = {
function renderTable(overrides: Partial<Parameters<typeof EnterpriseSignaturesTable>[0]> = {}) { function renderTable(overrides: Partial<Parameters<typeof EnterpriseSignaturesTable>[0]> = {}) {
const props: Parameters<typeof EnterpriseSignaturesTable>[0] = { const props: Parameters<typeof EnterpriseSignaturesTable>[0] = {
appliedDrainageKeyword: '', currentPage: 1, expandedSignatureId: signature.id, appliedDrainageKeyword: '',
filteredSignatures: [signature], visibleSignatures: [signature], total: 1, totalPages: 1, currentPage: 1,
loadData: vi.fn().mockResolvedValue(undefined), setDeleteTarget: vi.fn(), setDrainageModal: vi.fn(), expandedSignatureId: signature.id,
setDrainageStatusTarget: vi.fn(), setExpandedSignatureId: vi.fn(), setPage: vi.fn(), filteredSignatures: [signature],
setReportStatusTarget: vi.fn(), setSignatureModal: vi.fn(), setSignatureSort: vi.fn(), signatureSort: 'asc', visibleSignatures: [signature],
total: 1,
totalPages: 1,
loadData: vi.fn().mockResolvedValue(undefined),
setDeleteTarget: vi.fn(),
setDrainageModal: vi.fn(),
setDrainageStatusTarget: vi.fn(),
setExpandedSignatureId: vi.fn(),
setPage: vi.fn(),
setReportStatusTarget: vi.fn(),
setSignatureModal: vi.fn(),
setSignatureSort: vi.fn(),
signatureSort: 'asc',
...overrides, ...overrides,
}; };
return { ...render(<EnterpriseSignaturesTable {...props} />), props }; return {
...render(
<MemoryRouter>
<EnterpriseSignaturesTable {...props} />
</MemoryRouter>,
),
props,
};
} }
describe('EnterpriseSignaturesTable dense presentation', () => { describe('EnterpriseSignaturesTable dense presentation', () => {
@@ -56,6 +79,7 @@ describe('EnterpriseSignaturesTable dense presentation', () => {
expect(screen.getAllByRole('button', { name: '报备状态' })).toHaveLength(2); expect(screen.getAllByRole('button', { name: '报备状态' })).toHaveLength(2);
expect(screen.getAllByRole('button', { name: '编辑' })).toHaveLength(2); expect(screen.getAllByRole('button', { name: '编辑' })).toHaveLength(2);
expect(screen.getAllByRole('button', { name: '删除' })).toHaveLength(2); expect(screen.getAllByRole('button', { name: '删除' })).toHaveLength(2);
expect(screen.getByRole('button', { name: '4 条' })).toBeVisible();
}); });
it('requests a real descending sort from the signature column control', async () => { it('requests a real descending sort from the signature column control', async () => {
@@ -64,7 +88,11 @@ describe('EnterpriseSignaturesTable dense presentation', () => {
await userEvent.click(screen.getByRole('button', { name: '签名降序' })); await userEvent.click(screen.getByRole('button', { name: '签名降序' }));
expect(setSignatureSort).toHaveBeenCalledWith('desc'); expect(setSignatureSort).toHaveBeenCalledWith('desc');
expect(screen.getByRole('button', { name: '签名升序' }).querySelector('.lucide-triangle')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '签名升序' }).querySelector('.lucide-triangle')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '签名降序' }).querySelector('.enterprise-signature-table__sort-triangle--down')).toBeInTheDocument(); expect(
screen
.getByRole('button', { name: '签名降序' })
.querySelector('.enterprise-signature-table__sort-triangle--down'),
).toBeInTheDocument();
expect(document.querySelector('.lucide-arrow-up, .lucide-arrow-down')).not.toBeInTheDocument(); expect(document.querySelector('.lucide-arrow-up, .lucide-arrow-down')).not.toBeInTheDocument();
}); });
}); });
@@ -1,8 +1,15 @@
import type { Dispatch, SetStateAction } from 'react'; import type { Dispatch, SetStateAction } from 'react';
import { ChevronDown, ChevronRight, Edit3, Plus, Triangle } from 'lucide-react'; import { ChevronDown, ChevronRight, Edit3, Plus, Triangle } from 'lucide-react';
import type { ClientSmsSignature } from '@/api/adminApi'; import type { ClientSmsSignature } from '@/api/adminApi';
import { useNavigate } from 'react-router-dom';
import { Button, DeleteRiskAction, Pagination } from '@/components/ui'; import { Button, DeleteRiskAction, Pagination } from '@/components/ui';
import { AuditStatusTag, CarrierReportCount, formatSignatureName, readDrainagePayload, signatureCardVisual } from './signature.helpers'; import {
AuditStatusTag,
CarrierReportCount,
formatSignatureName,
readDrainagePayload,
signatureCardVisual,
} from './signature.helpers';
import type { DrainageInfo } from './signature.types'; import type { DrainageInfo } from './signature.types';
type EnterpriseSignaturesTableProps = { type EnterpriseSignaturesTableProps = {
@@ -44,6 +51,7 @@ export function EnterpriseSignaturesTable({
totalPages, totalPages,
visibleSignatures, visibleSignatures,
}: EnterpriseSignaturesTableProps) { }: EnterpriseSignaturesTableProps) {
const navigate = useNavigate();
return ( return (
<div className="signature-list admin-enterprise-signature-list"> <div className="signature-list admin-enterprise-signature-list">
<div className="enterprise-signature-table__head" role="row"> <div className="enterprise-signature-table__head" role="row">
@@ -51,8 +59,33 @@ export function EnterpriseSignaturesTable({
<span className="enterprise-signature-table__sortable"> <span className="enterprise-signature-table__sortable">
<span className="enterprise-signature-table__sort-actions"> <span className="enterprise-signature-table__sort-actions">
<Button aria-pressed={signatureSort === 'asc'} icon={<Triangle aria-hidden="true" fill="currentColor" size={11} />} iconOnly onClick={() => setSignatureSort('asc')} size="sm" variant={signatureSort === 'asc' ? 'secondary' : 'ghost'}></Button> <Button
<Button aria-pressed={signatureSort === 'desc'} icon={<Triangle aria-hidden="true" className="enterprise-signature-table__sort-triangle--down" fill="currentColor" size={11} />} iconOnly onClick={() => setSignatureSort('desc')} size="sm" variant={signatureSort === 'desc' ? 'secondary' : 'ghost'}></Button> aria-pressed={signatureSort === 'asc'}
icon={<Triangle aria-hidden="true" fill="currentColor" size={11} />}
iconOnly
onClick={() => setSignatureSort('asc')}
size="sm"
variant={signatureSort === 'asc' ? 'secondary' : 'ghost'}
>
</Button>
<Button
aria-pressed={signatureSort === 'desc'}
icon={
<Triangle
aria-hidden="true"
className="enterprise-signature-table__sort-triangle--down"
fill="currentColor"
size={11}
/>
}
iconOnly
onClick={() => setSignatureSort('desc')}
size="sm"
variant={signatureSort === 'desc' ? 'secondary' : 'ghost'}
>
</Button>
</span> </span>
</span> </span>
<span></span> <span></span>
@@ -62,33 +95,92 @@ export function EnterpriseSignaturesTable({
<span></span> <span></span>
<span></span> <span></span>
<span></span> <span></span>
<span></span>
<span></span> <span></span>
</div> </div>
{visibleSignatures.map((signature) => { {visibleSignatures.map((signature) => {
const payload = readDrainagePayload(signature); const payload = readDrainagePayload(signature);
const visibleDrainageLinks = appliedDrainageKeyword const visibleDrainageLinks = appliedDrainageKeyword
? payload.links.filter((item) => `${item.siteName} ${item.url} ${item.remark}`.includes(appliedDrainageKeyword)) ? payload.links.filter((item) =>
`${item.siteName} ${item.url} ${item.remark}`.includes(appliedDrainageKeyword),
)
: payload.links; : payload.links;
const cardVisual = signatureCardVisual(signature.auditStatus, signature.carrierReportSummary); const cardVisual = signatureCardVisual(signature.auditStatus, signature.carrierReportSummary);
const expanded = expandedSignatureId === signature.id || Boolean(appliedDrainageKeyword); const expanded = expandedSignatureId === signature.id || Boolean(appliedDrainageKeyword);
return ( return (
<article aria-label={`签名总体状态:${cardVisual.label}`} className={`signature-card signature-card--${cardVisual.tone}`} key={signature.id} title={`总体状态:${cardVisual.label}`}> <article
aria-label={`签名总体状态:${cardVisual.label}`}
className={`signature-card signature-card--${cardVisual.tone}`}
key={signature.id}
title={`总体状态:${cardVisual.label}`}
>
<div className="signature-summary"> <div className="signature-summary">
<button aria-label="展开签名" onClick={() => setExpandedSignatureId(expanded ? '' : signature.id)} type="button"> <button
aria-label="展开签名"
onClick={() => setExpandedSignatureId(expanded ? '' : signature.id)}
type="button"
>
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />} {expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button> </button>
<div className="enterprise-signature-table__signature" data-label="签名"><strong>{formatSignatureName(signature.name)}</strong></div> <div className="enterprise-signature-table__signature" data-label="签名">
<div data-label="企业"><span className="signature-summary__regular-value">{signature.tenant?.name ?? signature.tenantId}</span></div> <strong>{formatSignatureName(signature.name)}</strong>
<div data-label="应用"><span className="signature-summary__regular-value">{signature.application?.name ?? '-'}</span></div> </div>
<div data-label="审核状态"><AuditStatusTag status={signature.auditStatus} /></div> <div data-label="企业">
<div data-label="移动"><CarrierReportCount summary={signature.carrierReportSummary?.mobile} /></div> <span className="signature-summary__regular-value">{signature.tenant?.name ?? signature.tenantId}</span>
<div data-label="联通"><CarrierReportCount summary={signature.carrierReportSummary?.unicom} /></div> </div>
<div data-label="电信"><CarrierReportCount summary={signature.carrierReportSummary?.telecom} /></div> <div data-label="应用">
<div data-label="引流信息"><strong>{payload.links.length} </strong></div> <span className="signature-summary__regular-value">{signature.application?.name ?? '-'}</span>
</div>
<div data-label="审核状态">
<AuditStatusTag status={signature.auditStatus} />
</div>
<div data-label="移动">
<CarrierReportCount summary={signature.carrierReportSummary?.mobile} />
</div>
<div data-label="联通">
<CarrierReportCount summary={signature.carrierReportSummary?.unicom} />
</div>
<div data-label="电信">
<CarrierReportCount summary={signature.carrierReportSummary?.telecom} />
</div>
<div data-label="引流信息">
<strong>{payload.links.length} </strong>
</div>
<div data-label="待生成明细">
<Button
disabled={!signature.pendingReportDetailCount}
onClick={() =>
navigate(`/admin/report-tasks?signatureId=${encodeURIComponent(signature.id)}&scope=pending`)
}
size="sm"
variant="ghost"
>
{signature.pendingReportDetailCount ?? 0}
</Button>
</div>
<div className="signature-actions"> <div className="signature-actions">
<Button icon={<Edit3 size={16} />} onClick={() => setReportStatusTarget(signature)} size="sm" variant="ghost"></Button> <Button
<Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost"></Button> icon={<Edit3 size={16} />}
<DeleteRiskAction onCompleted={() => void loadData()} portal="admin" targetId={signature.id} targetType="signature" /> onClick={() => setReportStatusTarget(signature)}
size="sm"
variant="ghost"
>
</Button>
<Button
icon={<Edit3 size={16} />}
onClick={() => setSignatureModal(signature)}
size="sm"
variant="ghost"
>
</Button>
<DeleteRiskAction
onCompleted={() => void loadData()}
portal="admin"
targetId={signature.id}
targetType="signature"
/>
</div> </div>
</div> </div>
{expanded ? ( {expanded ? (
@@ -108,24 +200,60 @@ export function EnterpriseSignaturesTable({
const summary = signature.drainageCarrierReportSummary?.[item.id]; const summary = signature.drainageCarrierReportSummary?.[item.id];
return ( return (
<div className="drainage-table__row" key={item.id}> <div className="drainage-table__row" key={item.id}>
<span className="drainage-table__url" title={item.url}>{item.url}</span> <span className="drainage-table__url" title={item.url}>
{item.url}
</span>
<AuditStatusTag status={item.auditStatus ?? 'pending'} /> <AuditStatusTag status={item.auditStatus ?? 'pending'} />
<CarrierReportCount summary={summary?.mobile} /> <CarrierReportCount summary={summary?.mobile} />
<CarrierReportCount summary={summary?.unicom} /> <CarrierReportCount summary={summary?.unicom} />
<CarrierReportCount summary={summary?.telecom} /> <CarrierReportCount summary={summary?.telecom} />
<span className="drainage-row-actions"> <span className="drainage-row-actions">
<Button disabled={item.auditStatus !== 'approved'} onClick={() => setDrainageStatusTarget({ signature, item })} size="sm" variant="ghost"></Button> <Button
<Button onClick={() => setDrainageModal({ signatureId: signature.id, item })} size="sm" variant="ghost"></Button> disabled={item.auditStatus !== 'approved'}
<Button onClick={() => setDeleteTarget({ kind: 'drainage', signatureId: signature.id, id: item.id, name: item.url })} size="sm" variant="danger"></Button> onClick={() => setDrainageStatusTarget({ signature, item })}
size="sm"
variant="ghost"
>
</Button>
<Button
onClick={() => setDrainageModal({ signatureId: signature.id, item })}
size="sm"
variant="ghost"
>
</Button>
<Button
onClick={() =>
setDeleteTarget({
kind: 'drainage',
signatureId: signature.id,
id: item.id,
name: item.url,
})
}
size="sm"
variant="danger"
>
</Button>
</span> </span>
</div> </div>
);})} );
})}
</div> </div>
) : ( ) : (
<p className="muted"></p> <p className="muted"></p>
)} )}
<div className="drainage-panel__footer"> <div className="drainage-panel__footer">
<Button icon={<Plus size={16} />} onClick={() => setDrainageModal({ signatureId: signature.id })} size="sm" variant="ghost"></Button> <Button
icon={<Plus size={16} />}
onClick={() => setDrainageModal({ signatureId: signature.id })}
size="sm"
variant="ghost"
>
</Button>
</div> </div>
</div> </div>
) : null} ) : null}
+56 -18
View File
@@ -1,8 +1,4 @@
import { import { useCallback, useEffect, useState } from 'react';
useCallback,
useEffect,
useState,
} from 'react';
import { import {
Activity, Activity,
AlertTriangle, AlertTriangle,
@@ -39,10 +35,20 @@ import { getLastUserActivityAt, readSession, type LoginSession } from '@/api/ses
import { AppShell } from '@/layouts/AppShell'; import { AppShell } from '@/layouts/AppShell';
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary'; import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
const EMPTY_PENDING_AUDITS = { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0 }; const EMPTY_PENDING_AUDITS = {
enterpriseCertifications: 0,
smsAudits: 0,
templates: 0,
signatures: 0,
drainageInfos: 0,
};
export function AdminLayout() { export function AdminLayout() {
return <PortalSessionBoundary portal="admin">{(session) => <AdminAuthenticatedLayout session={session} />}</PortalSessionBoundary>; return (
<PortalSessionBoundary portal="admin">
{(session) => <AdminAuthenticatedLayout session={session} />}
</PortalSessionBoundary>
);
} }
function AdminAuthenticatedLayout({ session }: { session: LoginSession }) { function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
@@ -53,17 +59,27 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
const [sessionLocked, setSessionLocked] = useState(Boolean(session.locked)); const [sessionLocked, setSessionLocked] = useState(Boolean(session.locked));
const loadPendingAuditCount = useCallback(() => { const loadPendingAuditCount = useCallback(() => {
const currentSession = readSession('admin'); const currentSession = readSession('admin');
if (!currentSession || currentSession.locked if (
|| Date.now() - getLastUserActivityAt() >= currentSession.idleTimeoutSeconds * 1000) { !currentSession ||
currentSession.locked ||
Date.now() - getLastUserActivityAt() >= currentSession.idleTimeoutSeconds * 1000
) {
return; return;
} }
// This runs globally and on a timer, so it must not fan out through the full dashboard aggregation. // This runs globally and on a timer, so it must not fan out through the full dashboard aggregation.
Promise.allSettled([adminApi.getPendingAudits(), adminApi.getSignatureRetirementUnreadCount(), adminApi.getSecurityNotificationSummary(), adminApi.getInfrastructureMonitoringNotificationSummary()]) Promise.allSettled([
adminApi.getPendingAudits(),
adminApi.getSignatureRetirementUnreadCount(),
adminApi.getSecurityNotificationSummary(),
adminApi.getInfrastructureMonitoringNotificationSummary(),
])
.then(([audits, retirement, security, infrastructure]) => { .then(([audits, retirement, security, infrastructure]) => {
setPendingAudits(audits.status === 'fulfilled' ? audits.value : EMPTY_PENDING_AUDITS); setPendingAudits(audits.status === 'fulfilled' ? audits.value : EMPTY_PENDING_AUDITS);
setRetirementUnreadCount(retirement.status === 'fulfilled' ? retirement.value.count : 0); setRetirementUnreadCount(retirement.status === 'fulfilled' ? retirement.value.count : 0);
setSecurityAlertSummary(security.status === 'fulfilled' ? security.value : { count: 0, criticalCount: 0 }); setSecurityAlertSummary(security.status === 'fulfilled' ? security.value : { count: 0, criticalCount: 0 });
setInfrastructureAlertSummary(infrastructure.status === 'fulfilled' ? infrastructure.value : { count: 0, criticalCount: 0 }); setInfrastructureAlertSummary(
infrastructure.status === 'fulfilled' ? infrastructure.value : { count: 0, criticalCount: 0 },
);
}) })
.catch(() => { .catch(() => {
setPendingAudits(EMPTY_PENDING_AUDITS); setPendingAudits(EMPTY_PENDING_AUDITS);
@@ -107,9 +123,30 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
userRole="平台管理员" userRole="平台管理员"
onSessionLockedChange={setSessionLocked} onSessionLockedChange={setSessionLocked}
alertNotifications={[ alertNotifications={[
{ label: '签名清退预警', count: retirementUnreadCount, description: '今日未读且未抑制', to: '/admin/signature-retirement' }, {
{ label: '安全检测与封禁', count: securityAlertSummary.count, description: securityAlertSummary.criticalCount > 0 ? `${securityAlertSummary.criticalCount} 条严重告警待处置` : '待处置安全告警', to: '/admin/security-detection' }, label: '签名清退预警',
{ label: '系统监控告警', count: infrastructureAlertSummary.count, description: infrastructureAlertSummary.criticalCount > 0 ? `${infrastructureAlertSummary.criticalCount} 条 Prometheus 严重告警` : 'Prometheus 活动告警', to: '/admin/system-monitoring#active-alerts' }, count: retirementUnreadCount,
description: '今日未读且未抑制',
to: '/admin/signature-retirement',
},
{
label: '安全检测与封禁',
count: securityAlertSummary.count,
description:
securityAlertSummary.criticalCount > 0
? `${securityAlertSummary.criticalCount} 条严重告警待处置`
: '待处置安全告警',
to: '/admin/security-detection',
},
{
label: '系统监控告警',
count: infrastructureAlertSummary.count,
description:
infrastructureAlertSummary.criticalCount > 0
? `${infrastructureAlertSummary.criticalCount} 条 Prometheus 严重告警`
: 'Prometheus 活动告警',
to: '/admin/system-monitoring#active-alerts',
},
]} ]}
auditNotifications={[ auditNotifications={[
{ label: '企业认证待审', count: pendingAudits.enterpriseCertifications, to: '/admin/enterprise-audit' }, { label: '企业认证待审', count: pendingAudits.enterpriseCertifications, to: '/admin/enterprise-audit' },
@@ -160,12 +197,13 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
], ],
}, },
{ {
title: '报备任务', title: '报备工作台',
icon: ClipboardList, icon: ClipboardList,
items: [ items: [
{ label: '待生成报备批次', to: '/admin/report-materials', icon: FileSpreadsheet }, { label: '报备资料池', to: '/admin/report-materials', icon: FileSpreadsheet },
{ label: '报备明细', to: '/admin/report-tasks', icon: ClipboardList }, { label: '报备批次', to: '/admin/report-batches', icon: Layers3 },
{ label: '报备记录', to: '/admin/report-records', icon: ListChecks }, { label: '通道报备明细', to: '/admin/report-tasks', icon: ClipboardList },
{ label: '状态记录', to: '/admin/report-records', icon: ListChecks },
], ],
}, },
{ {
+126 -31
View File
@@ -8,7 +8,7 @@ import { RouteLoadBoundary } from './RouteLoadBoundary';
function lazyNamed(loader: () => Promise<unknown>, exportName: string): LazyExoticComponent<ComponentType<any>> { function lazyNamed(loader: () => Promise<unknown>, exportName: string): LazyExoticComponent<ComponentType<any>> {
return lazy(async () => { return lazy(async () => {
const loaded = await loader() as Record<string, ComponentType>; const loaded = (await loader()) as Record<string, ComponentType>;
const component = loaded[exportName]; const component = loaded[exportName];
if (!component) throw new Error(`Lazy route export ${exportName} was not found`); if (!component) throw new Error(`Lazy route export ${exportName} was not found`);
return { default: component }; return { default: component };
@@ -16,53 +16,135 @@ function lazyNamed(loader: () => Promise<unknown>, exportName: string): LazyExot
} }
const AdminAnalyticsPage = lazyNamed(() => import('@/apps/admin/AdminAnalyticsPage'), 'AdminAnalyticsPage'); const AdminAnalyticsPage = lazyNamed(() => import('@/apps/admin/AdminAnalyticsPage'), 'AdminAnalyticsPage');
const AdminChannelGroupFormPage = lazyNamed(() => import('@/apps/admin/AdminChannelGroupFormPage'), 'AdminChannelGroupFormPage'); const AdminChannelGroupFormPage = lazyNamed(
() => import('@/apps/admin/AdminChannelGroupFormPage'),
'AdminChannelGroupFormPage',
);
const AdminChannelGroupsPage = lazyNamed(() => import('@/apps/admin/AdminChannelGroupsPage'), 'AdminChannelGroupsPage'); const AdminChannelGroupsPage = lazyNamed(() => import('@/apps/admin/AdminChannelGroupsPage'), 'AdminChannelGroupsPage');
const AdminChannelsPage = lazyNamed(() => import('@/apps/admin/AdminChannelsPage'), 'AdminChannelsPage'); const AdminChannelsPage = lazyNamed(() => import('@/apps/admin/AdminChannelsPage'), 'AdminChannelsPage');
const AdminChannelReportPage = lazyNamed(() => import('@/apps/admin/AdminChannelReportPage'), 'AdminChannelReportPage'); const AdminChannelReportPage = lazyNamed(() => import('@/apps/admin/AdminChannelReportPage'), 'AdminChannelReportPage');
const AdminCustomerDetailPage = lazyNamed(() => import('@/apps/admin/AdminCustomerDetailPage'), 'AdminCustomerDetailPage'); const AdminCustomerDetailPage = lazyNamed(
() => import('@/apps/admin/AdminCustomerDetailPage'),
'AdminCustomerDetailPage',
);
const AdminCustomerFormPage = lazyNamed(() => import('@/apps/admin/AdminCustomerFormPage'), 'AdminCustomerFormPage'); const AdminCustomerFormPage = lazyNamed(() => import('@/apps/admin/AdminCustomerFormPage'), 'AdminCustomerFormPage');
const AdminCustomersPage = lazyNamed(() => import('@/apps/admin/AdminCustomersPage'), 'AdminCustomersPage'); const AdminCustomersPage = lazyNamed(() => import('@/apps/admin/AdminCustomersPage'), 'AdminCustomersPage');
const AdminDrainageFieldsPage = lazyNamed(() => import('@/apps/admin/AdminDrainageFieldsPage'), 'AdminDrainageFieldsPage'); const AdminDrainageFieldsPage = lazyNamed(
const AdminDrainageDetectionRulesPage = lazyNamed(() => import('@/apps/admin/AdminDrainageDetectionRulesPage'), 'AdminDrainageDetectionRulesPage'); () => import('@/apps/admin/AdminDrainageFieldsPage'),
const AdminDownstreamDeliveriesPage = lazyNamed(() => import('@/apps/admin/AdminDownstreamDeliveriesPage'), 'AdminDownstreamDeliveriesPage'); 'AdminDrainageFieldsPage',
const AdminDownstreamRecoveryStatusesPage = lazyNamed(() => import('@/apps/admin/AdminDownstreamRecoveryStatusesPage'), 'AdminDownstreamRecoveryStatusesPage'); );
const AdminEnterpriseApplicationsPage = lazyNamed(() => import('@/apps/admin/AdminEnterpriseApplicationsPage'), 'AdminEnterpriseApplicationsPage'); const AdminDrainageDetectionRulesPage = lazyNamed(
const AdminEnterpriseBlacklistPage = lazyNamed(() => import('@/apps/admin/AdminEnterpriseBlacklistPage'), 'AdminEnterpriseBlacklistPage'); () => import('@/apps/admin/AdminDrainageDetectionRulesPage'),
const AdminEnterpriseSignaturesPage = lazyNamed(() => import('@/apps/admin/AdminEnterpriseSignaturesPage'), 'AdminEnterpriseSignaturesPage'); 'AdminDrainageDetectionRulesPage',
const AdminEnterpriseTemplatesPage = lazyNamed(() => import('@/apps/admin/AdminEnterpriseTemplatesPage'), 'AdminEnterpriseTemplatesPage'); );
const AdminGlobalBlacklistPage = lazyNamed(() => import('@/apps/admin/AdminGlobalBlacklistPage'), 'AdminGlobalBlacklistPage'); const AdminDownstreamDeliveriesPage = lazyNamed(
const AdminGatewaySubmitExceptionsPage = lazyNamed(() => import('@/apps/admin/AdminGatewaySubmitExceptionsPage'), 'AdminGatewaySubmitExceptionsPage'); () => import('@/apps/admin/AdminDownstreamDeliveriesPage'),
'AdminDownstreamDeliveriesPage',
);
const AdminDownstreamRecoveryStatusesPage = lazyNamed(
() => import('@/apps/admin/AdminDownstreamRecoveryStatusesPage'),
'AdminDownstreamRecoveryStatusesPage',
);
const AdminEnterpriseApplicationsPage = lazyNamed(
() => import('@/apps/admin/AdminEnterpriseApplicationsPage'),
'AdminEnterpriseApplicationsPage',
);
const AdminEnterpriseBlacklistPage = lazyNamed(
() => import('@/apps/admin/AdminEnterpriseBlacklistPage'),
'AdminEnterpriseBlacklistPage',
);
const AdminEnterpriseSignaturesPage = lazyNamed(
() => import('@/apps/admin/AdminEnterpriseSignaturesPage'),
'AdminEnterpriseSignaturesPage',
);
const AdminEnterpriseTemplatesPage = lazyNamed(
() => import('@/apps/admin/AdminEnterpriseTemplatesPage'),
'AdminEnterpriseTemplatesPage',
);
const AdminGlobalBlacklistPage = lazyNamed(
() => import('@/apps/admin/AdminGlobalBlacklistPage'),
'AdminGlobalBlacklistPage',
);
const AdminGatewaySubmitExceptionsPage = lazyNamed(
() => import('@/apps/admin/AdminGatewaySubmitExceptionsPage'),
'AdminGatewaySubmitExceptionsPage',
);
const AdminHome = lazyNamed(() => import('@/apps/admin/AdminHome'), 'AdminHome'); const AdminHome = lazyNamed(() => import('@/apps/admin/AdminHome'), 'AdminHome');
const AdminMonitorPage = lazyNamed(() => import('@/apps/admin/AdminMonitorPage'), 'AdminMonitorPage'); const AdminMonitorPage = lazyNamed(() => import('@/apps/admin/AdminMonitorPage'), 'AdminMonitorPage');
const AdminPhoneSegmentsPage = lazyNamed(() => import('@/apps/admin/AdminPhoneSegmentsPage'), 'AdminPhoneSegmentsPage'); const AdminPhoneSegmentsPage = lazyNamed(() => import('@/apps/admin/AdminPhoneSegmentsPage'), 'AdminPhoneSegmentsPage');
const AdminRechargeRecordsPage = lazyNamed(() => import('@/apps/admin/AdminRechargeRecordsPage'), 'AdminRechargeRecordsPage'); const AdminRechargeRecordsPage = lazyNamed(
const AdminReconciliationReportsPage = lazyNamed(() => import('@/apps/admin/AdminReconciliationReportsPage'), 'AdminReconciliationReportsPage'); () => import('@/apps/admin/AdminRechargeRecordsPage'),
'AdminRechargeRecordsPage',
);
const AdminReconciliationReportsPage = lazyNamed(
() => import('@/apps/admin/AdminReconciliationReportsPage'),
'AdminReconciliationReportsPage',
);
const AdminProfitReportsPage = lazyNamed(() => import('@/apps/admin/AdminProfitReportsPage'), 'AdminProfitReportsPage'); const AdminProfitReportsPage = lazyNamed(() => import('@/apps/admin/AdminProfitReportsPage'), 'AdminProfitReportsPage');
const AdminQualityReportsPage = lazyNamed(() => import('@/apps/admin/AdminQualityReportsPage'), 'AdminQualityReportsPage'); const AdminQualityReportsPage = lazyNamed(
() => import('@/apps/admin/AdminQualityReportsPage'),
'AdminQualityReportsPage',
);
const AdminReportRecordsPage = lazyNamed(() => import('@/apps/admin/AdminReportRecordsPage'), 'AdminReportRecordsPage'); const AdminReportRecordsPage = lazyNamed(() => import('@/apps/admin/AdminReportRecordsPage'), 'AdminReportRecordsPage');
const AdminReportTasksPage = lazyNamed(() => import('@/apps/admin/AdminReportTasksPage'), 'AdminReportTasksPage'); const AdminReportTasksPage = lazyNamed(() => import('@/apps/admin/AdminReportTasksPage'), 'AdminReportTasksPage');
const AdminReportMaterialsPage = lazyNamed(() => import('@/apps/admin/AdminReportMaterialsPage'), 'AdminReportMaterialsPage'); const AdminReportMaterialsPage = lazyNamed(
const AdminSensitiveWordsPage = lazyNamed(() => import('@/apps/admin/AdminSensitiveWordsPage'), 'AdminSensitiveWordsPage'); () => import('@/apps/admin/AdminReportMaterialsPage'),
'AdminReportMaterialsPage',
);
const AdminReportBatchesPage = lazyNamed(() => import('@/apps/admin/AdminReportBatchesPage'), 'AdminReportBatchesPage');
const AdminSensitiveWordsPage = lazyNamed(
() => import('@/apps/admin/AdminSensitiveWordsPage'),
'AdminSensitiveWordsPage',
);
const AdminSmsAuditPage = lazyNamed(() => import('@/apps/admin/AdminSmsAuditPage'), 'AdminSmsAuditPage'); const AdminSmsAuditPage = lazyNamed(() => import('@/apps/admin/AdminSmsAuditPage'), 'AdminSmsAuditPage');
const AdminRiskRulesPage = lazyNamed(() => import('@/apps/admin/AdminRiskRulesPage'), 'AdminRiskRulesPage'); const AdminRiskRulesPage = lazyNamed(() => import('@/apps/admin/AdminRiskRulesPage'), 'AdminRiskRulesPage');
const AdminSmsApplicationFormPage = lazyNamed(() => import('@/apps/admin/AdminSmsApplicationFormPage'), 'AdminSmsApplicationFormPage'); const AdminSmsApplicationFormPage = lazyNamed(
() => import('@/apps/admin/AdminSmsApplicationFormPage'),
'AdminSmsApplicationFormPage',
);
const AdminSmsRecordsPage = lazyNamed(() => import('@/apps/admin/AdminSmsRecordsPage'), 'AdminSmsRecordsPage'); const AdminSmsRecordsPage = lazyNamed(() => import('@/apps/admin/AdminSmsRecordsPage'), 'AdminSmsRecordsPage');
const AdminSmsTaskProgressPage = lazyNamed(() => import('@/apps/admin/AdminSmsTaskProgressPage'), 'AdminSmsTaskProgressPage'); const AdminSmsTaskProgressPage = lazyNamed(
const AdminSmsUplinkRecordsPage = lazyNamed(() => import('@/apps/admin/AdminSmsUplinkRecordsPage'), 'AdminSmsUplinkRecordsPage'); () => import('@/apps/admin/AdminSmsTaskProgressPage'),
const AdminSignatureAuditPage = lazyNamed(() => import('@/apps/admin/AdminSignatureAuditPage'), 'AdminSignatureAuditPage'); 'AdminSmsTaskProgressPage',
const AdminSignatureRetirementPage = lazyNamed(() => import('@/apps/admin/AdminSignatureRetirementPage'), 'AdminSignatureRetirementPage'); );
const AdminSmsUplinkRecordsPage = lazyNamed(
() => import('@/apps/admin/AdminSmsUplinkRecordsPage'),
'AdminSmsUplinkRecordsPage',
);
const AdminSignatureAuditPage = lazyNamed(
() => import('@/apps/admin/AdminSignatureAuditPage'),
'AdminSignatureAuditPage',
);
const AdminSignatureRetirementPage = lazyNamed(
() => import('@/apps/admin/AdminSignatureRetirementPage'),
'AdminSignatureRetirementPage',
);
const AdminDrainageAuditPage = lazyNamed(() => import('@/apps/admin/AdminDrainageAuditPage'), 'AdminDrainageAuditPage'); const AdminDrainageAuditPage = lazyNamed(() => import('@/apps/admin/AdminDrainageAuditPage'), 'AdminDrainageAuditPage');
const AdminSystemLogsPage = lazyNamed(() => import('@/apps/admin/AdminSystemLogsPage'), 'AdminSystemLogsPage'); const AdminSystemLogsPage = lazyNamed(() => import('@/apps/admin/AdminSystemLogsPage'), 'AdminSystemLogsPage');
const AdminSystemMonitoringPage = lazyNamed(() => import('@/apps/admin/system-monitoring/AdminSystemMonitoringPage'), 'AdminSystemMonitoringPage'); const AdminSystemMonitoringPage = lazyNamed(
const AdminSecurityDetectionPage = lazyNamed(() => import('@/apps/admin/security-detection/AdminSecurityDetectionPage'), 'AdminSecurityDetectionPage'); () => import('@/apps/admin/system-monitoring/AdminSystemMonitoringPage'),
'AdminSystemMonitoringPage',
);
const AdminSecurityDetectionPage = lazyNamed(
() => import('@/apps/admin/security-detection/AdminSecurityDetectionPage'),
'AdminSecurityDetectionPage',
);
const AdminTemplateAuditPage = lazyNamed(() => import('@/apps/admin/AdminTemplateAuditPage'), 'AdminTemplateAuditPage'); const AdminTemplateAuditPage = lazyNamed(() => import('@/apps/admin/AdminTemplateAuditPage'), 'AdminTemplateAuditPage');
const AdminUsersPage = lazyNamed(() => import('@/apps/admin/AdminUsersPage'), 'AdminUsersPage'); const AdminUsersPage = lazyNamed(() => import('@/apps/admin/AdminUsersPage'), 'AdminUsersPage');
const AdminEnterpriseAuditPage = lazyNamed(() => import('@/apps/admin/AdminEnterpriseAuditPage'), 'AdminEnterpriseAuditPage'); const AdminEnterpriseAuditPage = lazyNamed(
const ClientApplicationsPage = lazyNamed(() => import('@/apps/client/ClientApplicationsPage'), 'ClientApplicationsPage'); () => import('@/apps/admin/AdminEnterpriseAuditPage'),
'AdminEnterpriseAuditPage',
);
const ClientApplicationsPage = lazyNamed(
() => import('@/apps/client/ClientApplicationsPage'),
'ClientApplicationsPage',
);
const ClientBatchTasksPage = lazyNamed(() => import('@/apps/client/ClientBatchTasksPage'), 'ClientBatchTasksPage'); const ClientBatchTasksPage = lazyNamed(() => import('@/apps/client/ClientBatchTasksPage'), 'ClientBatchTasksPage');
const ClientBillingPage = lazyNamed(() => import('@/apps/client/ClientBillingPage'), 'ClientBillingPage'); const ClientBillingPage = lazyNamed(() => import('@/apps/client/ClientBillingPage'), 'ClientBillingPage');
const ClientEnterpriseAuthPage = lazyNamed(() => import('@/apps/client/ClientEnterpriseAuthPage'), 'ClientEnterpriseAuthPage'); const ClientEnterpriseAuthPage = lazyNamed(
() => import('@/apps/client/ClientEnterpriseAuthPage'),
'ClientEnterpriseAuthPage',
);
const ClientHome = lazyNamed(() => import('@/apps/client/ClientHome'), 'ClientHome'); const ClientHome = lazyNamed(() => import('@/apps/client/ClientHome'), 'ClientHome');
const ClientHttpApiPage = lazyNamed(() => import('@/apps/client/ClientHttpApiPage'), 'ClientHttpApiPage'); const ClientHttpApiPage = lazyNamed(() => import('@/apps/client/ClientHttpApiPage'), 'ClientHttpApiPage');
const ClientSendDetailPage = lazyNamed(() => import('@/apps/client/ClientSendDetailPage'), 'ClientSendDetailPage'); const ClientSendDetailPage = lazyNamed(() => import('@/apps/client/ClientSendDetailPage'), 'ClientSendDetailPage');
@@ -70,13 +152,22 @@ const ClientSendPage = lazyNamed(() => import('@/apps/client/ClientSendPage'), '
const ClientSignaturesPage = lazyNamed(() => import('@/apps/client/ClientSignaturesPage'), 'ClientSignaturesPage'); const ClientSignaturesPage = lazyNamed(() => import('@/apps/client/ClientSignaturesPage'), 'ClientSignaturesPage');
const ClientSystemLogsPage = lazyNamed(() => import('@/apps/client/ClientSystemLogsPage'), 'ClientSystemLogsPage'); const ClientSystemLogsPage = lazyNamed(() => import('@/apps/client/ClientSystemLogsPage'), 'ClientSystemLogsPage');
const ClientTemplatesPage = lazyNamed(() => import('@/apps/client/ClientTemplatesPage'), 'ClientTemplatesPage'); const ClientTemplatesPage = lazyNamed(() => import('@/apps/client/ClientTemplatesPage'), 'ClientTemplatesPage');
const ClientUplinkMessagesPage = lazyNamed(() => import('@/apps/client/ClientUplinkMessagesPage'), 'ClientUplinkMessagesPage'); const ClientUplinkMessagesPage = lazyNamed(
() => import('@/apps/client/ClientUplinkMessagesPage'),
'ClientUplinkMessagesPage',
);
const ClientUsersPage = lazyNamed(() => import('@/apps/client/ClientUsersPage'), 'ClientUsersPage'); const ClientUsersPage = lazyNamed(() => import('@/apps/client/ClientUsersPage'), 'ClientUsersPage');
export function AppRoutes() { export function AppRoutes() {
return ( return (
<RouteLoadBoundary> <RouteLoadBoundary>
<Suspense fallback={<div className="page-stack"><div className="surface ui-table__empty">...</div></div>}> <Suspense
fallback={
<div className="page-stack">
<div className="surface ui-table__empty">...</div>
</div>
}
>
<Routes> <Routes>
<Route path="/" element={<Navigate to="/client" replace />} /> <Route path="/" element={<Navigate to="/client" replace />} />
<Route path="/client/login" element={<LoginPage portal="client" />} /> <Route path="/client/login" element={<LoginPage portal="client" />} />
@@ -116,7 +207,10 @@ export function AppRoutes() {
<Route path="customers/:enterpriseId/sms-apps/:appId/edit" element={<AdminSmsApplicationFormPage />} /> <Route path="customers/:enterpriseId/sms-apps/:appId/edit" element={<AdminSmsApplicationFormPage />} />
<Route path="customers/:enterpriseId/mms-apps/new" element={<PagePlaceholder />} /> <Route path="customers/:enterpriseId/mms-apps/new" element={<PagePlaceholder />} />
<Route path="customers/:enterpriseId/mms-apps/:appId/edit" element={<PagePlaceholder />} /> <Route path="customers/:enterpriseId/mms-apps/:appId/edit" element={<PagePlaceholder />} />
<Route path="customer-enterprises" element={<AdminCustomersPage basePath="/admin/customer-enterprises" />} /> <Route
path="customer-enterprises"
element={<AdminCustomersPage basePath="/admin/customer-enterprises" />}
/>
<Route path="customer-enterprises/new" element={<AdminCustomerFormPage />} /> <Route path="customer-enterprises/new" element={<AdminCustomerFormPage />} />
<Route path="customer-enterprises/:enterpriseId" element={<AdminCustomerDetailPage />} /> <Route path="customer-enterprises/:enterpriseId" element={<AdminCustomerDetailPage />} />
<Route path="customer-enterprises/:enterpriseId/edit" element={<AdminCustomerFormPage />} /> <Route path="customer-enterprises/:enterpriseId/edit" element={<AdminCustomerFormPage />} />
@@ -132,6 +226,7 @@ export function AppRoutes() {
<Route path="signature-retirement" element={<AdminSignatureRetirementPage />} /> <Route path="signature-retirement" element={<AdminSignatureRetirementPage />} />
<Route path="report-tasks" element={<AdminReportTasksPage />} /> <Route path="report-tasks" element={<AdminReportTasksPage />} />
<Route path="report-materials" element={<AdminReportMaterialsPage />} /> <Route path="report-materials" element={<AdminReportMaterialsPage />} />
<Route path="report-batches" element={<AdminReportBatchesPage />} />
<Route path="report-records" element={<AdminReportRecordsPage />} /> <Route path="report-records" element={<AdminReportRecordsPage />} />
<Route path="sms-task-progress" element={<AdminSmsTaskProgressPage />} /> <Route path="sms-task-progress" element={<AdminSmsTaskProgressPage />} />
<Route path="mms-task-progress" element={<PagePlaceholder />} /> <Route path="mms-task-progress" element={<PagePlaceholder />} />
+58 -7
View File
@@ -2007,9 +2007,9 @@
.admin-enterprise-signature-list .signature-summary { .admin-enterprise-signature-list .signature-summary {
gap: var(--space-2); gap: var(--space-2);
grid-template-columns: 22px minmax(132px, 0.9fr) minmax(168px, 1.1fr) minmax(120px, 0.8fr) minmax(82px, 0.55fr) repeat(3, minmax(64px, 0.45fr)) minmax(68px, 0.45fr) 224px; grid-template-columns: 22px minmax(132px, 0.9fr) minmax(168px, 1.1fr) minmax(120px, 0.8fr) minmax(82px, 0.55fr) repeat(3, minmax(64px, 0.45fr)) minmax(68px, 0.45fr) minmax(92px, 0.5fr) 224px;
min-height: 64px; min-height: 64px;
min-width: 1160px; min-width: 1260px;
padding-inline: var(--space-3); padding-inline: var(--space-3);
} }
@@ -2057,9 +2057,9 @@
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
gap: var(--space-2); gap: var(--space-2);
grid-template-columns: 22px minmax(132px, 0.9fr) minmax(168px, 1.1fr) minmax(120px, 0.8fr) minmax(82px, 0.55fr) repeat(3, minmax(64px, 0.45fr)) minmax(68px, 0.45fr) 224px; grid-template-columns: 22px minmax(132px, 0.9fr) minmax(168px, 1.1fr) minmax(120px, 0.8fr) minmax(82px, 0.55fr) repeat(3, minmax(64px, 0.45fr)) minmax(68px, 0.45fr) minmax(92px, 0.5fr) 224px;
min-height: 46px; min-height: 46px;
min-width: 1160px; min-width: 1260px;
padding-inline: var(--space-3); padding-inline: var(--space-3);
} }
@@ -5526,7 +5526,7 @@
align-items: end; align-items: end;
display: grid; display: grid;
gap: var(--space-5); gap: var(--space-5);
grid-template-columns: minmax(240px, 1fr) minmax(200px, 0.8fr) minmax(360px, 1.4fr); grid-template-columns: minmax(220px, 1.3fr) repeat(2, minmax(150px, 0.75fr)) repeat(2, minmax(138px, 0.65fr));
} }
.channel-report-filter-footer { .channel-report-filter-footer {
@@ -5544,14 +5544,65 @@
} }
.channel-report-table { .channel-report-table {
overflow: hidden; overflow-x: auto;
padding: 0; padding: 0;
} }
.channel-report-table__head, .channel-report-table__head,
.channel-report-row { .channel-report-row {
display: grid; display: grid;
grid-template-columns: 34px minmax(220px, 1.2fr) 112px 120px 120px 130px minmax(280px, 1.35fr) 132px; grid-template-columns: 34px minmax(220px, 1.2fr) 112px 120px 120px 130px minmax(280px, 1.35fr) 300px;
min-width: 1340px;
}
.report-material-detail-list {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.report-material-detail-list > div {
align-items: start;
border-bottom: 1px solid var(--color-border);
display: grid;
gap: var(--space-4);
grid-template-columns: minmax(180px, 0.7fr) minmax(0, 1.3fr);
padding: var(--space-4);
}
.report-material-detail-list > div:last-child { border-bottom: 0; }
.report-material-detail-list > div > span { color: var(--color-text-muted); }
.report-material-detail-list > div > strong { overflow-wrap: anywhere; }
.report-material-detail-list > div.is-missing { background: var(--color-danger-soft); }
.report-batch-toolbar {
align-items: end;
background: var(--color-surface-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: grid;
gap: var(--space-3);
grid-template-columns: minmax(180px, 1fr) minmax(180px, 0.6fr) minmax(220px, 1fr) auto;
padding: var(--space-4);
}
.report-material-summary {
display: grid;
gap: var(--space-1);
min-width: 260px;
}
.report-material-summary span {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
white-space: normal;
}
@media (max-width: 760px) {
.report-batch-toolbar,
.report-material-detail-list > div {
grid-template-columns: minmax(0, 1fr);
}
} }
.channel-report-table__head { .channel-report-table__head {