From 48d036392044ccfa63ee615059c7aa2457c6eb0f Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Fri, 4 Sep 2026 16:26:22 +0800 Subject: [PATCH] feat: enhance operations dashboard and reporting controls --- .../dictionaries/dictionaries.controller.ts | 19 ++++ .../dictionaries/dictionaries.service.spec.ts | 64 +++++++++++++ api/src/dictionaries/dictionaries.service.ts | 93 +++++++++++++++++++ api/src/operations/operations.service.spec.ts | 21 ++++- .../operations/queries/dashboard.queries.ts | 74 +++++++++++++++ .../channel-export.service.ts | 3 +- .../report-materials.controller.ts | 2 +- .../report-materials.service.spec.ts | 5 + .../report-materials.service.ts | 2 +- docs/system-functional-test-cases.md | 14 +++ docs/testing-progress.md | 13 +++ src/api/admin/governance.api.ts | 7 ++ src/api/core/httpClient.test.ts | 17 ++++ src/api/core/httpClient.ts | 3 + src/api/types/identity-config.ts | 6 ++ src/apps/admin/AdminAnalyticsPage.tsx | 10 +- .../admin/AdminDrainageFieldsPage.test.tsx | 39 +++++++- src/apps/admin/AdminDrainageFieldsPage.tsx | 86 +++++++++++++---- src/apps/admin/AdminHome.tsx | 33 +++++++ .../EnterpriseSignaturesTable.test.tsx | 6 ++ .../EnterpriseSignaturesTable.tsx | 11 ++- src/styles/global.css | 51 ++++++++-- src/styles/shell.css | 14 +-- 23 files changed, 544 insertions(+), 49 deletions(-) diff --git a/api/src/dictionaries/dictionaries.controller.ts b/api/src/dictionaries/dictionaries.controller.ts index cc0db3f..a957393 100644 --- a/api/src/dictionaries/dictionaries.controller.ts +++ b/api/src/dictionaries/dictionaries.controller.ts @@ -12,6 +12,8 @@ import { CreateSensitiveWordDto, DictionariesService, DictionaryStatusDto, + ReorderCommonReportFieldsDto, + UpdateDrainageFieldDto, } from './dictionaries.service'; @ApiTags('dictionaries') @@ -128,6 +130,15 @@ export class DictionariesController { return this.dictionaries.createDrainageField(body); } + @Put('drainage-fields/:id') + updateDrainageField( + @Param('id') id: string, + @Body() body: UpdateDrainageFieldDto, + @CurrentSessionUserId() operatorId?: string, + ) { + return this.dictionaries.updateDrainageField(id, body, operatorId); + } + @Delete('drainage-fields/:id') deleteDrainageField(@Param('id') id: string) { return this.dictionaries.deleteDrainageField(id); @@ -168,6 +179,14 @@ export class DictionariesController { return this.dictionaries.createCommonReportField(body); } + @Put('common-report-fields/order') + reorderCommonReportFields( + @Body() body: ReorderCommonReportFieldsDto, + @CurrentSessionUserId() operatorId?: string, + ) { + return this.dictionaries.reorderCommonReportFields(body, operatorId); + } + @Delete('common-report-fields/:id') deleteCommonReportField(@Param('id') id: string) { return this.dictionaries.deleteCommonReportField(id); diff --git a/api/src/dictionaries/dictionaries.service.spec.ts b/api/src/dictionaries/dictionaries.service.spec.ts index e1e0bd2..052934c 100644 --- a/api/src/dictionaries/dictionaries.service.spec.ts +++ b/api/src/dictionaries/dictionaries.service.spec.ts @@ -33,6 +33,7 @@ function createPrismaMock() { findMany: jest.fn().mockResolvedValue([]), findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })), + update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })), delete: jest.fn().mockResolvedValue({ id: 'field-1' }), }, channelReportField: { @@ -46,6 +47,7 @@ function createPrismaMock() { count: jest.fn().mockResolvedValue(0), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'common-1', ...data })), delete: jest.fn().mockResolvedValue({ id: 'common-1' }), + update: jest.fn(), }, smsApplication: { findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }), @@ -76,6 +78,42 @@ describe('DictionariesService', () => { await expect(service.updateCommonReportField('common-1', body)).rejects.toThrow('已停用'); await expect(service.updateCommonReportField('common-1', { ...body, required: 'false' as never })).rejects.toThrow('无效'); }); + + it('reorders every common field in one report type transaction and writes an audit trail', async () => { + const prisma = createPrismaMock(); + const tx = { + commonReportField: { + update: jest.fn().mockResolvedValue({}), + findMany: jest.fn().mockResolvedValue([{ id: 'common-2' }, { id: 'common-1' }]), + }, + operationLog: { create: jest.fn().mockResolvedValue({}) }, + }; + prisma.$transaction.mockImplementation((callback) => callback(tx)); + const service = new DictionariesService(prisma as never); + + await expect( + service.reorderCommonReportFields({ reportType: 'signature', ids: ['common-2', 'common-1'] }, 'admin-1'), + ).resolves.toEqual([{ id: 'common-2' }, { id: 'common-1' }]); + expect(tx.commonReportField.update).toHaveBeenNthCalledWith(1, { + where: { id: 'common-2' }, + data: { sortOrder: 10 }, + }); + expect(tx.commonReportField.update).toHaveBeenNthCalledWith(2, { + where: { id: 'common-1' }, + data: { sortOrder: 20 }, + }); + expect(tx.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ action: 'common_report_field.reorder', userId: 'admin-1' }), + }); + expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: 'Serializable' }); + tx.commonReportField.findMany.mockResolvedValue([{ id: 'common-1' }]); + await expect( + service.reorderCommonReportFields({ reportType: 'signature', ids: ['common-1'] }), + ).resolves.toEqual([{ id: 'common-1' }]); + await expect( + service.reorderCommonReportFields({ reportType: 'signature', ids: ['common-1', 'common-2'] }), + ).rejects.toThrow('排序范围已变化'); + }); it('builds the enterprise province and city library from distinct real phone segment regions', async () => { const prisma = createPrismaMock(); prisma.phoneSegment.findMany.mockResolvedValue([ @@ -130,6 +168,32 @@ describe('DictionariesService', () => { }); }); + it('edits an unreferenced field atomically and protects mapping keys once referenced', async () => { + const prisma = createPrismaMock(); + const existing = { id: 'field-1', code: 'license', name: '主体证明', fieldType: 'file', description: null }; + prisma.drainageField.findUnique.mockResolvedValue(existing as never); + const tx = { + drainageField: { update: jest.fn().mockResolvedValue({ ...existing, name: '企业主体证明', description: '最新版' }) }, + operationLog: { create: jest.fn().mockResolvedValue({}) }, + }; + prisma.$transaction.mockImplementation((callback) => callback(tx)); + const service = new DictionariesService(prisma as never); + + await expect(service.updateDrainageField('field-1', { + code: 'license', name: '企业主体证明', fieldType: 'file', description: ' 最新版 ', + }, 'admin-1')).resolves.toEqual(expect.objectContaining({ name: '企业主体证明' })); + expect(tx.drainageField.update).toHaveBeenCalledWith({ + where: { id: 'field-1' }, + data: { code: 'license', name: '企业主体证明', fieldType: 'file', description: '最新版' }, + }); + expect(tx.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'drainage_field.update', userId: 'admin-1' }) }); + + prisma.channelReportField.count.mockResolvedValue(1); + await expect(service.updateDrainageField('field-1', { + code: 'newCode', name: '企业主体证明', fieldType: 'file', description: '', + })).rejects.toThrow('不能修改字段代码或类型'); + }); + it('ignores deleted-channel references and removes those stale mappings when deleting the field', async () => { const prisma = createPrismaMock(); const tx = { diff --git a/api/src/dictionaries/dictionaries.service.ts b/api/src/dictionaries/dictionaries.service.ts index 203b84b..b021249 100644 --- a/api/src/dictionaries/dictionaries.service.ts +++ b/api/src/dictionaries/dictionaries.service.ts @@ -63,6 +63,8 @@ export interface CreateDrainageFieldDto { description?: string; } +export type UpdateDrainageFieldDto = CreateDrainageFieldDto; + export interface UpsertDrainageDetectionRuleDto { code: string; name: string; @@ -87,6 +89,11 @@ export interface CreateCommonReportFieldDto { sortOrder?: number; } +export interface ReorderCommonReportFieldsDto { + reportType: 'signature' | 'drainage'; + ids: string[]; +} + export interface DictionaryStatusDto { status?: string; operatorId?: string; @@ -399,6 +406,58 @@ export class DictionariesService { }); } + async updateDrainageField(id: string, data: UpdateDrainageFieldDto, operatorId?: string) { + const code = data.code?.trim(); + const name = data.name?.trim(); + if (!code || !/^[A-Za-z0-9]+$/.test(code)) { + throw new BadRequestException('code must contain only Arabic numerals and English letters'); + } + if (!name) throw new BadRequestException('name is required'); + if (!['string', 'image', 'file'].includes(data.fieldType)) { + throw new BadRequestException('fieldType must be string, image or file'); + } + const existing = await this.prisma.drainageField.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('报备字段不存在'); + const [usageCount, commonUsageCount] = await Promise.all([ + this.prisma.channelReportField.count({ where: { drainageFieldId: id, channel: { status: { not: 'deleted' } } } }), + this.prisma.commonReportField.count({ where: { drainageFieldId: id } }), + ]); + if ((usageCount > 0 || commonUsageCount > 0) && (code !== existing.code || data.fieldType !== existing.fieldType)) { + throw new BadRequestException('字段已被引用,只能修改名称和说明,不能修改字段代码或类型'); + } + try { + return await this.prisma.$transaction(async (tx) => { + const updated = await tx.drainageField.update({ + where: { id }, + data: { + code, + name, + fieldType: data.fieldType, + description: data.description?.trim() || null, + }, + }); + await tx.operationLog.create({ + data: { + userId: operatorId, + action: 'drainage_field.update', + resource: 'drainage_field', + resourceId: id, + detail: { + before: { code: existing.code, name: existing.name, fieldType: existing.fieldType, description: existing.description }, + after: { code: updated.code, name: updated.name, fieldType: updated.fieldType, description: updated.description }, + } as Prisma.InputJsonValue, + }, + }); + return updated; + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { + throw new ConflictException('字段代码已存在'); + } + throw error; + } + } + async deleteDrainageField(id: string) { const [usageCount, commonUsageCount] = await Promise.all([ this.prisma.channelReportField.count({ @@ -541,6 +600,40 @@ export class DictionariesService { }); } + async reorderCommonReportFields(data: ReorderCommonReportFieldsDto, operatorId?: string) { + if (!['signature', 'drainage'].includes(data?.reportType) || !Array.isArray(data?.ids) || !data.ids.length) { + throw new BadRequestException('通用字段排序参数无效'); + } + if (new Set(data.ids).size !== data.ids.length) throw new BadRequestException('通用字段排序不能包含重复项'); + return this.prisma.$transaction(async (tx) => { + const existing = await tx.commonReportField.findMany({ + where: { reportType: data.reportType, status: 'active' }, + select: { id: true }, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], + }); + const existingIds = existing.map((field) => field.id); + if (existingIds.length !== data.ids.length || existingIds.some((id) => !data.ids.includes(id))) { + throw new BadRequestException('通用字段排序范围已变化,请刷新页面后重试'); + } + for (const [index, id] of data.ids.entries()) { + await tx.commonReportField.update({ where: { id }, data: { sortOrder: (index + 1) * 10 } }); + } + await tx.operationLog.create({ + data: { + userId: operatorId, + action: 'common_report_field.reorder', + resource: 'common_report_field', + detail: { reportType: data.reportType, before: existingIds, after: data.ids } as Prisma.InputJsonValue, + }, + }); + return tx.commonReportField.findMany({ + where: { reportType: data.reportType, status: 'active' }, + include: { drainageField: true }, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], + }); + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + } + deleteCommonReportField(id: string) { return this.prisma.commonReportField.delete({ where: { id } }); } diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index 4b26d84..dc958ca 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -511,6 +511,11 @@ describe('OperationsService', () => { todaySpendCents: 24000n, balanceCents: 1000000n, creditCents: 50000n, + }]).mockResolvedValueOnce([{ + segmentCount: 20n, + deliveredSegmentCount: 18n, + billedCents: 360n, + costCents: 216n, }]).mockResolvedValueOnce([ { hour: 9, submittedCount: 12n, successCount: 10n }, { hour: 10, submittedCount: 5n, successCount: 4n }, @@ -541,7 +546,15 @@ describe('OperationsService', () => { templates: 1, total: 5, }, - today: expect.objectContaining({ returnedCents: 10 }), + today: expect.objectContaining({ + returnedCents: 10, + segmentCount: 20, + deliveredSegmentCount: 18, + arrivalRate: 90, + billedCents: 360, + profitCents: 144, + profitRate: 40, + }), hourlySendTrend: expect.arrayContaining([ { hour: 9, label: '09:00', submittedCount: 12, successCount: 10 }, { hour: 10, label: '10:00', submittedCount: 5, successCount: 4 }, @@ -592,7 +605,11 @@ describe('OperationsService', () => { updatedAt: { gte: expect.any(Date) }, }, }); - const hourlyTrendQuery = prisma.$queryRaw.mock.calls[1]?.[0] as { sql?: string }; + const dashboardMetricsQuery = prisma.$queryRaw.mock.calls[1]?.[0] as { sql?: string }; + expect(dashboardMetricsQuery.sql).toContain('FROM "SmsMessageSegmentAudit" segment'); + expect(dashboardMetricsQuery.sql).toContain('message."billingUnits" * message."unitPrice"'); + expect(dashboardMetricsQuery.sql).toContain('submit."costUnitPrice"'); + const hourlyTrendQuery = prisma.$queryRaw.mock.calls[2]?.[0] as { sql?: string }; expect(hourlyTrendQuery.sql).toContain( `HOUR FROM (message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai'`, ); diff --git a/api/src/operations/queries/dashboard.queries.ts b/api/src/operations/queries/dashboard.queries.ts index bd586a6..3409928 100644 --- a/api/src/operations/queries/dashboard.queries.ts +++ b/api/src/operations/queries/dashboard.queries.ts @@ -32,6 +32,7 @@ async dashboard(query: { tenantId?: string }) { recentTasks, recentRecharges, enterpriseSpendRows, + todayBusinessMetricsRows, downstreamPendingCount, downstreamFailedCount, downstreamDeliveredCount, @@ -117,6 +118,67 @@ async dashboard(query: { tenantId?: string }) { GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents" ORDER BY "todaySpendCents" DESC, tenant.name ASC `), + this.prisma.$queryRaw>(Prisma.sql` + WITH segment_metrics AS ( + SELECT + COUNT(segment.id)::bigint AS "segmentCount", + COUNT(segment.id) FILTER (WHERE segment."receiptStatus" = 'delivered')::bigint AS "deliveredSegmentCount" + FROM "SmsMessageSegmentAudit" segment + JOIN "SmsMessageRecord" message ON message.id = segment."messageRecordId" + WHERE message."queuedAt" >= ${businessDay.startAt} + AND message."queuedAt" < ${businessDay.endAt} + AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null}) + ), + message_revenue AS ( + SELECT COALESCE(SUM( + CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' + THEN message."billingUnits" * message."unitPrice" ELSE 0 END + ), 0)::bigint AS "billedCents" + FROM "SmsMessageRecord" message + WHERE message."queuedAt" >= ${businessDay.startAt} + AND message."queuedAt" < ${businessDay.endAt} + AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null}) + ), + submit_cost AS ( + SELECT COALESCE(SUM(submit."costUnitPrice" * CASE + WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count + WHEN legacy_receipt.delivered THEN message."billingUnits" + ELSE 0 + END), 0)::bigint AS "costCents" + FROM "SmsSubmitRecord" submit + JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId" + LEFT JOIN LATERAL ( + SELECT + COUNT(*)::integer AS audit_count, + COUNT(*) FILTER (WHERE audit."receiptStatus" = 'delivered')::integer AS delivered_count + FROM "SmsMessageSegmentAudit" audit + WHERE audit."submitRecordId" = submit.id + ) segment_receipts ON TRUE + LEFT JOIN LATERAL ( + SELECT EXISTS ( + SELECT 1 FROM "SmsReceiptRecord" receipt + WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" + AND receipt."channelId" = submit."channelId" + AND receipt."receiptStatus" = 'delivered' + ) AS delivered + ) legacy_receipt ON TRUE + WHERE submit."submitStatus" = 'accepted' + AND message."queuedAt" >= ${businessDay.startAt} + AND message."queuedAt" < ${businessDay.endAt} + AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null}) + ) + SELECT + segment_metrics."segmentCount", + segment_metrics."deliveredSegmentCount", + message_revenue."billedCents", + submit_cost."costCents" + FROM segment_metrics, message_revenue, submit_cost + `), this.prisma.cmppDownstreamDelivery.count({ where: { tenantId: query.tenantId, status: 'pending' }, }), @@ -239,6 +301,12 @@ async dashboard(query: { tenantId?: string }) { `), ]); const todayTotals = summarizeMessageGroups(todayMessageGroups); + const todayBusinessMetrics = todayBusinessMetricsRows[0]; + const segmentCount = Number(todayBusinessMetrics?.segmentCount ?? 0); + const deliveredSegmentCount = Number(todayBusinessMetrics?.deliveredSegmentCount ?? 0); + const billedCents = moneyToNumber(todayBusinessMetrics?.billedCents); + const costCents = moneyToNumber(todayBusinessMetrics?.costCents); + const profitCents = billedCents - costCents; const hourlyRowsByHour = new Map(hourlySendRows.map((row) => [Number(row.hour), row])); // Always return all 24 Shanghai-time buckets so the line chart does not imply missing hours are missing data. const hourlySendTrend = Array.from({ length: 24 }, (_, hour) => { @@ -279,6 +347,12 @@ async dashboard(query: { tenantId?: string }) { spendCents: todayTotals.amountCents, returnedCents: moneyToNumber(transactionAggregate._sum.amountCents), billingUnits: todayTotals.billingUnits, + segmentCount, + deliveredSegmentCount, + arrivalRate: segmentCount > 0 ? Number(((deliveredSegmentCount / segmentCount) * 100).toFixed(1)) : 0, + billedCents, + profitCents, + profitRate: billedCents > 0 ? Number(((profitCents / billedCents) * 100).toFixed(1)) : 0, }, uplinkCount, billing: billingAggregate, diff --git a/api/src/report-materials/channel-export.service.ts b/api/src/report-materials/channel-export.service.ts index 8f9074d..9b6ecb4 100644 --- a/api/src/report-materials/channel-export.service.ts +++ b/api/src/report-materials/channel-export.service.ts @@ -331,7 +331,8 @@ export class ReportChannelExportService { }; } - async exportSingleMaterial(data: SingleReportMaterialDto, operatorId?: string) { + async exportSingleMaterial(data: SingleReportMaterialDto | undefined, operatorId?: string) { + if (!data) throw new BadRequestException('导出参数不能为空'); if ((data.reportType ?? 'signature') !== 'signature') throw new BadRequestException('首版仅支持单条签名报备资料导出'); const detail = await this.getSingleMaterialDetail(data); diff --git a/api/src/report-materials/report-materials.controller.ts b/api/src/report-materials/report-materials.controller.ts index 132dc3b..7e76d87 100644 --- a/api/src/report-materials/report-materials.controller.ts +++ b/api/src/report-materials/report-materials.controller.ts @@ -214,7 +214,7 @@ export class ReportMaterialsController { @Post('single-export') @RequireRecentAuthentication() async exportSingleMaterial( - @Body() body: SingleReportMaterialDto, + @Body() body: SingleReportMaterialDto | undefined, @CurrentSessionUserId() operatorId: string | undefined, @Res() response: DownloadResponse, ) { diff --git a/api/src/report-materials/report-materials.service.spec.ts b/api/src/report-materials/report-materials.service.spec.ts index b82c9a6..547a693 100644 --- a/api/src/report-materials/report-materials.service.spec.ts +++ b/api/src/report-materials/report-materials.service.spec.ts @@ -4,6 +4,11 @@ import { ReportMaterialsService } from './report-materials.service'; import { mappedCorePatchValue } from './report-materials.helpers'; describe('ReportMaterialsService', () => { + it('rejects an unparsed single-export body as a readable 400 instead of throwing a TypeError', async () => { + const service = new ReportMaterialsService({} as never, {} as never, {} as never); + await expect(service.exportSingleMaterial(undefined, 'operator-1')).rejects.toThrow('导出参数不能为空'); + }); + it('does not clear an existing core field when the import column is unmapped or blank', () => { expect(mappedCorePatchValue([], {}, 'purpose')).toBeUndefined(); expect( diff --git a/api/src/report-materials/report-materials.service.ts b/api/src/report-materials/report-materials.service.ts index 94aa713..e286f68 100644 --- a/api/src/report-materials/report-materials.service.ts +++ b/api/src/report-materials/report-materials.service.ts @@ -121,7 +121,7 @@ export class ReportMaterialsService { return this.channelExport.getSingleMaterialDetail(data); } - async exportSingleMaterial(data: SingleReportMaterialDto, operatorId?: string) { + async exportSingleMaterial(data: SingleReportMaterialDto | undefined, operatorId?: string) { return this.channelExport.exportSingleMaterial(data, operatorId); } } diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 2bbb9f0..9798d79 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5048,3 +5048,17 @@ npm run verify:phase8 | TC-HFQ-008 | 获取企业筛选选项 | 使用轻量options接口,仅返回id/name/code/status且后端过滤deleted,不返回企业认证材料 | | TC-HFQ-009 | 批量导入解析后切换“保存为可复用映射方案” | 控件使用通用按钮外观、图标和清晰选中态;aria-pressed随状态切换,选中后展示方案名称输入框 | | TC-HFQ-010 | 查看签名及引流两级操作按钮 | 报备状态、编辑、删除均使用通用sm按钮高度,删除按钮不再高低不齐 | + +## TC-ADMIN-ENHANCEMENT-20260904 运营看板与配置交互增强 + +| 用例ID | 场景 | 预期 | +| --- | --- | --- | +| TC-DASHBOARD-FRAGMENT-001 | 准备当天单分片、长短信多分片及成功/失败回执后打开运营看板,并用PostgreSQL独立聚合复核 | “今日消息分片数”取真实`SmsMessageSegmentAudit`行数;“今日到达率”等于成功到达分片数/发送总分片数,零分片时为0;原总体成功率仍按业务短信统计 | +| TC-DASHBOARD-PROFIT-001 | 准备当天成功短信、返还流水和不同客户价/通道成本快照后打开运营看板 | 今日返还取真实返还流水;今日计收按最终成功短信计费条数×客户价快照;成本按成功分片×通道成本快照,利润与利润率计算一致,收入为0时利润率为0 | +| TC-ENTERPRISE-SIGNATURE-HOVER-001 | 在企业签名列表悬停被截断的签名 | 可查看完整签名、企业、应用、用途、审核状态及创建/更新时间;不增加接口或使用静态数据 | +| TC-NAV-DENSITY-001 | 在桌面端和390px窄屏展开运营端长菜单 | 一级分组、二级菜单上下间距更紧凑,菜单仍可滚动,图标、文本、角标、选中态及点击导航完整可用 | +| TC-SIGNATURE-QUALITY-MATRIX-001 | 打开任一签名发送质量详情,切换整体统计与按引流切分 | 通道×运营商矩阵保留通道、运营商、引流状态、提交次数、成功率、平均到达时间和提交失败数;表头与通道列滚动时可辨识,成功率层级清晰;请求及后端接口不变 | +| TC-REPORT-FIELD-EDIT-001 | 编辑未引用字段的代码、名称、类型和说明后刷新页面 | 修改通过真实PUT接口写入PostgreSQL并记录操作日志,刷新后仍存在;非法代码、空名称、非法类型和重复代码由API拒绝 | +| TC-REPORT-FIELD-EDIT-002 | 编辑已被通道或通用字段引用的字段 | 页面锁定代码和类型,允许修改名称和说明;绕过前端直接修改代码或类型时API返回400,既有通道映射和历史资料不受影响 | +| TC-REPORT-FIELD-ORDER-001 | 分别在签名、引流通用字段中点击上移/下移并刷新 | 仅在当前资料类型内整体重排,真实`sortOrder`按新顺序持久化;并发导致集合变化时明确失败,不产生部分更新 | +| TC-REPORT-WORKBENCH-025 | 在通道报备明细点击单条导出,并分别模拟空请求体和缺失必填资料 | 正常请求以JSON Content-Type提交并下载真实XLSX;空请求体返回可读400而不是500;资料错误明确返回且不静默生成空文件,不改变报备状态或触发短信链路 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 8fd1735..76ec74a 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4425,3 +4425,16 @@ git diff --check - 预生产最终`.deployed-commit=dada0d978bb05d7046469c1ec77371ddb2fb03bc`。PostgreSQL、Redis、MinIO、API、四个Worker、Gateway、Security Agent、Nginx和Node Exporter共12项服务均`active`,七项应用服务`NRestarts=0 / Result=success`;内外`12026`、`sms.lisglo.com`、API、Gateway和Callback健康均通过,发布后error级journal为空。 - 服务重启后3条富泷供应商通道一度认证失败,供应商连接从9/9暂为6/9;旧版本回滚阶段同样出现,未修改通道配置或凭据。系统按既有计划自动重试,于20:56:52恢复`desired=9 / connected=9`并持续稳定;Gateway当前4条下游连接均在线且持续收发心跳。三条Redis Stream最终仍为`pending=0 / lag=0`,其entries-read仅随正常连接恢复、回执和协议日志前进;本轮未发送、补发、重投或重新入队短信。 - 工作站从预生产实际回读主资源`index-DHpz4GTj.js`、`index-rk3aXfEF.css`和状态记录分块`AdminReportRecordsPage-Bo0P2l0_.js`,均HTTP 200,长度分别为370340、236129和8658字节;登录后的状态记录真实交互沿用本地真实API/PostgreSQL/Redis验收结果,未在没有预生产运营登录态时冒充线上登录验收。 + +## 2026-09-04 运营看板、签名与报备配置增强(本地修改) + +- 运营看板新增今日消息分片数和按分片计算的今日到达率,并按当天实时短信、分片审计、客户价及通道成本快照展示今日返还、计收、利润和利润率;聚合直接查询真实业务表,不依赖尚未生成的T+1日报,不改变原业务短信成功率口径。 +- 企业签名列表的签名悬停信息补齐完整签名、企业、应用、用途、审核状态及创建/更新时间;导航分组和菜单项纵向间距收紧,保留滚动、折叠、角标和响应式逻辑。 +- 签名质量详情的通道×运营商矩阵仅调整前端展示:表头和通道列吸附、单元指标分层、成功率颜色提示更清晰;整体/引流切分、所有原字段和现有后端接口均保留。 +- 报备字段定义新增真实编辑接口和操作日志。未引用字段可调整代码、名称、类型和说明;已被通道或通用配置引用时只允许改名称和说明,前后端同时保护映射关键字段。通用字段可在签名/引流各自范围内原子调整顺序,集合变化时拒绝部分更新;未新增数据库字段或migration。 +- 通道报备明细单条导出的500根因定位为Blob POST传JSON字符串时未设置`Content-Type: application/json`,Nest未解析请求体。前端请求头已修复,后端为空请求增加可读400保护;导出仍读取真实字段、资料和文件,不改变报备状态,不发送、补发、重投或重新入队短信。 +- 测试环境`100.93.204.60`当前ICMP和22端口可达,但约642ms且现有密钥认证失败;本轮没有部署授权,也没有把登录后的测试环境页面冒充已验收。 +- 定向API 3套64项、定向前端3文件23项、全量API 52套605项、前端12文件61项通过;前后端TypeScript、Vite生产构建、依赖安全、部署契约、结构质量、包体积和`git diff --check`通过。Vite仅保留既有Chart分块超过500kB提示,入口gzip 107.68KiB,符合250KiB预算。 +- Browser插件本轮不可用,按前端调试流程使用工作区Playwright Chromium验证本地生产构建。1600×1000运营看板和质量矩阵、390×844运营看板页面身份、非空、错误层、控制台及横向溢出检查通过;菜单项上下内边距实测7px,矩阵完整保留通道提交、成功率、平均到达和提交失败信息。截图数据只用于布局验证,不冒充真实业务数据。 +- 本地真实PostgreSQL启动后,新看板聚合代码直接执行成功,返回当日零短信下分片数、到达率、计收、利润和利润率均为0,确认SQL语法、表关联和零分母处理可运行;本地API健康HTTP 200。Redis未启动时API持续输出连接拒绝,故未将该不完整本地栈作为页面功能验收,验证后已关闭本地API、预览和PostgreSQL。 +- 本轮只做本地提交,不推送、不部署,不访问或修改测试/预生产业务数据;不发送、补发、重投或重新入队短信,不修改余额、通道或客户配置。本节与源码、测试用例一并纳入本轮本地提交。 diff --git a/src/api/admin/governance.api.ts b/src/api/admin/governance.api.ts index a141ae1..639ccc4 100644 --- a/src/api/admin/governance.api.ts +++ b/src/api/admin/governance.api.ts @@ -194,6 +194,8 @@ export const adminGovernanceApi = { listDrainageFields: () => request('/admin/dictionaries/drainage-fields'), 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) }), + updateDrainageField: (id: string, body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; description?: string }) => + request(`/admin/dictionaries/drainage-fields/${id}`, { method: 'PUT', body: JSON.stringify(body) }), deleteDrainageField: (id: string) => request(`/admin/dictionaries/drainage-fields/${id}`, { method: 'DELETE' }), listDrainageDetectionRules: (query: { keyword?: string; status?: string } = {}) => request(withQuery('/admin/dictionaries/drainage-detection-rules', query)), @@ -208,6 +210,11 @@ export const adminGovernanceApi = { listCommonReportFields: () => request('/admin/dictionaries/common-report-fields'), createCommonReportField: (body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean; sortOrder?: number }) => request('/admin/dictionaries/common-report-fields', { method: 'POST', body: JSON.stringify(body) }), + reorderCommonReportFields: (body: { reportType: 'signature' | 'drainage'; ids: string[] }) => + request('/admin/dictionaries/common-report-fields/order', { + method: 'PUT', + body: JSON.stringify(body), + }), deleteCommonReportField: (id: string) => request(`/admin/dictionaries/common-report-fields/${id}`, { method: 'DELETE' }), updateCommonReportField: (id: string, body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean }) => request(`/admin/dictionaries/common-report-fields/${id}`, { method: 'PUT', body: JSON.stringify(body) }), diff --git a/src/api/core/httpClient.test.ts b/src/api/core/httpClient.test.ts index 088d919..9b85c89 100644 --- a/src/api/core/httpClient.test.ts +++ b/src/api/core/httpClient.test.ts @@ -140,6 +140,23 @@ describe('request tenant and error boundaries', () => { await expect(requestBlob('/client/file-fail')).rejects.toThrow('文件不存在'); }); + it('marks string-body blob requests as JSON so download endpoints receive their payload', async () => { + let receivedContentType = ''; + let receivedBody: unknown; + server.use( + http.post('http://localhost/api/admin/report-export', async ({ request: incoming }) => { + receivedContentType = incoming.headers.get('content-type') ?? ''; + receivedBody = await incoming.json(); + return new HttpResponse('xlsx-data', { status: 200 }); + }), + ); + await expect( + (await requestBlob('/admin/report-export', { method: 'POST', body: JSON.stringify({ reportType: 'signature' }) })).text(), + ).resolves.toBe('xlsx-data'); + expect(receivedContentType).toContain('application/json'); + expect(receivedBody).toEqual({ reportType: 'signature' }); + }); + it('retries blob downloads after recent authentication and supports admin tenant selection', async () => { writeSession({ portal: 'admin', diff --git a/src/api/core/httpClient.ts b/src/api/core/httpClient.ts index 373c88e..99f8aef 100644 --- a/src/api/core/httpClient.ts +++ b/src/api/core/httpClient.ts @@ -114,6 +114,9 @@ export async function request(path: string, options: RequestOptions = {}): Pr export async function requestBlob(path: string, options: RequestOptions = {}): Promise { const headers = new Headers(options.headers); + if (typeof options.body === 'string' && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } const portal = requestPortal(path); const session = portal ? readSession(portal) : null; if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user'); diff --git a/src/api/types/identity-config.ts b/src/api/types/identity-config.ts index f4caeed..c213ea0 100644 --- a/src/api/types/identity-config.ts +++ b/src/api/types/identity-config.ts @@ -152,6 +152,12 @@ export type DashboardResponse = { spendCents: number; returnedCents: number; billingUnits: number; + segmentCount: number; + deliveredSegmentCount: number; + arrivalRate: number; + billedCents: number; + profitCents: number; + profitRate: number; }; uplinkCount: number; billing: { _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } }; diff --git a/src/apps/admin/AdminAnalyticsPage.tsx b/src/apps/admin/AdminAnalyticsPage.tsx index 189454f..9e5c64c 100644 --- a/src/apps/admin/AdminAnalyticsPage.tsx +++ b/src/apps/admin/AdminAnalyticsPage.tsx @@ -607,12 +607,10 @@ function MatrixMetric({ metric, zeroWhenEmpty = false }: { metric?: SignatureCha if (zeroWhenEmpty && total === 0) return 0; return ( -
- {total.toLocaleString('zh-CN')} 次 - - {successRate.toFixed(1)}% - - {formatDuration(metric?.averageArrivalMs)} +
+
通道提交{total.toLocaleString('zh-CN')} 次
+
成功率{successRate.toFixed(1)}%
+
平均到达{formatDuration(metric?.averageArrivalMs)}
{(metric?.submitFailureCount ?? 0) > 0 ? 提交失败 {metric?.submitFailureCount} : null}
); diff --git a/src/apps/admin/AdminDrainageFieldsPage.test.tsx b/src/apps/admin/AdminDrainageFieldsPage.test.tsx index 569c80b..9d535ce 100644 --- a/src/apps/admin/AdminDrainageFieldsPage.test.tsx +++ b/src/apps/admin/AdminDrainageFieldsPage.test.tsx @@ -2,16 +2,22 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AdminDrainageFieldsPage } from './AdminDrainageFieldsPage'; -const { adminApi } = vi.hoisted(() => ({ adminApi: { listDrainageFields: vi.fn(), listCommonReportFields: vi.fn(), updateCommonReportField: vi.fn() } })); +const { adminApi } = vi.hoisted(() => ({ adminApi: { listDrainageFields: vi.fn(), listCommonReportFields: vi.fn(), reorderCommonReportFields: vi.fn(), updateCommonReportField: vi.fn(), updateDrainageField: vi.fn(), createDrainageField: vi.fn(), deleteDrainageField: vi.fn() } })); vi.mock('@/api/adminApi', () => ({ adminApi })); describe('common reporting configuration', () => { beforeEach(() => { Object.values(adminApi).forEach((method) => method.mockReset()); const field = { id: 'field-1', code: 'license', name: '主体证明', fieldType: 'file', status: 'active' }; - adminApi.listDrainageFields.mockResolvedValue([field]); - adminApi.listCommonReportFields.mockResolvedValue([{ id: 'common-1', drainageFieldId: 'field-1', reportType: 'signature', required: false, drainageField: field }]); + const secondField = { id: 'field-2', code: 'smsContent', name: '短信内容', fieldType: 'string', status: 'active' }; + adminApi.listDrainageFields.mockResolvedValue([field, secondField]); + adminApi.listCommonReportFields.mockResolvedValue([ + { id: 'common-1', drainageFieldId: 'field-1', reportType: 'signature', required: false, sortOrder: 10, drainageField: field }, + { id: 'common-2', drainageFieldId: 'field-2', reportType: 'signature', required: false, sortOrder: 20, drainageField: secondField }, + ]); + adminApi.reorderCommonReportFields.mockResolvedValue([]); adminApi.updateCommonReportField.mockResolvedValue({}); + adminApi.updateDrainageField.mockResolvedValue({}); }); it('opens existing values and saves the edited requirement with PUT API', async () => { render(); @@ -24,4 +30,31 @@ describe('common reporting configuration', () => { await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(adminApi.listCommonReportFields).toHaveBeenCalledTimes(2); }); + + it('moves a common field within its own material type through the reorder API', async () => { + render(); + fireEvent.click(await screen.findByRole('button', { name: '下移通用字段主体证明' })); + await waitFor(() => expect(adminApi.reorderCommonReportFields).toHaveBeenCalledWith({ + reportType: 'signature', + ids: ['common-2', 'common-1'], + })); + await waitFor(() => expect(adminApi.listCommonReportFields).toHaveBeenCalledTimes(2)); + }); + + it('edits a field definition and locks mapping keys for a referenced field', async () => { + adminApi.listDrainageFields.mockResolvedValueOnce([ + { id: 'field-1', code: 'license', name: '主体证明', fieldType: 'file', status: 'active', usageCount: 1, description: '旧说明' }, + ]); + render(); + fireEvent.click(await screen.findByRole('button', { name: '编辑主体证明' })); + expect(screen.getByRole('dialog')).toHaveTextContent('编辑报备字段'); + expect(screen.getByLabelText('字段代码')).toBeDisabled(); + expect(screen.getByRole('button', { name: '字段类型' })).toBeDisabled(); + fireEvent.change(screen.getByLabelText('字段名称'), { target: { value: '企业主体证明' } }); + fireEvent.change(screen.getByLabelText('描述'), { target: { value: '新说明' } }); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + await waitFor(() => expect(adminApi.updateDrainageField).toHaveBeenCalledWith('field-1', { + code: 'license', name: '企业主体证明', fieldType: 'file', description: '新说明', + })); + }); }); diff --git a/src/apps/admin/AdminDrainageFieldsPage.tsx b/src/apps/admin/AdminDrainageFieldsPage.tsx index cc45aa8..5373a24 100644 --- a/src/apps/admin/AdminDrainageFieldsPage.tsx +++ b/src/apps/admin/AdminDrainageFieldsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { Database, Edit3, FileCheck2, Link2, Plus, Search, Trash2 } from 'lucide-react'; +import { ArrowDown, ArrowUp, Database, Edit3, FileCheck2, Link2, Plus, Search, Trash2 } from 'lucide-react'; import { Breadcrumb, Button, Input, Modal, Select, Textarea, Tag } from '@/components/ui'; import { adminApi, type CommonReportField, type DictionaryItem } from '@/api/adminApi'; @@ -32,6 +32,8 @@ export function AdminDrainageFieldsPage() { const [type, setType] = useState('all'); const [appliedType, setAppliedType] = useState('all'); const [creating, setCreating] = useState(false); + const [editingField, setEditingField] = useState(null); + const [fieldSaving, setFieldSaving] = useState(false); const [code, setCode] = useState(''); const [name, setName] = useState(''); const [fieldType, setFieldType] = useState('string'); @@ -45,6 +47,7 @@ export function AdminDrainageFieldsPage() { const [commonReportType, setCommonReportType] = useState<'signature' | 'drainage'>('signature'); const [commonRequired, setCommonRequired] = useState(false); const [commonDeleteTarget, setCommonDeleteTarget] = useState(null); + const [commonOrderingId, setCommonOrderingId] = useState(); const codeError = code && !/^[A-Za-z0-9]+$/.test(code) ? '字段代码只能包含阿拉伯数字和英文大小写字母' : ''; function loadData() { @@ -70,17 +73,38 @@ export function AdminDrainageFieldsPage() { [appliedKeyword, appliedType, fields], ); - function createField() { - adminApi.createDrainageField({ code, name, fieldType, description, status: 'active' }) + function closeFieldModal() { + setCreating(false); + setEditingField(null); + setCode(''); + setName(''); + setFieldType('string'); + setDescription(''); + } + + function openFieldModal(field?: DrainageField) { + setEditingField(field ?? null); + setCode(field?.code ?? ''); + setName(field?.name ?? ''); + setFieldType((field?.fieldType as ReportFieldType | undefined) ?? 'string'); + setDescription(field?.description ?? ''); + setError(''); + setCreating(true); + } + + function saveField() { + if (fieldSaving) return; + setFieldSaving(true); + const request = editingField + ? adminApi.updateDrainageField(editingField.id, { code, name, fieldType, description }) + : adminApi.createDrainageField({ code, name, fieldType, description, status: 'active' }); + request .then(() => { - setCode(''); - setName(''); - setFieldType('string'); - setDescription(''); - setCreating(false); + closeFieldModal(); loadData(); }) - .catch((failure: Error) => setError(failure.message || '报备字段新增失败')); + .catch((failure: Error) => setError(failure.message || '报备字段保存失败')) + .finally(() => setFieldSaving(false)); } function deleteField() { @@ -130,6 +154,26 @@ export function AdminDrainageFieldsPage() { .catch((failure: Error) => setError(failure.message || '通用字段删除失败')); } + async function moveCommonField(field: CommonReportField, direction: -1 | 1) { + if (commonOrderingId) return; + const group = commonFields.filter((item) => item.reportType === field.reportType); + const currentIndex = group.findIndex((item) => item.id === field.id); + const targetIndex = currentIndex + direction; + if (currentIndex < 0 || targetIndex < 0 || targetIndex >= group.length) return; + const ids = group.map((item) => item.id); + [ids[currentIndex], ids[targetIndex]] = [ids[targetIndex], ids[currentIndex]]; + setCommonOrderingId(field.id); + setError(''); + try { + await adminApi.reorderCommonReportFields({ reportType: field.reportType, ids }); + loadData(); + } catch (failure) { + setError(failure instanceof Error ? failure.message : '通用字段顺序调整失败'); + } finally { + setCommonOrderingId(undefined); + } + } + const signatureCommon = commonFields.filter((field) => field.reportType === 'signature'); const drainageCommon = commonFields.filter((field) => field.reportType === 'drainage'); const referencedCount = fields.filter((field) => (field.usageCount ?? 0) > 0 || (field.commonUsageCount ?? 0) > 0).length; @@ -142,7 +186,7 @@ export function AdminDrainageFieldsPage() {

报备字段库

统一维护签名和引流信息的资料字段,并配置全平台通用报备要求。

- + {error ?

{error}

: null} @@ -170,8 +214,8 @@ export function AdminDrainageFieldsPage() {
- - + +
@@ -180,7 +224,7 @@ export function AdminDrainageFieldsPage() { {filteredFields.length ?
{filteredFields.map((field) => { const locked = (field.usageCount ?? 0) > 0 || (field.commonUsageCount ?? 0) > 0; return
-
{typeLabels[field.fieldType ?? ''] ?? field.fieldType}
+
{typeLabels[field.fieldType ?? ''] ?? field.fieldType}

{field.name ?? '-'}

{field.code}

{field.description || '暂无字段说明'}

通道引用 {field.usageCount ?? 0}通用配置 {field.commonUsageCount ?? 0}
; @@ -205,23 +249,25 @@ export function AdminDrainageFieldsPage() { - - + + )} - onClose={() => setCreating(false)} + onClose={closeFieldModal} open={creating} - title="添加报备字段" + title={editingField ? '编辑报备字段' : '添加报备字段'} >
- setCode(event.target.value)} placeholder="仅允许数字和英文字母" value={code} /> + 0 || (editingField.commonUsageCount ?? 0) > 0))} error={codeError} label="字段代码" onChange={(event) => setCode(event.target.value)} placeholder="仅允许数字和英文字母" value={code} /> setName(event.target.value)} value={name} />