From eab059583f03ad648ef495cc3e3a552d2cbe2390 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Sun, 12 Jul 2026 20:38:20 +0800 Subject: [PATCH] feat: unify signature channel reporting status --- .../migration.sql | 9 + api/src/channels/channels.controller.ts | 10 +- api/src/channels/channels.service.spec.ts | 50 +++- api/src/channels/channels.service.ts | 73 +++++- .../dictionaries/dictionaries.service.spec.ts | 16 ++ api/src/dictionaries/dictionaries.service.ts | 5 +- api/src/sms-config/sms-config.service.spec.ts | 2 +- api/src/sms-config/sms-config.service.ts | 28 +- .../first-version-development-requirements.md | 4 +- docs/system-functional-test-cases.md | 20 +- docs/testing-progress.md | 5 + src/api/adminApi.ts | 10 +- src/apps/admin/AdminChannelReportPage.tsx | 243 ++++++++++++------ src/apps/admin/AdminDrainageFieldsPage.tsx | 15 +- .../admin/AdminEnterpriseSignaturesPage.tsx | 45 +++- src/apps/admin/AdminReportTasksPage.tsx | 14 +- 16 files changed, 439 insertions(+), 110 deletions(-) create mode 100644 api/prisma/migrations/20260712170000_normalize_report_field_types/migration.sql diff --git a/api/prisma/migrations/20260712170000_normalize_report_field_types/migration.sql b/api/prisma/migrations/20260712170000_normalize_report_field_types/migration.sql new file mode 100644 index 0000000..8d29881 --- /dev/null +++ b/api/prisma/migrations/20260712170000_normalize_report_field_types/migration.sql @@ -0,0 +1,9 @@ +UPDATE "DrainageField" +SET "fieldType" = 'string' +WHERE "fieldType" NOT IN ('string', 'image', 'file'); + +UPDATE "ChannelReportField" +SET "fieldType" = CASE + WHEN "fieldType" IN ('string', 'image', 'file') THEN "fieldType" + ELSE 'string' +END; diff --git a/api/src/channels/channels.controller.ts b/api/src/channels/channels.controller.ts index 762e743..fea2a6a 100644 --- a/api/src/channels/channels.controller.ts +++ b/api/src/channels/channels.controller.ts @@ -12,6 +12,7 @@ import { CreateReportFieldDto, CreateReportMaterialDto, CreateReportTaskDto, + ChangeReportTaskStatusesDto, CreateRouteRuleDto, TestChannelDto, UpsertConnectionStateDto, @@ -145,8 +146,8 @@ export class ChannelsController { } @Get('report-tasks') - listReportTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string) { - return this.channels.listReportTasks(tenantId, status); + listReportTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('channelId') channelId?: string) { + return this.channels.listReportTasks(tenantId, status, channelId); } @Post('report-tasks/generate') @@ -154,6 +155,11 @@ export class ChannelsController { return this.channels.createReportTask(body); } + @Post('report-tasks/status-change') + changeReportTaskStatuses(@Body() body: ChangeReportTaskStatusesDto) { + return this.channels.changeReportTaskStatuses(body); + } + @Post('report-tasks/:id/export') createReportExport(@Param('id') taskId: string, @Body() body: CreateReportExportDto) { return this.channels.createReportExport(taskId, body); diff --git a/api/src/channels/channels.service.spec.ts b/api/src/channels/channels.service.spec.ts index ddbfc0a..1da0fcc 100644 --- a/api/src/channels/channels.service.spec.ts +++ b/api/src/channels/channels.service.spec.ts @@ -86,7 +86,7 @@ function createPrismaMock() { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-item-1', ...data })), }, channelRouteRule: { - findMany: jest.fn(), + findMany: jest.fn().mockResolvedValue([]), findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'route-1', ...data })), }, @@ -103,7 +103,7 @@ function createPrismaMock() { upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve({ id: 'material-1', ...create })), }, channelSignatureReportTask: { - findMany: jest.fn(), + findMany: jest.fn().mockResolvedValue([{ ...reportTask, status: 'partial', channel }]), create: jest.fn().mockResolvedValue(reportTask), findUnique: jest.fn().mockResolvedValue(reportTask), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...reportTask, ...data })), @@ -119,6 +119,7 @@ function createPrismaMock() { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'import-1', ...data })), }, smsSignature: { + findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: null }), update: jest.fn(), }, smsApplication: { @@ -174,6 +175,47 @@ describe('ChannelsService', () => { }); }); + it('lists report tasks for one real channel', async () => { + const prisma = createPrismaMock(); + prisma.channelSignatureReportTask.findMany.mockResolvedValue([]); + const service = new ChannelsService(prisma as never); + + await service.listReportTasks(undefined, undefined, 'channel-1'); + + expect(prisma.channelSignatureReportTask.findMany).toHaveBeenCalledWith({ + where: { tenantId: undefined, status: undefined, channelId: 'channel-1' }, + include: { signature: true, channel: true }, + orderBy: { createdAt: 'desc' }, + }); + }); + + it('changes channel report status and recomputes the signature summary atomically', async () => { + const prisma = createPrismaMock(); + const tx = { + smsSignature: { + findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }), + update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }), + }, + smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', status: 'active' }) }, + channelSignatureReportTask: { + findFirst: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'reporting' }), + update: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }), + create: jest.fn(), + findMany: jest.fn().mockResolvedValue([{ id: 'task-1', channelId: 'channel-1', status: 'approved', channel: { id: 'channel-1', carrier: 'mobile', status: 'active' } }]), + }, + channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'record-1' }) }, + channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: [{ channel: { id: 'channel-1', carrier: 'mobile', status: 'active' } }] } }]) }, + }; + prisma.$transaction.mockImplementation((callback) => callback(tx)); + const service = new ChannelsService(prisma as never); + + await expect(service.changeReportTaskStatuses({ items: [{ signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }], reason: '运营商确认' })).resolves.toEqual([ + expect.objectContaining({ signatureId: 'sig-1', reportStatus: 'approved' }), + ]); + expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved' }) }); + expect(tx.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'approved' } }); + }); + beforeEach(() => { mockQueueAdd.mockClear(); mockQueueClose.mockClear(); @@ -548,7 +590,7 @@ describe('ChannelsService', () => { }); expect(prisma.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, - data: { reportStatus: 'partial' }, + data: { reportStatus: 'reporting' }, }); }); @@ -579,7 +621,7 @@ describe('ChannelsService', () => { }); expect(prisma.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, - data: { reportStatus: 'partial' }, + data: { reportStatus: 'reporting' }, }); }); diff --git a/api/src/channels/channels.service.ts b/api/src/channels/channels.service.ts index cd797c4..ed451f2 100644 --- a/api/src/channels/channels.service.ts +++ b/api/src/channels/channels.service.ts @@ -101,6 +101,12 @@ export interface CreateReportTaskDto { createdById?: string; } +export interface ChangeReportTaskStatusesDto { + items: Array<{ signatureId: string; channelId: string; status: string }>; + reason?: string; + operatorId?: string; +} + export interface CreateReportExportDto { fileObjectId?: string; fileName: string; @@ -958,9 +964,9 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { }); } - listReportTasks(tenantId?: string, status?: string) { + listReportTasks(tenantId?: string, status?: string, channelId?: string) { return this.prisma.channelSignatureReportTask.findMany({ - where: { tenantId, status }, + where: { tenantId, status, channelId }, include: { signature: true, channel: true }, orderBy: { createdAt: 'desc' }, }); @@ -980,6 +986,53 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { return task; } + async changeReportTaskStatuses(data: ChangeReportTaskStatusesDto) { + if (!data.items.length) throw new BadRequestException('items is required'); + const allowed = new Set(['pending', 'waiting_material', 'reporting', 'approved', 'failed', 'rejected', 'abandoned']); + for (const item of data.items) { + 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))]; + for (const item of data.items) { + 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 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.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 } }); + } + const summaries = []; + for (const signatureId of signatureIds) summaries.push(await this.recomputeSignatureReportSummary(tx, signatureId)); + return summaries; + }); + } + + private async recomputeSignatureReportSummary(tx: Prisma.TransactionClient, signatureId: string) { + const signature = await tx.smsSignature.findUnique({ where: { id: signatureId } }); + if (!signature) throw new NotFoundException('Signature not found'); + const routes = signature.applicationId ? await tx.channelRouteRule.findMany({ + where: { applicationId: signature.applicationId, status: 'active' }, + include: { group: { include: { items: { include: { channel: true } } } } }, + }) : []; + const configuredChannels = routes.flatMap((route) => route.group.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted'); + const tasks = await tx.channelSignatureReportTask.findMany({ where: { signatureId }, 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])); + const carrierReportSummary = Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => { + const targets = uniqueChannels.filter((channel) => channel.carrier === carrier || channel.carrier === 'all'); + const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending'); + return [carrier, summarizeReportStatuses(statuses)]; + })); + const allStatuses = uniqueChannels.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending'); + const reportStatus = summarizeReportStatuses(allStatuses).status; + await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } }); + return { signatureId, reportStatus, carrierReportSummary }; + } + async createReportExport(taskId: string, data: CreateReportExportDto) { const task = await this.getReportTaskOrThrow(taskId); const exported = await this.prisma.reportExportFile.create({ @@ -1014,10 +1067,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { }, }); await this.updateReportTaskStatus(taskId, task.channelId, task.status, statusAfter, 'receipt_import', data.reason); - await this.prisma.smsSignature.update({ - where: { id: task.signatureId }, - data: { reportStatus: statusAfter }, - }); + await this.recomputeSignatureReportSummary(this.prisma as unknown as Prisma.TransactionClient, task.signatureId); return imported; } @@ -1644,6 +1694,17 @@ function normalizeReportType(value?: string) { throw new BadRequestException('reportType must be signature, drainage or both'); } +function summarizeReportStatuses(statuses: string[]) { + if (!statuses.length) return { status: 'not_applicable', approved: 0, total: 0 }; + const approved = statuses.filter((status) => status === 'approved').length; + let status = 'pending'; + if (approved === statuses.length) status = 'approved'; + else if (statuses.some((item) => ['failed', 'rejected'].includes(item))) status = 'failed'; + else if (statuses.some((item) => ['reporting', 'exporting', 'partial', 'partial_success'].includes(item)) || approved > 0) status = 'reporting'; + else if (statuses.some((item) => item === 'waiting_material')) status = 'waiting_material'; + return { status, approved, total: statuses.length }; +} + function normalizeLinkEvent(action: string) { if (action.includes('connect_requested')) { return '连接请求'; diff --git a/api/src/dictionaries/dictionaries.service.spec.ts b/api/src/dictionaries/dictionaries.service.spec.ts index 61a793f..2e268f4 100644 --- a/api/src/dictionaries/dictionaries.service.spec.ts +++ b/api/src/dictionaries/dictionaries.service.spec.ts @@ -25,6 +25,9 @@ function createPrismaMock() { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'enterprise-1', ...data })), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'enterprise-1', ...data })), }, + drainageField: { + create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })), + }, smsApplication: { findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }), }, @@ -112,4 +115,17 @@ describe('DictionariesService', () => { }); expect(prisma.enterpriseBlacklist.update).toHaveBeenCalledWith({ where: { id: 'enterprise-1' }, data: { status: 'deleted' } }); }); + + it('only accepts string, image and file report field types', async () => { + const prisma = createPrismaMock(); + const service = new DictionariesService(prisma as never); + + await expect(service.createDrainageField({ code: 'license', name: '营业执照', fieldType: 'image' })).resolves.toEqual( + expect.objectContaining({ fieldType: 'image' }), + ); + expect(() => service.createDrainageField({ code: 'amount', name: '数量', fieldType: 'number' as never })).toThrow( + 'fieldType must be string, image or file', + ); + expect(prisma.drainageField.create).toHaveBeenCalledTimes(1); + }); }); diff --git a/api/src/dictionaries/dictionaries.service.ts b/api/src/dictionaries/dictionaries.service.ts index 2ac2381..560b8e5 100644 --- a/api/src/dictionaries/dictionaries.service.ts +++ b/api/src/dictionaries/dictionaries.service.ts @@ -47,7 +47,7 @@ export interface CreateBlacklistDto { export interface CreateDrainageFieldDto { code: string; name: string; - fieldType: string; + fieldType: 'string' | 'image' | 'file'; required?: boolean; status?: string; description?: string; @@ -255,6 +255,9 @@ export class DictionariesService { } createDrainageField(data: CreateDrainageFieldDto) { + if (!['string', 'image', 'file'].includes(data.fieldType)) { + throw new BadRequestException('fieldType must be string, image or file'); + } return this.prisma.drainageField.create({ data: { code: data.code, diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index 18d6ebc..2eee2b5 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -430,7 +430,7 @@ describe('SmsConfigService', () => { auditStatus: { not: 'deleted' }, OR: expect.any(Array), }), - include: { materials: true, tenant: true, application: true }, + include: { materials: true, tenant: true, application: true, reportTasks: { include: { channel: true } } }, })); }); diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index 72ea346..5cef436 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -570,9 +570,9 @@ export class SmsConfigService { }); } - listSignatures(queryOrTenantId?: string | { tenantId?: string; keyword?: string; status?: string }) { + async listSignatures(queryOrTenantId?: string | { tenantId?: string; keyword?: string; status?: string }) { const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {}; - return this.prisma.smsSignature.findMany({ + const signatures = await this.prisma.smsSignature.findMany({ where: { tenantId: query.tenantId, auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, @@ -583,9 +583,31 @@ export class SmsConfigService { { application: { name: { contains: query.keyword } } }, ] : undefined, }, - include: { materials: true, tenant: true, application: true }, + include: { materials: true, tenant: true, application: true, reportTasks: { include: { channel: true } } }, orderBy: { createdAt: 'desc' }, }); + 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 } } } } }, + }) : []; + 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])); + 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 })); + })(), + 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 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'; + return [carrier, { status, approved, total: targets.length }]; + })), + })); } async createSignature(data: CreateSmsSignatureDto) { diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 481923d..d79bf67 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -180,7 +180,7 @@ ### 4.7 通道签名报备 -1. 运营端先在“报备字段库”维护字段编码、名称、类型、是否必填等标准定义;通道报备详情只能从字段库选择字段,并指定用途为签名报备、引流信息报备或两者共用,不得在通道内另建同名孤立字段。 +1. 运营端先在“报备字段库”维护字段编码、名称、类型、是否必填等标准定义;字段类型只允许字符串、图片、文件三种。通道报备详情只能从字段库选择字段,并指定用途为签名报备、引流信息报备或两者共用,不得在通道内另建同名孤立字段。 2. 通道组配置通道,企业应用通过路由规则选择通道组。企业签名和引流信息编辑时,系统必须沿“企业应用 -> 生效路由规则 -> 通道组 -> 组内通道 -> 通道报备字段”实时解析字段合集。 3. 同一字段被多个通道引用时按字段库记录去重;任一通道将该字段配置为必填,则企业资料中按必填处理,并保留该字段来源的全部通道用于后续分别报备。 4. 企业签名弹窗只展示签名报备/两者共用字段;每条引流信息只展示引流信息报备/两者共用字段。文件字段走真实对象存储上传,其他字段保存真实值,必填校验同时在前端和 NestJS API 执行。 @@ -190,6 +190,8 @@ 8. 运营端在报备任务或通道报备详情页导入通道回执,系统根据回执同步签名在各通道的报备状态。 9. 报备记录保留每次导出、导入、状态变更和操作人。 10. 发送前必须校验最终选中通道上的签名报备任务为 approved;补发切换到新通道时必须重新按新通道校验报备状态,未通过则该次发送失败。 +11. 企业签名页、通道报备详情页和报备任务页均允许人工修正报备状态,但三个入口必须操作同一份 `ChannelSignatureReportTask` 通道级事实并写 `ChannelSignatureReportRecord`;企业签名页修改时必须展示应用当前通道组内的具体通道矩阵,不允许直接修改移动/联通/电信汇总标签。 +12. 每次人工状态变更或回执导入后,系统必须按应用当前生效路由规则重新汇总各运营商目标通道状态和签名全局 `reportStatus`。新增目标通道但尚无任务时按未报备计入分母;移出当前配置的历史通道不参与当前汇总,但任务和记录继续保留。 ### 4.8 CMPP Gateway 与外部接入 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index eb5b18d..d842747 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -301,17 +301,35 @@ - 优先级:P0 - 前置条件:存在两个 active 通道、一个包含这两个通道的通道组,以及绑定该通道组的企业应用。 - 步骤: - 1. 在报备字段库创建文件字段“营业执照”和文本字段“网站主体”。 + 1. 在报备字段库创建图片字段“营业执照”和字符串字段“网站主体”,并直接调用 API 尝试创建整数、网址、电话、日期等其他类型。 2. 在通道一将营业执照配置为签名报备必填,在通道二将同一字段配置为两者共用非必填,并将网站主体配置为引流信息报备必填。 3. 打开该企业应用下的企业签名编辑弹窗和引流信息编辑弹窗。 4. 分别尝试缺少必填值保存,再补齐文件和值后保存。 5. 查询 PostgreSQL 中签名、签名报备材料和引流报备材料记录;删除该引流项后再次查询。 - 预期结果: - 营业执照按字段库 ID 合并为一个字段,且因任一目标通道必填而整体必填;签名弹窗展示营业执照,引流弹窗展示网站主体及两者共用字段。 + - 字段库页面只提供字符串、图片、文件三种类型;API 对其他类型返回 400,历史其他类型迁移为字符串。 - 缺少必填资料时前端禁止提交;直接调用 API 也返回 400,不能绕过页面保存不完整资料。 - 文件通过真实对象存储上传;动态值随签名 JSON 保存,并按来源通道分别写入规范化报备材料表。 - 删除引流项后,对应引流报备材料记录被同步删除,不保留可被后续导出误用的孤立资料。 +### TC-ADMIN-005B 企业签名、通道详情和报备任务状态一致性 + +- 优先级:P0 +- 前置条件:企业应用绑定移动、联通通道组,移动组含两个通道,联通组含一个通道;企业签名已存在。 +- 步骤: + 1. 在企业签名页打开“报备状态”,确认显示三个具体目标通道,将移动通道一标记通过。 + 2. 在移动通道二的通道报备详情中标记报备通过。 + 3. 在报备任务页将联通任务标记报备中,再通过回执导入改为通过。 + 4. 每步后分别刷新企业签名、通道详情和报备任务页面,并查询数据库任务、记录和签名状态。 + 5. 向移动通道组新增一个通道但不生成任务,再刷新企业签名列表。 +- 预期结果: + - 三个入口操作同一条 `ChannelSignatureReportTask`;不存在的目标通道任务由统一接口真实创建。 + - 每次变化写入 `ChannelSignatureReportRecord`,包含前后状态、原因、操作人和时间。 + - 两个移动通道均通过后移动汇总为通过;联通处理中时签名全局状态不是通过;联通回执通过后三网目标通道全部通过,签名全局状态为 approved。 + - 新增移动通道后移动汇总立即变为部分通过/报备中,分母包含新增通道,不能继续误显示全部通过。 + - 发送时仍校验最终路由通道对应任务为 approved,不以企业签名列表汇总标签代替通道级校验。 + ### TC-ADMIN-006 报备回执导入通过 - 优先级:P0 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 0448e04..65bc68d 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -1630,3 +1630,8 @@ git diff --check - 已提交并 push `87ae4a20`,随后以该提交生成发布快照并部署生产;部署前备份 PostgreSQL 和运行源码,migration `20260712150000_link_report_field_library` 已成功应用。生产 `.deployed-commit=87ae4a20`,`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均为 active,`12026/17890/8090/3000` 监听,API/Gateway health 和外部 HTTP 均通过。 - 生产数据库已确认 `ChannelReportField.drainageFieldId/reportType` 和 `DrainageReportMaterial` 存在。当前生产 `DrainageField=0`、`ChannelReportField=0`,因此不会凭空展示动态资料区;需要先按真实业务配置创建字段库和通道字段后再做页面来源弹窗的有数据验收。 - 浏览器可打开生产登录路由并识别页面标题“CMPP 短信平台”,但读取 DOM/控制台时浏览器连接连续超时,未将解释弹窗点击交互标记为已通过;待生产产生真实字段配置后补测。 +- 2026-07-12 追加:报备字段库字段类型收窄为字符串、图片、文件三种;前端筛选和新增弹窗移除整数、网址、电话、日期,API 严格拒绝三种之外的类型。migration `20260712170000_normalize_report_field_types` 将历史其他类型及其通道字段副本统一归并为字符串。 +- 2026-07-12 追加:按设计基线 `131f344a^` 恢复“通道列表 → 报备详情”页面结构,不再把报备详情错误简化为字段配置表。页面按当前通道查询真实 `ChannelSignatureReportTask/ChannelSignatureReportRecord/SmsSignature.drainageInfo`,展示签名任务、报备状态和时间,签名下引流信息默认收起并可展开;查看详情使用真实企业、应用和动态资料。发送统计没有数据库事实时明确显示“暂无统计”,不复用基线演示百分比。签名报备字段和引流信息字段配置保留为页面顶部两个入口,均从真实报备字段库选择。 +- 本地验证:通道/字典定向测试 2 suites、31 项通过,API 全量 13 suites、134 项通过,API build、前端 build、`git diff --check` 通过。浏览器确认本地构建可加载且无框架错误覆盖,但本地未启动真实 API,认证验证码请求返回 502 并停留登录页,因此未把目标报备页面的登录后视觉交互标记为通过;没有绕过认证或注入 mock 数据。 +- 2026-07-12 追加:报备状态改为通道任务唯一事实来源。新增统一批量状态变更 API,企业签名按应用当前通道组展示具体通道矩阵,通道详情修改当前任务,报备任务页人工修正任务;三个入口统一更新/创建 `ChannelSignatureReportTask`、写 `ChannelSignatureReportRecord`,并重算三网汇总和 `SmsSignature.reportStatus`。回执导入不再直接覆盖全局状态,同样调用汇总算法;新增但无任务的目标通道按未报备计入汇总分母。 +- 已执行相关定向测试 2 suites、46 项及 API 全量测试 13 suites、135 项,API build、前端 build、`git diff --check` 均通过;前端仅有既有 Vite chunk size warning。 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index f17851f..c12cbb4 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -281,6 +281,10 @@ export type ClientSmsSignature = { materials?: Array>; tenant?: TenantOption; application?: ClientSmsApplication | null; + reportStatus?: string; + reportTasks?: Array; + reportTargets?: Array<{ channel: AdminChannel; channelId: string; status: string; taskId?: string }>; + carrierReportSummary?: Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>; }; export type ClientSmsTemplate = { @@ -976,9 +980,11 @@ export const adminApi = { listChannelReportFields: (channelId?: string) => request(withQuery('/admin/channel-report-fields', { channelId })), createChannelReportField: (body: Record) => request('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }), - listReportTasks: (query: { tenantId?: string; status?: string } = {}) => request(withQuery('/admin/report-tasks', query)), + listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string } = {}) => request(withQuery('/admin/report-tasks', query)), createReportTask: (body: { tenantId: string; signatureId: string; channelId: 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 }) => + 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) }), importReportReceipt: (id: string, body: { fileObjectId?: string; fileName: string; fileContent?: string; delimiter?: ',' | '\t'; rowCount?: number; successCount?: number; failedCount?: number; statusAfter?: string; reason?: string; result?: Record }) => @@ -1040,7 +1046,7 @@ export const adminApi = { createPhoneCarrierRule: (body: { carrier: string; pattern: string; priority?: number; status?: string; remark?: string }) => request('/admin/dictionaries/phone-carrier-rules', { method: 'POST', body: JSON.stringify(body) }), listDrainageFields: () => request('/admin/dictionaries/drainage-fields'), - createDrainageField: (body: { code: string; name: string; fieldType: string; required?: boolean; status?: string; description?: string }) => + createDrainageField: (body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; required?: boolean; status?: string; description?: string }) => request('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }), uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId?: string) => { const form = new FormData(); diff --git a/src/apps/admin/AdminChannelReportPage.tsx b/src/apps/admin/AdminChannelReportPage.tsx index 08a8ef9..69baaf1 100644 --- a/src/apps/admin/AdminChannelReportPage.tsx +++ b/src/apps/admin/AdminChannelReportPage.tsx @@ -1,112 +1,201 @@ import { useEffect, useMemo, useState } from 'react'; -import { ArrowLeft, Plus, Search } from 'lucide-react'; -import { useNavigate } from 'react-router-dom'; -import { adminApi, type AdminChannel, type ChannelReportField, type DictionaryItem } from '@/api/adminApi'; -import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, Tag, type TableColumn } from '@/components/ui'; +import { ArrowLeft, ChevronDown, ChevronRight, Eye, FileSliders, Plus, 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'; +import { formatDateTime } from '@/utils/dateTime'; + +type ReportType = 'signature' | 'drainage' | 'both'; +type DrainageItem = Record & { id?: string; url?: string; siteName?: string; submittedAt?: string; remark?: string }; + +const statusMeta: Record = { + approved: { label: '报备成功', tone: 'success' }, + success: { label: '报备成功', tone: 'success' }, + failed: { label: '报备失败', tone: 'danger' }, + rejected: { label: '报备失败', tone: 'danger' }, + pending: { label: '未报备', tone: 'neutral' }, + waiting_material: { label: '资料待补充', tone: 'warning' }, + exporting: { label: '报备中', tone: 'warning' }, + partial_success: { label: '部分成功', tone: 'warning' }, + filing: { label: '报备中', tone: 'warning' }, +}; + +const fieldTypeLabel: Record = { string: '字符串', image: '图片', file: '文件' }; + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +function drainageItems(signature?: ClientSmsSignature) { + const payload = asRecord(signature?.drainageInfo); + return Array.isArray(payload.links) ? payload.links.filter((item): item is DrainageItem => Boolean(item) && typeof item === 'object') : []; +} + +function DateTime({ value }: { value?: unknown }) { + return value ? {formatDateTime(String(value))} : -; +} + +function ReportStatus({ value }: { value?: string }) { + const meta = statusMeta[value ?? ''] ?? { label: value || '未报备', tone: 'neutral' as const }; + return {meta.label}; +} + +function DetailModal({ drainage, signature, task, onClose }: { drainage?: DrainageItem; signature?: ClientSmsSignature; task: ReportTask; onClose: () => void }) { + const payload = asRecord(signature?.drainageInfo); + const profile = asRecord(payload.signatureProfile); + const reportValues = asRecord(drainage ? drainage.reportValues : payload.signatureReportValues); + return ( + 关闭} onClose={onClose} open size="xl" title={drainage ? '查看引流信息详情' : '查看签名详情'}> +
+ {drainage ? String(drainage.siteName || drainage.url || '引流信息') : `【${signature?.name ?? task.signature?.name ?? '-'}】`} +

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

+

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

+ {!drainage ? <>

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

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

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

: null} + {drainage ? <>

引流地址{String(drainage.url ?? '-')}

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

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

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

)} +
+
+ ); +} export function AdminChannelReportPage() { const navigate = useNavigate(); - const [channels, setChannels] = useState([]); + const { channelId = '' } = useParams(); + const [channel, setChannel] = useState(); + const [tasks, setTasks] = useState([]); + const [records, setRecords] = useState([]); + const [signatures, setSignatures] = useState([]); const [fields, setFields] = useState([]); const [libraryFields, setLibraryFields] = useState([]); - const [channelId, setChannelId] = useState(''); + const [expanded, setExpanded] = useState>(new Set()); const [keyword, setKeyword] = useState(''); - const [modalOpen, setModalOpen] = useState(false); + const [status, setStatus] = useState('all'); + const [detail, setDetail] = useState<{ task: ReportTask; signature?: ClientSmsSignature; drainage?: DrainageItem }>(); + const [statusTask, setStatusTask] = useState(); + const [nextStatus, setNextStatus] = useState('approved'); + const [statusReason, setStatusReason] = useState(''); + const [configType, setConfigType] = useState(); const [drainageFieldId, setDrainageFieldId] = useState(''); - const [reportType, setReportType] = useState<'signature' | 'drainage' | 'both'>('signature'); const [required, setRequired] = useState(false); const [description, setDescription] = useState(''); const [error, setError] = useState(''); - function loadData(nextChannelId = channelId) { - Promise.all([adminApi.listChannels(), adminApi.listChannelReportFields(nextChannelId || undefined), adminApi.listDrainageFields()]) - .then(([channelItems, fieldItems, libraryItems]) => { - setChannels(channelItems.filter((item) => item.status !== 'deleted')); - setFields(fieldItems); - setLibraryFields(libraryItems.filter((item) => item.status === 'active')); - setError(''); - }) - .catch((failure: Error) => setError(failure.message || '通道报备配置加载失败')); + function loadData() { + Promise.all([ + adminApi.listChannels(), + adminApi.listReportTasks({ channelId }), + adminApi.listReportRecords({ channelId }), + adminApi.listEnterpriseSignatures(), + adminApi.listChannelReportFields(channelId), + adminApi.listDrainageFields(), + ]).then(([channelItems, taskItems, recordItems, signatureItems, fieldItems, libraryItems]) => { + setChannel(channelItems.find((item) => item.id === channelId)); + setTasks(taskItems); + setRecords(recordItems); + setSignatures(signatureItems); + setFields(fieldItems); + setLibraryFields(libraryItems.filter((item) => item.status === 'active')); + setError(''); + }).catch((failure: Error) => setError(failure.message || '通道报备详情加载失败')); } - useEffect(() => { - loadData(''); - }, []); + useEffect(loadData, [channelId]); - const filteredFields = useMemo(() => fields.filter((field) => !keyword || [field.code, field.name, field.fieldType, field.description].join(' ').includes(keyword)), [fields, keyword]); + 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())); + return matchesKeyword && (status === 'all' || task.status === status); + }), [keyword, signatureMap, status, tasks]); + + function lastRecord(taskId: string, action: string) { + return records.find((record) => record.taskId === taskId && record.action === action); + } function createField() { - const selected = libraryFields.find((item) => item.id === drainageFieldId); - if (!selected) return; - adminApi.createChannelReportField({ channelId, drainageFieldId, reportType, required, description, status: 'active' }) - .then(() => { - setModalOpen(false); - setDrainageFieldId(''); - setReportType('signature'); - setRequired(false); - setDescription(''); - loadData(); - }) + if (!configType || !drainageFieldId) return; + adminApi.createChannelReportField({ channelId, drainageFieldId, reportType: configType, required, description, status: 'active' }) + .then(() => { setConfigType(undefined); setDrainageFieldId(''); setRequired(false); setDescription(''); loadData(); }) .catch((failure: Error) => setError(failure.message || '报备字段保存失败')); } - const columns: Array> = [ - { key: 'channel', title: '通道', width: '220px', render: (record) => channels.find((item) => item.id === record.channelId)?.name ?? record.channelId }, - { key: 'code', title: '字段代码', width: '160px', render: (record) => {record.code} }, - { key: 'name', title: '字段名称', width: '160px', render: (record) => record.name }, - { key: 'type', title: '字段类型', width: '120px', render: (record) => record.fieldType }, - { key: 'reportType', title: '报备用途', width: '140px', render: (record) => record.reportType === 'signature' ? '签名报备' : record.reportType === 'drainage' ? '引流信息报备' : '签名+引流' }, - { key: 'required', title: '必填', width: '90px', render: (record) => {record.required ? '是' : '否'} }, - { key: 'description', title: '说明', render: (record) => record.description ?? '-' }, - ]; + 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 }) + .then(() => { setStatusTask(undefined); setStatusReason(''); loadData(); }) + .catch((failure: Error) => setError(failure.message || '报备状态保存失败')); + } return ( -
-
-
- -

通道报备配置

-
-
- - +
+
+ +
+ +

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

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

{error}

: null} -
- setKeyword(event.target.value)} placeholder="搜索字段代码、名称或说明" prefix={} value={keyword} /> - +
+
+ setKeyword(event.target.value)} placeholder="请输入关键词" prefix={} value={keyword} /> + 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}/>