diff --git a/api/src/channels/channels.service.spec.ts b/api/src/channels/channels.service.spec.ts index 1c5020c..cb92bb0 100644 --- a/api/src/channels/channels.service.spec.ts +++ b/api/src/channels/channels.service.spec.ts @@ -352,37 +352,40 @@ describe('ChannelsService', () => { })); }); - 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', carriers: ['mobile'], status: 'active' }) }, - smsDrainageInfo: { findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }) }, - channelSignatureReportTask: { - findFirst: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'reporting' }), - update: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved' }), - create: jest.fn(), - findMany: jest.fn().mockResolvedValue([{ id: 'task-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved', channel: { id: 'channel-1', carrier: 'mobile', carriers: ['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); + it.each(['enterprise_signature', 'report_task'] as const)( + 'changes channel report status from %s and recomputes the signature summary atomically', + async (sourceEntry) => { + 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', carriers: ['mobile'], status: 'active' }) }, + smsDrainageInfo: { findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }) }, + channelSignatureReportTask: { + findFirst: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'reporting' }), + update: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved' }), + create: jest.fn(), + findMany: jest.fn().mockResolvedValue([{ id: 'task-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved', channel: { id: 'channel-1', carrier: 'mobile', carriers: ['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', carrier: 'mobile', status: 'approved' }], reason: '运营商确认', sourceEntry: 'enterprise_signature' })).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', sourceEntry: 'enterprise_signature' }) }); - expect(tx.channelSignatureReportTask.update).toHaveBeenCalledWith({ - where: { id: 'task-1' }, - data: expect.objectContaining({ status: 'approved', approvedAt: expect.any(Date) }), - }); - expect(tx.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'approved' } }); - }); + await expect(service.changeReportTaskStatuses({ items: [{ signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', status: 'approved' }], reason: '运营商确认', sourceEntry })).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', sourceEntry }) }); + expect(tx.channelSignatureReportTask.update).toHaveBeenCalledWith({ + where: { id: 'task-1' }, + data: expect.objectContaining({ status: 'approved', approvedAt: expect.any(Date) }), + }); + expect(tx.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'approved' } }); + }, + ); it('uses the enterprise-signature save time when creating an approved carrier task', async () => { const prisma = createPrismaMock(); diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index f59f9cf..a99a535 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -1231,7 +1231,14 @@ describe('SmsConfigService', () => { expect(result.items[0]).not.toHaveProperty('drainageReportTargets'); }); - it('returns the filtered total of signature channel-carrier details still awaiting batch generation', async () => { + it.each([ + ['abandoned', 1], + ['pending', 2], + ['reporting', 2], + ['approved', 2], + ['failed', 2], + ['rejected', 2], + ])('keeps the pending detail total aligned after a persisted %s task status', async (taskStatus, expectedTotal) => { const prisma = createPrismaMock(); prisma.smsSignature.findMany.mockImplementation(({ where }) => { if (where?.AND) { @@ -1242,7 +1249,7 @@ describe('SmsConfigService', () => { materialVersion: 2, application: { status: 'active' }, reportTasks: [ - { channelId: 'channel-1', carrier: 'mobile', status: 'abandoned', approvalScope: 'carrier_specific' }, + { channelId: 'channel-1', carrier: 'mobile', status: taskStatus, approvalScope: 'carrier_specific' }, ], reportBatchItems: [], }, @@ -1273,7 +1280,7 @@ describe('SmsConfigService', () => { const result = await service.listSignaturesPage({ signatureKeyword: '测试', page: 1, pageSize: 10 }); - expect(result.pendingReportDetailTotal).toBe(1); + expect(result.pendingReportDetailTotal).toBe(expectedTotal); expect(prisma.smsSignature.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { AND: [expect.objectContaining({ name: { contains: '测试' } }), { auditStatus: 'approved', pendingReport: true }] }, diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 1a8e12f..f650e21 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5029,6 +5029,7 @@ npm run verify:phase8 | TC-REPORT-WORKBENCH-019 | 点击批量或单条“修改状态” | 状态选择和修改原因只在独立弹窗中出现;确认后调用既有批量状态接口,写入真实状态记录并刷新批次四项明细数;失败时显示错误,不静默吞错 | | TC-REPORT-WORKBENCH-020 | 打开“报备文件导出”弹窗并下载单个通道文件 | 每个通道展示一份简报、复制按钮和报备文件下载按钮;XLSX文件名为`YYYY-MM-DD_通道名_批次号.xlsx`,文件来自该批次真实MinIO对象 | | TC-REPORT-WORKBENCH-021 | 点击报备文件弹窗“全部下载” | 一次下载ZIP,内含每个通道一份XLSX和一份TXT简报;所有条目均按`YYYY-MM-DD_通道名_批次号`命名;任一通道文件缺失、超过100个通道或总文件超过200MB时返回明确错误,不生成不完整压缩包 | +| TC-REPORT-WORKBENCH-022 | 对尚未生成当前材料版本批次的签名,分别从通道报备明细和企业签名的报备状态弹窗将一个通道×运营商组合设为放弃报备,再恢复为其他状态并刷新企业签名页 | 两个入口调用同一真实状态接口并写入各自来源记录;设为放弃后待生成明细总数减少1,恢复后增加1。当前材料版本已经生成成功批次的组合不得因人工改状态重新计入,避免重复生成;企业签名页右上角两个按钮使用统一操作区间距,窄屏可换行且不重叠 | ## TC-HIGH-FREQUENCY-QUERY-20260902 高频查询与按需详情 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index ff88044..9f2628c 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4381,3 +4381,12 @@ git diff --check - 真实测试数据批次为`RB20260903031835C997`:在同一应用下创建3条专用已审核签名,每条均填写字段库中排序第一的“短信内容”,按6个既有`LGST-*`测试通道生成6份XLSX、6份通道简报和18条通道报备明细。四项计数为总明细18、报备中18、成功0、失败0,符合新批次任务初始`exporting`状态归入报备中的规则。 - 通过已部署服务、真实PostgreSQL和MinIO逐份读回验证:每个通道XLSX均4行(表头1行、签名3行),每份简报均包含3条签名及各自短信内容;批量ZIP名称为`2026-09-03_RB20260903031835C997_报备文件.zip`,内部严格为每通道一份`日期_通道名_批次号.xlsx`和同名`.txt`,共12项。验证JSON及发布后Stream快照已写入恢复点并附独立SHA-256。 - 测试数据生成前后三条Redis Stream的组信息、最后投递ID、entries-read、pending和lag快照逐字一致,均为`pending=0 / lag=0`,没有发送、补发、重投或重新入队短信;没有修改余额或短信通道连接参数。登录后测试环境真实浏览器验收因没有有效平台管理员登录态未执行,未以本地隔离页面视觉验证冒充线上登录交互。 + +## 2026-09-03 待生成明细数量状态联动与企业签名按钮间距(本地修改) + +- 重新核对两处状态入口:通道报备明细使用`sourceEntry=report_task`,企业签名报备状态弹窗使用`sourceEntry=enterprise_signature`,均调用统一真实批量状态接口并持久化`ChannelSignatureReportTask/ChannelSignatureReportRecord`;企业签名弹窗保存成功后会重新请求分页接口刷新汇总数。 +- 待生成明细总数继续严格遵循已确认口径:仅统计审核通过、仍在资料池、当前材料版本尚未成功生成批次且未放弃的“企业应用×签名×通道×运营商”组合。尚未生成批次时,改为`abandoned`会减少、恢复为其他状态会增加;已成功生成当前版本的组合不会因人工状态变化重新进入,避免同版本重复生成。本轮未扩大或改写批次资格规则。 +- 企业签名管理右上角按钮容器修正为统一`page-heading__actions`,使“批量导入签名及引流资料”和“添加签名”获得标准间距并保留窄屏换行。 +- 增加两个状态入口共用事务逻辑、各状态下待生成数量和页面按钮间距的回归用例。定向API 2套122项、API全量52套601项、前端全量12文件55项、前后端TypeScript及Vite生产构建均通过;Vite仅保留既有Chart分块超过500kB提示。 +- Browser插件不在本会话技能列表,按前端调试流程使用本机Playwright Chrome检查本地生产构建:1600×1000与390×844下均显示两个目标按钮,计算样式间距为8px、无重叠和页面横向溢出,控制台及页面错误均为0。接口响应使用隔离布局数据,仅验证样式和响应式交互,不冒充真实API/PostgreSQL验收。 +- 本轮不提交、不推送、不部署,不访问预生产,不修改测试环境业务数据,不触发短信发送、补发、重投或重新入队。 diff --git a/src/apps/admin/AdminEnterpriseSignaturesPage.tsx b/src/apps/admin/AdminEnterpriseSignaturesPage.tsx index 14e69d5..b1d28b7 100644 --- a/src/apps/admin/AdminEnterpriseSignaturesPage.tsx +++ b/src/apps/admin/AdminEnterpriseSignaturesPage.tsx @@ -231,7 +231,7 @@ export function AdminEnterpriseSignaturesPage() {

企业签名管理

-
+