From 551b99cbcdfa4c7bf1348d902d67dfa03e58efbd Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Mon, 13 Jul 2026 17:40:37 +0800 Subject: [PATCH] feat: complete signature and drainage reporting workflows --- .../migration.sql | 9 ++ .../migration.sql | 45 ++++++++ .../migration.sql | 7 ++ api/prisma/schema.prisma | 4 + api/src/channels/channels.service.spec.ts | 27 +++++ api/src/channels/channels.service.ts | 26 +++-- api/src/send-chain/send-chain.service.spec.ts | 1 + api/src/send-chain/send-chain.service.ts | 4 +- .../sms-config/admin-sms-config.controller.ts | 2 +- api/src/sms-config/sms-config.service.spec.ts | 27 +++++ api/src/sms-config/sms-config.service.ts | 75 +++++++++++- .../first-version-development-requirements.md | 4 + docs/system-functional-test-cases.md | 26 +++++ docs/testing-progress.md | 9 ++ src/api/adminApi.ts | 19 ++- src/apps/admin/AdminChannelReportPage.tsx | 42 +++---- .../admin/AdminEnterpriseSignaturesPage.tsx | 56 ++++++--- src/apps/admin/AdminReportTasksPage.tsx | 26 +++-- src/apps/admin/AdminSignatureAuditPage.tsx | 109 ++++++++++++++++++ src/layouts/AdminLayout.tsx | 2 +- src/routes/AppRoutes.tsx | 3 +- 21 files changed, 452 insertions(+), 71 deletions(-) create mode 100644 api/prisma/migrations/20260713153000_add_drainage_report_tasks/migration.sql create mode 100644 api/prisma/migrations/20260713154000_backfill_drainage_report_tasks/migration.sql create mode 100644 api/prisma/migrations/20260713155000_unique_report_task_scope/migration.sql create mode 100644 src/apps/admin/AdminSignatureAuditPage.tsx diff --git a/api/prisma/migrations/20260713153000_add_drainage_report_tasks/migration.sql b/api/prisma/migrations/20260713153000_add_drainage_report_tasks/migration.sql new file mode 100644 index 0000000..ca6f2ca --- /dev/null +++ b/api/prisma/migrations/20260713153000_add_drainage_report_tasks/migration.sql @@ -0,0 +1,9 @@ +ALTER TABLE "ChannelSignatureReportTask" +ADD COLUMN "reportType" TEXT NOT NULL DEFAULT 'signature', +ADD COLUMN "drainageItemId" TEXT; + +CREATE INDEX "ChannelSignatureReportTask_signatureId_drainageItemId_channelId_idx" +ON "ChannelSignatureReportTask"("signatureId", "drainageItemId", "channelId"); + +CREATE INDEX "ChannelSignatureReportTask_reportType_status_idx" +ON "ChannelSignatureReportTask"("reportType", "status"); diff --git a/api/prisma/migrations/20260713154000_backfill_drainage_report_tasks/migration.sql b/api/prisma/migrations/20260713154000_backfill_drainage_report_tasks/migration.sql new file mode 100644 index 0000000..54dfd59 --- /dev/null +++ b/api/prisma/migrations/20260713154000_backfill_drainage_report_tasks/migration.sql @@ -0,0 +1,45 @@ +INSERT INTO "ChannelSignatureReportTask" ( + "id", "tenantId", "signatureId", "channelId", "reportType", "drainageItemId", "status", "createdAt", "updatedAt" +) +SELECT + 'drainage_' || md5(random()::text || clock_timestamp()::text || material."id"), + signature."tenantId", + material."signatureId", + material."channelId", + 'drainage', + material."drainageItemId", + 'pending', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +FROM ( + SELECT DISTINCT ON ("signatureId", "drainageItemId", "channelId") + "id", "signatureId", "drainageItemId", "channelId" + FROM "DrainageReportMaterial" + ORDER BY "signatureId", "drainageItemId", "channelId", "createdAt" +) material +JOIN "SmsSignature" signature ON signature."id" = material."signatureId" +WHERE NOT EXISTS ( + SELECT 1 + FROM "ChannelSignatureReportTask" task + WHERE task."signatureId" = material."signatureId" + AND task."channelId" = material."channelId" + AND task."reportType" = 'drainage' + AND task."drainageItemId" = material."drainageItemId" +); + +INSERT INTO "ChannelSignatureReportRecord" ( + "id", "taskId", "channelId", "action", "statusAfter", "reason", "createdAt" +) +SELECT + 'drainage_record_' || md5(random()::text || clock_timestamp()::text || task."id"), + task."id", + task."channelId", + 'migration_backfill', + task."status", + '从已有引流报备材料回填任务', + CURRENT_TIMESTAMP +FROM "ChannelSignatureReportTask" task +WHERE task."reportType" = 'drainage' + AND NOT EXISTS ( + SELECT 1 FROM "ChannelSignatureReportRecord" record WHERE record."taskId" = task."id" + ); diff --git a/api/prisma/migrations/20260713155000_unique_report_task_scope/migration.sql b/api/prisma/migrations/20260713155000_unique_report_task_scope/migration.sql new file mode 100644 index 0000000..e24b56a --- /dev/null +++ b/api/prisma/migrations/20260713155000_unique_report_task_scope/migration.sql @@ -0,0 +1,7 @@ +CREATE UNIQUE INDEX "ChannelSignatureReportTask_target_key" +ON "ChannelSignatureReportTask"( + "signatureId", + "channelId", + "reportType", + COALESCE("drainageItemId", '') +); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 908c714..e69aebf 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -720,6 +720,8 @@ model ChannelSignatureReportTask { tenantId String signatureId String channelId String + reportType String @default("signature") + drainageItemId String? status String @default("pending") reason String? createdById String? @@ -734,6 +736,8 @@ model ChannelSignatureReportTask { @@index([tenantId, status]) @@index([signatureId, channelId]) + @@index([signatureId, drainageItemId, channelId]) + @@index([reportType, status]) } model ChannelSignatureReportRecord { diff --git a/api/src/channels/channels.service.spec.ts b/api/src/channels/channels.service.spec.ts index 1da0fcc..04fc56a 100644 --- a/api/src/channels/channels.service.spec.ts +++ b/api/src/channels/channels.service.spec.ts @@ -216,6 +216,33 @@ describe('ChannelsService', () => { expect(tx.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'approved' } }); }); + it('changes a drainage report task without overwriting the signature report summary', async () => { + const prisma = createPrismaMock(); + const tx = { + smsSignature: { + findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }), + update: jest.fn(), + }, + smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', status: 'active' }) }, + channelSignatureReportTask: { + findFirst: jest.fn().mockResolvedValue({ id: 'drainage-task-1', signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'reporting' }), + update: jest.fn().mockResolvedValue({ id: 'drainage-task-1', status: 'approved' }), + create: jest.fn(), + }, + channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'record-1' }) }, + }; + prisma.$transaction.mockImplementation((callback) => callback(tx)); + const service = new ChannelsService(prisma as never); + + await expect(service.changeReportTaskStatuses({ + items: [{ signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'approved' }], + reason: '引流信息已报备', + })).resolves.toEqual([{ signatureId: 'sig-1', reportType: 'drainage', drainageItemId: 'drain-1', channelId: 'channel-1', status: 'approved' }]); + expect(tx.channelSignatureReportTask.findFirst).toHaveBeenCalledWith({ where: { signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1' } }); + expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved' }) }); + expect(tx.smsSignature.update).not.toHaveBeenCalled(); + }); + beforeEach(() => { mockQueueAdd.mockClear(); mockQueueClose.mockClear(); diff --git a/api/src/channels/channels.service.ts b/api/src/channels/channels.service.ts index ed451f2..967bbed 100644 --- a/api/src/channels/channels.service.ts +++ b/api/src/channels/channels.service.ts @@ -98,11 +98,13 @@ export interface CreateReportTaskDto { tenantId: string; signatureId: string; channelId: string; + reportType?: 'signature' | 'drainage'; + drainageItemId?: string; createdById?: string; } export interface ChangeReportTaskStatusesDto { - items: Array<{ signatureId: string; channelId: string; status: string }>; + items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>; reason?: string; operatorId?: string; } @@ -973,11 +975,15 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { } async createReportTask(data: CreateReportTaskDto) { + const reportType = data.reportType ?? 'signature'; + if (reportType === 'drainage' && !data.drainageItemId) throw new BadRequestException('drainageItemId is required'); const task = await this.prisma.channelSignatureReportTask.create({ data: { tenantId: data.tenantId, signatureId: data.signatureId, channelId: data.channelId, + reportType, + drainageItemId: reportType === 'drainage' ? data.drainageItemId : undefined, createdById: data.createdById, status: 'pending', }, @@ -993,20 +999,24 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { if (!allowed.has(item.status)) throw new BadRequestException('unsupported report task status'); } return this.prisma.$transaction(async (tx) => { - const signatureIds = [...new Set(data.items.map((item) => item.signatureId))]; + const signatureIds = [...new Set(data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId))]; + const drainageResults: Array<{ signatureId: string; reportType: 'drainage'; drainageItemId: string; channelId: string; status: string }> = []; for (const item of data.items) { + const reportType = item.reportType ?? 'signature'; + if (reportType === 'drainage' && !item.drainageItemId) throw new BadRequestException('drainageItemId is required'); const signature = await tx.smsSignature.findUnique({ where: { id: item.signatureId } }); const channel = await tx.smsChannel.findUnique({ where: { id: item.channelId } }); if (!signature || !channel) throw new NotFoundException('Signature or channel not found'); - const existing = await tx.channelSignatureReportTask.findFirst({ where: { signatureId: item.signatureId, channelId: item.channelId } }); + const existing = await tx.channelSignatureReportTask.findFirst({ where: { signatureId: item.signatureId, channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : null } }); const task = existing ? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: item.status, reason: data.reason } }) - : await tx.channelSignatureReportTask.create({ data: { tenantId: signature.tenantId, signatureId: item.signatureId, channelId: item.channelId, status: item.status, reason: data.reason, createdById: data.operatorId } }); + : await tx.channelSignatureReportTask.create({ data: { tenantId: signature.tenantId, signatureId: item.signatureId, channelId: item.channelId, 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 } }); + if (reportType === 'drainage') drainageResults.push({ signatureId: item.signatureId, reportType, drainageItemId: item.drainageItemId!, channelId: item.channelId, status: item.status }); } const summaries = []; for (const signatureId of signatureIds) summaries.push(await this.recomputeSignatureReportSummary(tx, signatureId)); - return summaries; + return [...summaries, ...drainageResults]; }); } @@ -1018,7 +1028,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { 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 }, include: { channel: true } }); + const tasks = await tx.channelSignatureReportTask.findMany({ where: { signatureId, reportType: 'signature' }, include: { channel: true } }); const channels = configuredChannels.length ? configuredChannels : tasks.map((task) => task.channel); const uniqueChannels = [...new Map(channels.map((channel) => [channel.id, channel])).values()]; const taskByChannel = new Map(tasks.map((task) => [task.channelId, task])); @@ -1067,7 +1077,9 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { }, }); await this.updateReportTaskStatus(taskId, task.channelId, task.status, statusAfter, 'receipt_import', data.reason); - await this.recomputeSignatureReportSummary(this.prisma as unknown as Prisma.TransactionClient, task.signatureId); + if ((task.reportType ?? 'signature') === 'signature') { + await this.recomputeSignatureReportSummary(this.prisma as unknown as Prisma.TransactionClient, task.signatureId); + } return imported; } diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index af5983f..7a408c5 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -739,6 +739,7 @@ describe('SendChainService', () => { service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd }); await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual(expect.objectContaining({ submitted: true, channelId: backup.id })); + expect(prisma.channelSignatureReportTask.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ reportType: 'signature' }) })); expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ channelId: backup.id }) })); }); diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index 1bd0401..029d8fd 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -1876,7 +1876,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { const signatureId = await this.resolveMessageSignatureId(message); if (!signatureId) throw new BadRequestException('短信签名未配置,无法选择已报备通道'); const approvedTasks = await this.prisma.channelSignatureReportTask.findMany({ - where: { signatureId, status: 'approved', channelId: { in: route.group.items.map((item) => item.channelId) } }, + where: { signatureId, reportType: 'signature', status: 'approved', channelId: { in: route.group.items.map((item) => item.channelId) } }, select: { channelId: true }, }); const approvedChannelIds = new Set(approvedTasks.map((task) => task.channelId)); @@ -2247,7 +2247,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { throw new BadRequestException('短信签名未配置,不能提交到通道'); } const reportTask = await this.prisma.channelSignatureReportTask.findFirst({ - where: { signatureId, channelId, status: 'approved' }, + where: { signatureId, channelId, reportType: 'signature', status: 'approved' }, select: { id: true }, }); if (!reportTask) { diff --git a/api/src/sms-config/admin-sms-config.controller.ts b/api/src/sms-config/admin-sms-config.controller.ts index bc5e234..d1a05ca 100644 --- a/api/src/sms-config/admin-sms-config.controller.ts +++ b/api/src/sms-config/admin-sms-config.controller.ts @@ -54,7 +54,7 @@ export class AdminSmsConfigController { @Post('enterprise-signatures') createSignature(@Body() body: CreateSmsSignatureDto) { - return this.smsConfig.createSignature(body); + return this.smsConfig.createSignature(body, { initialAuditStatus: 'approved' }); } @Put('enterprise-signatures/:id') diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index 2eee2b5..e479043 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -66,6 +66,7 @@ function createPrismaMock() { materials: [], }]), findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', auditStatus: 'pending' }), + create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sig-new', tenantId: 'tenant-1', ...data })), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', ...data })), }, signatureReportMaterial: { @@ -75,6 +76,14 @@ function createPrismaMock() { upsert: jest.fn().mockResolvedValue({ id: 'drainage-report-value-1' }), deleteMany: jest.fn().mockResolvedValue({ count: 0 }), }, + channelSignatureReportTask: { + findFirst: jest.fn().mockResolvedValue(null), + create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-task-1', ...data })), + deleteMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + channelSignatureReportRecord: { + create: jest.fn().mockResolvedValue({ id: 'drainage-record-1' }), + }, smsTemplate: { findMany: jest.fn().mockResolvedValue([{ id: 'tpl-1', @@ -502,6 +511,24 @@ describe('SmsConfigService', () => { expect(prisma.drainageReportMaterial.deleteMany).toHaveBeenCalledWith({ where: { signatureId: 'sig-1', drainageItemId: { notIn: ['drain-1'] } }, }); + expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'pending' }), + }); + expect(prisma.channelSignatureReportRecord.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ taskId: 'drainage-task-1', action: 'create', statusAfter: 'pending' }), + }); + }); + + it('creates admin signatures with an approved initial audit status', async () => { + const prisma = createPrismaMock(); + const service = new SmsConfigService(prisma as never); + + await service.createSignature({ tenantId: 'tenant-1', name: '运营新建签名' }, { initialAuditStatus: 'approved' }); + + expect(prisma.smsSignature.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ auditStatus: 'approved', name: '运营新建签名' }), + }); + expect(prisma.auditRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'admin_create_approved', statusAfter: 'approved' }) }); }); it('updates enterprise signature drainage info through the admin API path', async () => { diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index 5cef436..6e7b585 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -44,6 +44,10 @@ export interface CreateSmsSignatureDto { drainageInfo?: Record; } +export interface CreateSmsSignatureOptions { + initialAuditStatus?: string; +} + export type UpdateSmsSignatureDto = Partial> & { auditStatus?: string; }; @@ -589,19 +593,44 @@ export class SmsConfigService { const applicationIds = signatures.map((signature) => signature.applicationId).filter((id): id is string => Boolean(id)); const routes = applicationIds.length ? await this.prisma.channelRouteRule.findMany({ where: { applicationId: { in: applicationIds }, status: 'active' }, - include: { group: { include: { items: { include: { channel: true } } } } }, + include: { group: { include: { items: { include: { channel: { include: { reportFields: true } } } } } } }, }) : []; return signatures.map((signature) => ({ ...signature, 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 taskByChannel = new Map((signature.reportTasks ?? []).map((task) => [task.channelId, task])); + const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task])); return [...new Map(channels.map((channel) => [channel.id, channel])).values()].map((channel) => ({ channel, channelId: channel.id, status: taskByChannel.get(channel.id)?.status ?? 'pending', taskId: taskByChannel.get(channel.id)?.id })); })(), + drainageReportTargets: Object.fromEntries((Array.isArray((signature.drainageInfo as Record | null)?.links) ? (signature.drainageInfo as Record).links as Array> : []).map((link) => { + const drainageItemId = String(link.id ?? ''); + const channels = routes + .filter((route) => route.applicationId === signature.applicationId && route.group) + .flatMap((route) => route.group!.items.map((item) => item.channel)) + .filter((channel) => channel.status !== 'deleted' && 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()].map((channel) => ({ channel, channelId: channel.id, status: taskByChannel.get(channel.id)?.status ?? 'pending', taskId: taskByChannel.get(channel.id)?.id }))]; + })), + drainageCarrierReportSummary: Object.fromEntries((Array.isArray((signature.drainageInfo as Record | null)?.links) ? (signature.drainageInfo as Record).links as Array> : []).map((link) => { + const drainageItemId = String(link.id ?? ''); + const channels = routes + .filter((route) => route.applicationId === signature.applicationId && route.group) + .flatMap((route) => route.group!.items.map((item) => item.channel)) + .filter((channel) => channel.status !== 'deleted' && channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType))); + const targets = [...new Map(channels.map((channel) => [channel.id, channel])).values()]; + const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task])); + return [drainageItemId, Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => { + const carrierTargets = targets.filter((channel) => channel.carrier === carrier || channel.carrier === 'all'); + const statuses = carrierTargets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending'); + const approved = statuses.filter((status) => status === 'approved').length; + const status = !statuses.length ? 'not_applicable' : approved === statuses.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending'; + return [carrier, { status, approved, total: statuses.length }]; + }))]; + })), 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' && (channel.carrier === carrier || channel.carrier === 'all')); const targets = [...new Map(configured.map((channel) => [channel.id, channel])).values()]; - const taskByChannel = new Map((signature.reportTasks ?? []).map((task) => [task.channelId, task])); + const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task])); const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending'); const approved = statuses.filter((status) => status === 'approved').length; const status = !targets.length ? 'not_applicable' : approved === targets.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending'; @@ -610,7 +639,7 @@ export class SmsConfigService { })); } - async createSignature(data: CreateSmsSignatureDto) { + async createSignature(data: CreateSmsSignatureDto, options: CreateSmsSignatureOptions = {}) { await this.validateSignatureReportValues(data.applicationId, data.drainageInfo); const drainageInfo = await this.withReportRequirementSnapshot(data.applicationId, data.drainageInfo); const signature = await this.prisma.smsSignature.create({ @@ -619,10 +648,21 @@ export class SmsConfigService { applicationId: data.applicationId, name: data.name, purpose: data.purpose, + auditStatus: options.initialAuditStatus, drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, }, }); await this.syncSignatureReportValues(signature.id, data.applicationId, drainageInfo); + if (options.initialAuditStatus) { + await this.createAuditRecord({ + tenantId: signature.tenantId, + targetType: 'sms_signature', + targetId: signature.id, + action: 'admin_create_approved', + statusAfter: options.initialAuditStatus, + reason: '运营端新建签名自动审核通过', + }); + } return signature; } @@ -684,6 +724,13 @@ export class SmsConfigService { ...(drainageItemIds.length > 0 ? { drainageItemId: { notIn: drainageItemIds } } : {}), }, }); + await this.prisma.channelSignatureReportTask.deleteMany({ + where: { + signatureId, + reportType: 'drainage', + ...(drainageItemIds.length > 0 ? { drainageItemId: { notIn: drainageItemIds } } : {}), + }, + }); for (const field of fields.filter((item) => item.reportTypes.some((type) => type === 'signature' || type === 'both'))) { const value = reportValueParts(signatureValues[field.code]); for (const channel of field.channels) { @@ -694,11 +741,29 @@ export class SmsConfigService { }); } } + const drainageFields = fields.filter((item) => item.reportTypes.some((type) => type === 'drainage' || type === 'both')); + const drainageChannels = new Map(drainageFields.flatMap((item) => item.channels).map((channel) => [channel.id, channel])); + const signatureOwner = links.length > 0 + ? await this.prisma.smsSignature.findUnique({ where: { id: signatureId }, select: { tenantId: true } }) + : null; for (const link of links) { const drainageItemId = String(link.id ?? ''); const values = isRecord(link.reportValues) ? link.reportValues : {}; if (!drainageItemId) continue; - for (const field of fields.filter((item) => item.reportTypes.some((type) => type === 'drainage' || type === 'both'))) { + for (const channel of drainageChannels.values()) { + const existingTask = await this.prisma.channelSignatureReportTask.findFirst({ + where: { signatureId, channelId: channel.id, reportType: 'drainage', drainageItemId }, + }); + if (!existingTask && signatureOwner) { + const task = await this.prisma.channelSignatureReportTask.create({ + data: { tenantId: signatureOwner.tenantId, signatureId, channelId: channel.id, reportType: 'drainage', drainageItemId, status: 'pending' }, + }); + await this.prisma.channelSignatureReportRecord.create({ + data: { taskId: task.id, channelId: channel.id, action: 'create', statusAfter: 'pending' }, + }); + } + } + for (const field of drainageFields) { const value = reportValueParts(values[field.code]); for (const channel of field.channels) { await this.prisma.drainageReportMaterial.upsert({ diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 45ad9fa..d0f13b5 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -1182,10 +1182,14 @@ 8. 回执导入。 9. 报备记录。 10. 签名报备状态同步。 +11. 引流信息按通道报备任务、报备记录和回执。 +12. 企业签名、通道报备详情、报备任务三个入口共用同一报备状态事实源。 验收标准: - 签名审核状态和通道报备状态分层。 +- 运营端提供真实签名审核列表、资料详情和通过/驳回操作;运营端直接新建的签名自动审核通过并写审核记录。 +- 引流信息按“报备字段库 → 通道配置引流字段 → 企业签名提交资料 → 通道报备任务”流转,不使用 JSON 中的静态三网状态代替任务状态。 - 发送前必须校验签名在目标通道报备通过。 - 报备任务导出、导入具备幂等和历史记录。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 3bb0f73..ee0aa0c 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -233,6 +233,18 @@ - 驳回原因保存并返回客户端。 - 被驳回模板不能用于发送。 +### TC-ADMIN-001A 运营端新建签名自动通过 + +- 优先级:P0 +- 前置条件:运营管理员已登录,目标企业和应用存在。 +- 步骤: + 1. 在运营端企业签名页新建签名并保存。 + 2. 查询签名列表、签名审核页和审核记录。 +- 预期结果: + - `SmsSignature.auditStatus=approved`,不停留在 draft。 + - 生成 `admin_create_approved` 审核记录。 + - 客户端自行新建签名仍按 draft → pending → approved/rejected 流转。 + ### TC-ADMIN-003 通道创建与启停 - 优先级:P0 @@ -308,6 +320,20 @@ 5. 查询 PostgreSQL 中签名、签名报备材料和引流报备材料记录;删除该引流项后再次查询。 - 预期结果: - 营业执照按字段库 ID 合并为一个字段,且因任一目标通道必填而整体必填;签名弹窗展示营业执照,引流弹窗展示网站主体及两者共用字段。 + +### TC-ADMIN-005B 引流信息按通道报备及三入口同步 + +- 优先级:P0 +- 前置条件:企业应用已绑定通道组,至少一个目标通道配置 `drainage` 或 `both` 报备字段。 +- 步骤: + 1. 在企业签名中新增引流信息,填写动态字段并保存。 + 2. 查询 `DrainageReportMaterial` 和 `ChannelSignatureReportTask(reportType=drainage)`。 + 3. 分别从企业签名、通道报备详情、报备任务页修改同一引流项在同一通道的状态。 + 4. 查看报备记录,导出任务并导入回执。 +- 预期结果: + - 每个“签名 + 引流项 + 通道”对应独立真实任务,初始为 pending。 + - 三个入口的状态和通过数/总数一致,修改写入 `ChannelSignatureReportRecord`。 + - 引流任务不参与 `SmsSignature.reportStatus` 聚合,也不能被短信发送选路误当为签名报备通过。 - 字段库页面只提供字符串、图片、文件三种类型;API 对其他类型返回 400,历史其他类型迁移为字符串。 - 缺少必填资料时前端禁止提交;直接调用 API 也返回 400,不能绕过页面保存不完整资料。 - 文件通过真实对象存储上传;动态值随签名 JSON 保存,并按来源通道分别写入规范化报备材料表。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 02ec608..62fa0ce 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -1668,3 +1668,12 @@ git diff --check - 本地验证:API 全量 13 suites、137 项通过,API build、前端 build、`git diff --check` 通过;前端仅有既有 chunk size warning。Chrome 当前无已登录生产标签,部署前未绕过图形验证码或注入 mock 数据。 - 已将功能提交 `19d47b45` push 到 `origin/main` 并部署生产。部署前备份 PostgreSQL 为 `/opt/cmpp-platform/backups/cmpp-20260713-103205.sql`(约 61MB),备份运行源码为 `/opt/cmpp-platform/backups/source-20260713-103205.tar.gz`(约 63MB);发布快照本地/服务器 SHA-256 一致。生产无待执行 migration,`.deployed-commit=19d47b45`,`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均为 active,`12026/17890/8090/3000` 监听,API/Gateway health 和外部 HTTP 200。生产 `index.html` 已引用新资源 `index-HtuLnrXL.js/index-C5Tf8YiT.css`,源码已确认包含企业模板响应式行和签名操作区两列布局。 - 生产浏览器确认路由可加载、标题为“聆界短信管理平台”、DOM 非空、无 console error/warn 和框架错误覆盖。由于浏览器没有已登录运营端会话,访问受保护页面按预期落到登录界面;未经用户确认不代解图形验证码,因此登录后真实数据截图和点击交互留待用户刷新生产页面验收。 + +## 2026-07-13 签名审核与引流信息通道报备闭环 + +- 生产只读核查确认:运营端新建的【安徽航天信息】仍为 `auditStatus=draft`;引流资料保存在 `SmsSignature.drainageInfo.links`,动态字段可写 `DrainageReportMaterial`,但缺少引流项维度的真实通道报备任务和记录。 +- 将 `/admin/signatures` 从占位页替换为真实签名审核页,支持状态查询、资质/动态资料详情、通过和带原因驳回,调用现有 NestJS 审核 API 并写 `AuditRecord`。运营端新建签名直接写 `auditStatus=approved`,同时写 `admin_create_approved` 审核记录;客户端提交审核流不变。 +- `ChannelSignatureReportTask` 增加 `reportType=signature/drainage` 和 `drainageItemId`。保存引流资料时,根据企业应用的真实路由通道及其 `drainage/both` 字段自动生成 pending 任务和 create 记录;删除引流项时同步清理对应材料和任务。 +- 企业签名引流列表、通道报备详情、报备任务页均按引流项和通道读取/修改同一任务,共享报备记录、导出和回执链路。企业签名三网状态改为引流任务真实汇总,不再使用 `drainageInfo.links.mobile/unicom/telecom` 静态值。 +- 短信发送选路和最终通道二次校验明确增加 `reportType=signature`,引流通过任务不会误放行未报备签名。Prisma migrations:`20260713153000_add_drainage_report_tasks`、`20260713154000_backfill_drainage_report_tasks`、`20260713155000_unique_report_task_scope`;后两者分别回填已有引流材料的 pending 任务/记录,并保证同一报备对象和通道仅一个任务。 +- 本地真实 PostgreSQL 已成功应用 3 条新 migration;API 全量 13 suites、139 项通过,Gateway 全量 Go 测试、Prisma validate、API build、前端 build 和 `git diff --check` 通过,前端仅有既有 chunk size warning。应用内浏览器确认本地运营端登录路由标题正确、DOM 非空、无框架错误覆盖且 console 无 error/warn;真实图形验证码阻止进入受保护页,未代解验证码、未绕过认证、未注入 mock。 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index c12cbb4..40df3a1 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -285,6 +285,8 @@ export type ClientSmsSignature = { reportTasks?: Array; reportTargets?: Array<{ channel: AdminChannel; channelId: string; status: string; taskId?: string }>; carrierReportSummary?: Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>; + drainageReportTargets?: Record>; + drainageCarrierReportSummary?: Record>; }; export type ClientSmsTemplate = { @@ -301,7 +303,7 @@ export type ClientSmsTemplate = { updatedAt: string; variables?: Array<{ name: string; example?: string | null; required?: boolean }>; application?: { id: string; name: string }; - signature?: { id: string; name: string }; + signature?: { id: string; name: string; drainageInfo?: Record | null }; tenant?: TenantOption; }; @@ -539,8 +541,10 @@ export type ReportTask = DictionaryItem & { tenantId: string; signatureId: string; channelId: string; + reportType?: 'signature' | 'drainage'; + drainageItemId?: string | null; status: string; - signature?: { id: string; name: string }; + signature?: { id: string; name: string; drainageInfo?: Record | null }; channel?: { id: string; name: string; code: string }; }; @@ -930,7 +934,12 @@ export const adminApi = { method: 'POST', body: JSON.stringify({ reason }), }), - listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string } = {}) => + approveSignature: (id: string) => request(`/admin/signatures/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }), + rejectSignature: (id: string, reason = '运营审核驳回') => request(`/admin/signatures/${id}/reject`, { + method: 'POST', + body: JSON.stringify({ reason }), + }), + listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string } = {}) => request(withQuery('/admin/enterprise-signatures', query)), createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record }) => request('/admin/enterprise-signatures', { method: 'POST', body: JSON.stringify(body) }), @@ -981,9 +990,9 @@ export const adminApi = { createChannelReportField: (body: Record) => request('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }), listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string } = {}) => request(withQuery('/admin/report-tasks', query)), - createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; createdById?: string }) => + createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) => request('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }), - changeReportTaskStatuses: (body: { items: Array<{ signatureId: string; channelId: string; status: string }>; reason?: string; operatorId?: string }) => + changeReportTaskStatuses: (body: { items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>; reason?: string; operatorId?: string }) => request }>>('/admin/report-tasks/status-change', { method: 'POST', body: JSON.stringify(body) }), createReportExport: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number }) => request>(`/admin/report-tasks/${id}/export`, { method: 'POST', body: JSON.stringify(body) }), diff --git a/src/apps/admin/AdminChannelReportPage.tsx b/src/apps/admin/AdminChannelReportPage.tsx index 91e9138..05d2c2f 100644 --- a/src/apps/admin/AdminChannelReportPage.tsx +++ b/src/apps/admin/AdminChannelReportPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { ArrowLeft, ChevronDown, ChevronRight, Eye, FileSliders, Plus, Search } from 'lucide-react'; +import { ArrowLeft, Eye, FileSliders, Search } from 'lucide-react'; import { useNavigate, useParams } from 'react-router-dom'; import { adminApi, type AdminChannel, type ChannelReportField, type ClientSmsSignature, type DictionaryItem, type ReportRecord, type ReportTask } from '@/api/adminApi'; import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui'; @@ -16,6 +16,8 @@ const statusMeta: Record([]); const [fields, setFields] = useState([]); const [libraryFields, setLibraryFields] = useState([]); - const [expanded, setExpanded] = useState>(new Set()); const [keyword, setKeyword] = useState(''); const [status, setStatus] = useState('all'); const [detail, setDetail] = useState<{ task: ReportTask; signature?: ClientSmsSignature; drainage?: DrainageItem }>(); @@ -109,7 +110,8 @@ export function AdminChannelReportPage() { const signatureMap = useMemo(() => new Map(signatures.map((item) => [item.id, item])), [signatures]); const visibleTasks = useMemo(() => tasks.filter((task) => { const signature = signatureMap.get(task.signatureId); - const matchesKeyword = !keyword.trim() || [signature?.name, signature?.tenant?.name, signature?.application?.name].some((value) => String(value ?? '').includes(keyword.trim())); + const drainage = drainageItems(signature).find((item) => String(item.id) === task.drainageItemId); + const matchesKeyword = !keyword.trim() || [signature?.name, signature?.tenant?.name, signature?.application?.name, drainage?.siteName, drainage?.url].some((value) => String(value ?? '').includes(keyword.trim())); return matchesKeyword && (status === 'all' || task.status === status); }), [keyword, signatureMap, status, tasks]); @@ -124,17 +126,9 @@ export function AdminChannelReportPage() { .catch((failure: Error) => setError(failure.message || '报备字段保存失败')); } - function toggle(signatureId: string) { - setExpanded((current) => { - const next = new Set(current); - next.has(signatureId) ? next.delete(signatureId) : next.add(signatureId); - return next; - }); - } - function saveTaskStatus() { if (!statusTask) return; - adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, status: nextStatus }], reason: statusReason }) + adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, reportType: statusTask.reportType, drainageItemId: statusTask.drainageItemId ?? undefined, status: nextStatus }], reason: statusReason }) .then(() => { setStatusTask(undefined); setStatusReason(''); loadData(); }) .catch((failure: Error) => setError(failure.message || '报备状态保存失败')); } @@ -159,37 +153,27 @@ export function AdminChannelReportPage() {
setKeyword(event.target.value)} placeholder="请输入关键词" prefix={} value={keyword} /> setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} />
) :
当前应用的目标通道没有配置引流信息报备字段。
} +