From 99c8c7c68b5eb7cd0f63f0c70d376c257cc18c61 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Tue, 28 Jul 2026 20:28:47 +0800 Subject: [PATCH] feat: complete reporting and filing workflows --- .../migration.sql | 35 ++ api/prisma/schema.prisma | 29 ++ .../certification/certification.controller.ts | 9 +- .../certification.service.spec.ts | 20 +- .../certification/certification.service.ts | 28 +- api/src/channels/channels.controller.ts | 4 +- api/src/channels/channels.service.spec.ts | 20 +- api/src/channels/channels.service.ts | 20 +- .../dictionaries/dictionaries.service.spec.ts | 37 +- api/src/dictionaries/dictionaries.service.ts | 43 +- .../operations/admin-operations.controller.ts | 23 + api/src/operations/operations.service.spec.ts | 106 ++++ api/src/operations/operations.service.ts | 237 ++++++++- .../report-materials.controller.ts | 44 +- .../report-materials.service.spec.ts | 114 +++++ .../report-materials.service.ts | 396 +++++++++++++-- api/src/reports/reports.service.spec.ts | 13 + api/src/reports/reports.service.ts | 59 ++- .../risk-review/risk-review.service.spec.ts | 15 + api/src/risk-review/risk-review.service.ts | 2 + .../send-chain/admin-send-chain.controller.ts | 10 + api/src/send-chain/send-chain.service.spec.ts | 30 ++ api/src/send-chain/send-chain.service.ts | 33 ++ .../sms-config/admin-sms-config.controller.ts | 16 +- api/src/sms-config/sms-config.service.ts | 3 + api/src/tenants/tenants.service.spec.ts | 31 +- api/src/tenants/tenants.service.ts | 33 +- api/tools/seed-signature-quality-demo.ts | 259 ++++++++++ .../first-version-development-requirements.md | 34 +- docs/system-functional-test-cases.md | 46 +- docs/testing-progress.md | 62 +++ src/api/adminApi.ts | 162 ++++++- src/apps/admin/AdminAnalyticsPage.tsx | 407 +++++++++++++++- src/apps/admin/AdminCustomerFormPage.tsx | 10 +- src/apps/admin/AdminCustomersPage.tsx | 2 +- .../admin/AdminDownstreamDeliveriesPage.tsx | 15 +- .../AdminDownstreamRecoveryStatusesPage.tsx | 61 ++- src/apps/admin/AdminDrainageAuditPage.tsx | 10 +- src/apps/admin/AdminEnterpriseAuditPage.tsx | 6 + .../admin/AdminEnterpriseSignaturesPage.tsx | 26 +- src/apps/admin/AdminReportMaterialsPage.tsx | 278 +++++++++-- src/apps/admin/AdminReportTasksPage.tsx | 298 +++++------- src/apps/admin/AdminSignatureAuditPage.tsx | 10 +- src/apps/admin/AdminSmsAuditPage.tsx | 14 +- src/apps/admin/AdminSmsTaskProgressPage.tsx | 65 ++- src/apps/admin/AdminTemplateAuditPage.tsx | 18 +- src/apps/admin/ReportImportAuditPanel.tsx | 169 +++++++ src/apps/admin/ReportMaterialImportModal.tsx | 2 +- src/components/ui/AuditReviewInfo.tsx | 42 ++ src/components/ui/index.ts | 1 + src/layouts/AdminLayout.tsx | 4 +- src/styles/global.css | 455 ++++++++++++++++++ 52 files changed, 3490 insertions(+), 376 deletions(-) create mode 100644 api/prisma/migrations/20260728153000_stage_report_material_import_reviews/migration.sql create mode 100644 api/tools/seed-signature-quality-demo.ts create mode 100644 src/apps/admin/ReportImportAuditPanel.tsx create mode 100644 src/components/ui/AuditReviewInfo.tsx diff --git a/api/prisma/migrations/20260728153000_stage_report_material_import_reviews/migration.sql b/api/prisma/migrations/20260728153000_stage_report_material_import_reviews/migration.sql new file mode 100644 index 0000000..d9665bb --- /dev/null +++ b/api/prisma/migrations/20260728153000_stage_report_material_import_reviews/migration.sql @@ -0,0 +1,35 @@ +ALTER TABLE "ReportMaterialImportBatch" + ADD COLUMN "reviewedById" TEXT, + ADD COLUMN "reviewedAt" TIMESTAMP(3); + +CREATE TABLE "ReportMaterialImportItem" ( + "id" TEXT NOT NULL, + "batchId" TEXT NOT NULL, + "rowNumber" INTEGER NOT NULL, + "reportType" TEXT NOT NULL, + "operation" TEXT NOT NULL, + "targetId" TEXT, + "status" TEXT NOT NULL DEFAULT 'pending_review', + "payload" JSONB NOT NULL, + "originalSnapshot" JSONB, + "errorMessage" TEXT, + "reviewReason" TEXT, + "reviewedById" TEXT, + "reviewedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ReportMaterialImportItem_pkey" PRIMARY KEY ("id"), + CONSTRAINT "ReportMaterialImportItem_batchId_fkey" + FOREIGN KEY ("batchId") REFERENCES "ReportMaterialImportBatch"("id") + ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX "ReportMaterialImportItem_batchId_rowNumber_key" + ON "ReportMaterialImportItem"("batchId", "rowNumber"); +CREATE INDEX "ReportMaterialImportItem_batchId_status_idx" + ON "ReportMaterialImportItem"("batchId", "status"); +CREATE INDEX "ReportMaterialImportItem_reportType_status_createdAt_idx" + ON "ReportMaterialImportItem"("reportType", "status", "createdAt"); +CREATE INDEX "ReportMaterialImportItem_targetId_idx" + ON "ReportMaterialImportItem"("targetId"); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 8851487..02f43fe 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -1194,13 +1194,42 @@ model ReportMaterialImportBatch { rowCount Int @default(0) successCount Int @default(0) failedCount Int @default(0) + reviewedById String? + reviewedAt DateTime? createdAt DateTime @default(now()) completedAt DateTime? + items ReportMaterialImportItem[] + @@index([tenantId, createdAt]) @@index([status, createdAt]) } +model ReportMaterialImportItem { + id String @id @default(cuid()) + batchId String + rowNumber Int + reportType String + operation String + targetId String? + status String @default("pending_review") + payload Json + originalSnapshot Json? + errorMessage String? + reviewReason String? + reviewedById String? + reviewedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + batch ReportMaterialImportBatch @relation(fields: [batchId], references: [id], onDelete: Cascade) + + @@unique([batchId, rowNumber]) + @@index([batchId, status]) + @@index([reportType, status, createdAt]) + @@index([targetId]) +} + model ReportReceiptImport { id String @id @default(cuid()) taskId String diff --git a/api/src/certification/certification.controller.ts b/api/src/certification/certification.controller.ts index df4cd6c..fa9c676 100644 --- a/api/src/certification/certification.controller.ts +++ b/api/src/certification/certification.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; import { TenantId } from '../common/tenant-id.decorator'; import { CertificationService, ReviewCertificationDto, SubmitCertificationDto } from './certification.service'; @@ -35,12 +36,12 @@ export class AdminCertificationController { } @Post(':id/approve') - approve(@Param('id') id: string, @Body() body: ReviewCertificationDto) { - return this.certifications.approve(id, body); + approve(@Param('id') id: string, @Body() body: ReviewCertificationDto, @CurrentSessionUserId() reviewerId?: string) { + return this.certifications.approve(id, { ...body, reviewerId }); } @Post(':id/reject') - reject(@Param('id') id: string, @Body() body: ReviewCertificationDto) { - return this.certifications.reject(id, body); + reject(@Param('id') id: string, @Body() body: ReviewCertificationDto, @CurrentSessionUserId() reviewerId?: string) { + return this.certifications.reject(id, { ...body, reviewerId }); } } diff --git a/api/src/certification/certification.service.spec.ts b/api/src/certification/certification.service.spec.ts index 9692797..d5c41b9 100644 --- a/api/src/certification/certification.service.spec.ts +++ b/api/src/certification/certification.service.spec.ts @@ -8,12 +8,13 @@ function createPrismaMock() { }, enterpriseCertification: { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'cert-1', ...data })), - findMany: jest.fn(), + findMany: jest.fn().mockResolvedValue([]), findUnique: jest.fn().mockResolvedValue({ id: 'cert-1', tenantId: 'tenant-1', status: 'pending' }), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'cert-1', ...data })), }, user: { - findUnique: jest.fn().mockResolvedValue({ id: 'reviewer-1' }), + findUnique: jest.fn().mockResolvedValue({ id: 'reviewer-1', username: 'reviewer', displayName: '审核员' }), + findMany: jest.fn().mockResolvedValue([]), }, operationLog: { create: jest.fn(), @@ -22,6 +23,21 @@ function createPrismaMock() { } describe('CertificationService', () => { + it('returns the reviewer username with enterprise certification records', async () => { + const prisma = createPrismaMock(); + prisma.enterpriseCertification.findMany.mockResolvedValue([ + { id: 'cert-1', tenantId: 'tenant-1', reviewerId: 'reviewer-1' }, + ]); + prisma.user.findMany.mockResolvedValue([ + { id: 'reviewer-1', username: 'reviewer', displayName: '审核员' }, + ]); + const service = new CertificationService(prisma as never); + + await expect(service.list()).resolves.toEqual([ + expect.objectContaining({ reviewer: expect.objectContaining({ username: 'reviewer' }) }), + ]); + }); + it('submits certification and marks tenant pending', async () => { const prisma = createPrismaMock(); const service = new CertificationService(prisma as never); diff --git a/api/src/certification/certification.service.ts b/api/src/certification/certification.service.ts index 4c856e5..5570cd9 100644 --- a/api/src/certification/certification.service.ts +++ b/api/src/certification/certification.service.ts @@ -20,8 +20,8 @@ export interface ReviewCertificationDto { export class CertificationService { constructor(private readonly prisma: PrismaService) {} - list(tenantId?: string, status?: string, keyword?: string) { - return this.prisma.enterpriseCertification.findMany({ + async list(tenantId?: string, status?: string, keyword?: string) { + const records = await this.prisma.enterpriseCertification.findMany({ where: { tenantId, status: status && status !== 'all' ? status : undefined, @@ -36,6 +36,7 @@ export class CertificationService { include: { tenant: true }, orderBy: { createdAt: 'desc' }, }); + return this.attachReviewers(records); } async get(id: string) { @@ -46,7 +47,7 @@ export class CertificationService { if (!certification) { throw new NotFoundException('Enterprise certification not found'); } - return certification; + return (await this.attachReviewers([certification]))[0]; } async submit(data: SubmitCertificationDto) { @@ -97,8 +98,12 @@ export class CertificationService { if (!certification) { throw new NotFoundException('Enterprise certification not found'); } + let reviewer: { id: string; username: string; displayName: string } | null = null; if (data.reviewerId) { - const reviewer = await this.prisma.user.findUnique({ where: { id: data.reviewerId }, select: { id: true } }); + reviewer = await this.prisma.user.findUnique({ + where: { id: data.reviewerId }, + select: { id: true, username: true, displayName: true }, + }); if (!reviewer) { throw new BadRequestException('reviewerId does not reference an existing user'); } @@ -126,6 +131,19 @@ export class CertificationService { detail: { reason: data.reason } as Prisma.InputJsonValue, }, }); - return updated; + return { ...updated, reviewer }; + } + + private async attachReviewers(records: T[]) { + const reviewerIds = [...new Set(records.map((record) => record.reviewerId).filter((id): id is string => Boolean(id)))]; + const reviewers = reviewerIds.length ? await this.prisma.user.findMany({ + where: { id: { in: reviewerIds } }, + select: { id: true, username: true, displayName: true }, + }) : []; + const reviewerById = new Map(reviewers.map((reviewer) => [reviewer.id, reviewer])); + return records.map((record) => ({ + ...record, + reviewer: record.reviewerId ? reviewerById.get(record.reviewerId) ?? null : null, + })); } } diff --git a/api/src/channels/channels.controller.ts b/api/src/channels/channels.controller.ts index 7454729..eb19bb2 100644 --- a/api/src/channels/channels.controller.ts +++ b/api/src/channels/channels.controller.ts @@ -178,8 +178,8 @@ export class ChannelsController { @Post('report-tasks/status-change') @RequireRecentAuthentication() - changeReportTaskStatuses(@Body() body: ChangeReportTaskStatusesDto) { - return this.channels.changeReportTaskStatuses(body); + changeReportTaskStatuses(@Body() body: ChangeReportTaskStatusesDto, @CurrentSessionUserId() operatorId?: string) { + return this.channels.changeReportTaskStatuses({ ...body, operatorId }); } @Post('report-tasks/:id/export') diff --git a/api/src/channels/channels.service.spec.ts b/api/src/channels/channels.service.spec.ts index 39395e4..623ab8d 100644 --- a/api/src/channels/channels.service.spec.ts +++ b/api/src/channels/channels.service.spec.ts @@ -213,8 +213,24 @@ describe('ChannelsService', () => { await service.listReportTasks(undefined, undefined, 'channel-1'); expect(prisma.channelSignatureReportTask.findMany).toHaveBeenCalledWith({ - where: { tenantId: undefined, status: undefined, channelId: 'channel-1', reportType: undefined }, - include: { signature: true, channel: true, drainageInfo: true }, + where: { + tenantId: undefined, + status: undefined, + channelId: 'channel-1', + reportType: undefined, + signature: { auditStatus: { not: 'deleted' } }, + }, + include: { + signature: { include: { tenant: true, application: true } }, + channel: true, + drainageInfo: true, + exportItems: { + include: { exportFile: true, batchItem: { include: { batch: true } } }, + orderBy: { id: 'desc' }, + take: 1, + }, + records: { orderBy: { createdAt: 'desc' }, take: 20 }, + }, orderBy: { createdAt: 'desc' }, }); }); diff --git a/api/src/channels/channels.service.ts b/api/src/channels/channels.service.ts index 948fa05..5581397 100644 --- a/api/src/channels/channels.service.ts +++ b/api/src/channels/channels.service.ts @@ -1155,8 +1155,24 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { async listReportTasks(tenantId?: string, status?: string, channelId?: string, reportType?: string) { const tasks = await this.prisma.channelSignatureReportTask.findMany({ - where: { tenantId, status, channelId, reportType }, - include: { signature: true, channel: true, drainageInfo: true }, + where: { + tenantId, + status, + channelId, + reportType, + signature: { auditStatus: { not: 'deleted' } }, + }, + include: { + signature: { include: { tenant: true, application: true } }, + channel: true, + drainageInfo: true, + exportItems: { + include: { exportFile: true, batchItem: { include: { batch: true } } }, + orderBy: { id: 'desc' }, + take: 1, + }, + records: { orderBy: { createdAt: 'desc' }, take: 20 }, + }, orderBy: { createdAt: 'desc' }, }); if (tasks.length === 0) { diff --git a/api/src/dictionaries/dictionaries.service.spec.ts b/api/src/dictionaries/dictionaries.service.spec.ts index 7ba287e..4bbbc6e 100644 --- a/api/src/dictionaries/dictionaries.service.spec.ts +++ b/api/src/dictionaries/dictionaries.service.spec.ts @@ -34,7 +34,9 @@ function createPrismaMock() { delete: jest.fn().mockResolvedValue({ id: 'field-1' }), }, channelReportField: { + findMany: jest.fn().mockResolvedValue([]), count: jest.fn().mockResolvedValue(0), + deleteMany: jest.fn(), }, commonReportField: { findMany: jest.fn().mockResolvedValue([]), @@ -49,6 +51,7 @@ function createPrismaMock() { operationLog: { create: jest.fn(), }, + $transaction: jest.fn(), }; } @@ -64,13 +67,45 @@ describe('DictionariesService', () => { it('returns drainage field usage counts and blocks deleting fields used by channels', async () => { const prisma = createPrismaMock(); - prisma.drainageField.findMany.mockResolvedValue([{ id: 'field-1', code: 'license', _count: { channelReportFields: 2, commonReportFields: 0 } }]); + prisma.drainageField.findMany.mockResolvedValue([{ id: 'field-1', code: 'license' }]); + prisma.channelReportField.findMany.mockResolvedValue([ + { drainageFieldId: 'field-1', channelId: 'channel-1' }, + { drainageFieldId: 'field-1', channelId: 'channel-1' }, + { drainageFieldId: 'field-1', channelId: 'channel-2' }, + ]); prisma.channelReportField.count.mockResolvedValue(2); const service = new DictionariesService(prisma as never); await expect(service.listDrainageFields()).resolves.toEqual([{ id: 'field-1', code: 'license', usageCount: 2, commonUsageCount: 0 }]); await expect(service.deleteDrainageField('field-1')).rejects.toThrow('不能删除'); expect(prisma.drainageField.delete).not.toHaveBeenCalled(); + expect(prisma.channelReportField.findMany).toHaveBeenCalledWith({ + where: { + drainageFieldId: { in: ['field-1'] }, + channel: { status: { not: 'deleted' } }, + }, + select: { drainageFieldId: true, channelId: true }, + }); + }); + + it('ignores deleted-channel references and removes those stale mappings when deleting the field', async () => { + const prisma = createPrismaMock(); + const tx = { + channelReportField: { deleteMany: jest.fn().mockResolvedValue({ count: 1 }) }, + drainageField: { delete: jest.fn().mockResolvedValue({ id: 'field-1' }) }, + }; + prisma.$transaction.mockImplementation((callback) => callback(tx)); + const service = new DictionariesService(prisma as never); + + await expect(service.deleteDrainageField('field-1')).resolves.toEqual({ id: 'field-1' }); + + expect(prisma.channelReportField.count).toHaveBeenCalledWith({ + where: { drainageFieldId: 'field-1', channel: { status: { not: 'deleted' } } }, + }); + expect(tx.channelReportField.deleteMany).toHaveBeenCalledWith({ + where: { drainageFieldId: 'field-1', channel: { status: 'deleted' } }, + }); + expect(tx.drainageField.delete).toHaveBeenCalledWith({ where: { id: 'field-1' } }); }); it('creates and deletes real common signature and drainage field configurations', async () => { diff --git a/api/src/dictionaries/dictionaries.service.ts b/api/src/dictionaries/dictionaries.service.ts index 0e75ec0..f6289a0 100644 --- a/api/src/dictionaries/dictionaries.service.ts +++ b/api/src/dictionaries/dictionaries.service.ts @@ -294,13 +294,37 @@ export class DictionariesService { async listDrainageFields() { const fields = await this.prisma.drainageField.findMany({ - include: { _count: { select: { channelReportFields: true, commonReportFields: true } } }, orderBy: { createdAt: 'desc' }, }); - return fields.map(({ _count, ...field }) => ({ + const fieldIds = fields.map((field) => field.id); + const [channelReferences, commonReferences] = fieldIds.length ? await Promise.all([ + this.prisma.channelReportField.findMany({ + where: { + drainageFieldId: { in: fieldIds }, + channel: { status: { not: 'deleted' } }, + }, + select: { drainageFieldId: true, channelId: true }, + }), + this.prisma.commonReportField.findMany({ + where: { drainageFieldId: { in: fieldIds } }, + select: { drainageFieldId: true }, + }), + ]) : [[], []]; + const channelsByField = new Map>(); + for (const reference of channelReferences) { + if (!reference.drainageFieldId) continue; + const channelIds = channelsByField.get(reference.drainageFieldId) ?? new Set(); + channelIds.add(reference.channelId); + channelsByField.set(reference.drainageFieldId, channelIds); + } + const commonCountByField = new Map(); + for (const reference of commonReferences) { + commonCountByField.set(reference.drainageFieldId, (commonCountByField.get(reference.drainageFieldId) ?? 0) + 1); + } + return fields.map((field) => ({ ...field, - usageCount: _count.channelReportFields, - commonUsageCount: _count.commonReportFields, + usageCount: channelsByField.get(field.id)?.size ?? 0, + commonUsageCount: commonCountByField.get(field.id) ?? 0, })); } @@ -326,13 +350,20 @@ export class DictionariesService { async deleteDrainageField(id: string) { const [usageCount, commonUsageCount] = await Promise.all([ - this.prisma.channelReportField.count({ where: { drainageFieldId: id } }), + this.prisma.channelReportField.count({ + where: { drainageFieldId: id, channel: { status: { not: 'deleted' } } }, + }), this.prisma.commonReportField.count({ where: { drainageFieldId: id } }), ]); if (usageCount > 0 || commonUsageCount > 0) { throw new BadRequestException(`该字段已被 ${usageCount} 个通道和 ${commonUsageCount} 个通用配置使用,不能删除`); } - return this.prisma.drainageField.delete({ where: { id } }); + return this.prisma.$transaction(async (tx) => { + await tx.channelReportField.deleteMany({ + where: { drainageFieldId: id, channel: { status: 'deleted' } }, + }); + return tx.drainageField.delete({ where: { id } }); + }); } listCommonReportFields() { diff --git a/api/src/operations/admin-operations.controller.ts b/api/src/operations/admin-operations.controller.ts index 9244c1b..88376b0 100644 --- a/api/src/operations/admin-operations.controller.ts +++ b/api/src/operations/admin-operations.controller.ts @@ -99,6 +99,21 @@ export class AdminOperationsController { return this.operations.sendQuality(date); } + @Get('signature-quality') + signatureQuality( + @Query('date') date?: string, + @Query('keyword') keyword?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.operations.signatureQuality({ + date, + keyword, + page: Number(page), + pageSize: Number(pageSize), + }); + } + @Get('audit-logs') auditLogs( @Query('tenantId') tenantId?: string, @@ -211,6 +226,8 @@ export class AdminOperationsController { @Query('state') state?: string, @Query('failureCategory') failureCategory?: string, @Query('keyword') keyword?: string, + @Query('updatedAtFrom') updatedAtFrom?: string, + @Query('updatedAtTo') updatedAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { @@ -220,6 +237,8 @@ export class AdminOperationsController { state, failureCategory, keyword, + updatedAtFrom, + updatedAtTo, page: Number(page), pageSize: Number(pageSize), }); @@ -232,6 +251,8 @@ export class AdminOperationsController { @Query('state') state: string | undefined, @Query('failureCategory') failureCategory: string | undefined, @Query('keyword') keyword: string | undefined, + @Query('updatedAtFrom') updatedAtFrom: string | undefined, + @Query('updatedAtTo') updatedAtTo: string | undefined, @Res() response: DownloadResponse, ) { const exported = await this.operations.exportDownstreamRecoveryStatuses({ @@ -240,6 +261,8 @@ export class AdminOperationsController { state, failureCategory, keyword, + updatedAtFrom, + updatedAtTo, }); response.setHeader('Content-Type', 'text/csv; charset=utf-8'); response.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(exported.fileName)}`); diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index a6f15ea..696a38c 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -521,6 +521,96 @@ describe('OperationsService', () => { await expect(service.sendQuality('2026-02-31')).rejects.toThrow('统计日期无效'); }); + it('returns paged registered-signature quality with channel and carrier breakdowns', async () => { + const prisma = createPrismaMock(); + prisma.$queryRaw + .mockResolvedValueOnce([{ + signatureId: 'signature-1', + signatureName: '【测试签名】', + tenantId: 'tenant-1', + tenantName: '租户A', + applicationNames: '通知应用、营销应用', + total: 5, + acceptedCount: 4, + submitFailureCount: 1, + successCount: 3, + unknownCount: 1, + failureCount: 0, + successRate: 75, + averageArrivalMs: 1200, + rowCount: 12, + }]) + .mockResolvedValueOnce([ + { + signatureId: 'signature-1', + channelId: 'channel-1', + channelName: '通道一', + carrier: 'mobile', + total: 4, + acceptedCount: 4, + submitFailureCount: 0, + successCount: 3, + unknownCount: 1, + failureCount: 0, + successRate: 75, + averageArrivalMs: 1200, + }, + { + signatureId: 'signature-1', + channelId: 'channel-2', + channelName: '通道二', + carrier: 'telecom', + total: 2, + acceptedCount: 1, + submitFailureCount: 1, + successCount: 1, + unknownCount: 0, + failureCount: 0, + successRate: 100, + averageArrivalMs: 1800, + }, + ]); + const service = new OperationsService(prisma as never); + + await expect(service.signatureQuality({ + date: '2026-07-24', + keyword: '测试', + page: 2, + pageSize: 5, + })).resolves.toEqual({ + date: '2026-07-24', + items: [expect.objectContaining({ + signatureId: 'signature-1', + signatureName: '【测试签名】', + total: 5, + channelSubmitTotal: 6, + breakdowns: [ + expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', total: 4 }), + expect.objectContaining({ channelId: 'channel-2', carrier: 'telecom', total: 2 }), + ], + })], + total: 12, + page: 2, + pageSize: 5, + }); + expect(prisma.$queryRaw).toHaveBeenCalledTimes(2); + }); + + it('does not query channel details when the selected date has no registered signatures', async () => { + const prisma = createPrismaMock(); + prisma.$queryRaw.mockResolvedValueOnce([]); + const service = new OperationsService(prisma as never); + + await expect(service.signatureQuality({ date: '2026-07-24' })).resolves.toEqual({ + date: '2026-07-24', + items: [], + total: 0, + page: 1, + pageSize: 10, + }); + expect(prisma.$queryRaw).toHaveBeenCalledTimes(1); + }); + it('returns trace details and reconciliation diffs', async () => { const prisma = createPrismaMock(); const service = new OperationsService(prisma as never); @@ -813,6 +903,8 @@ describe('OperationsService', () => { state: 'waiting_connection', failureCategory: 'client_disconnected', keyword: '100001', + updatedAtFrom: '2026-07-02', + updatedAtTo: '2026-07-08', page: 1, pageSize: 10, })).resolves.toEqual({ @@ -832,6 +924,14 @@ describe('OperationsService', () => { ], }, }); + expect(prisma.gatewayDownstreamRecoveryStatus.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + updatedAt: { + gte: new Date('2026-07-01T16:00:00.000Z'), + lte: new Date('2026-07-08T15:59:59.999Z'), + }, + }), + })); }); it('returns downstream recovery status detail', async () => { @@ -860,6 +960,8 @@ describe('OperationsService', () => { tenantId: 'tenant-1', state: 'waiting_connection', keyword: '100001', + updatedAtFrom: '2026-07-02', + updatedAtTo: '2026-07-08', })).resolves.toEqual(expect.objectContaining({ total: 1, fileName: expect.stringMatching(/^gateway-downstream-recovery-statuses-\d{8}-\d{6}\.csv$/), @@ -868,6 +970,10 @@ describe('OperationsService', () => { expect(prisma.gatewayDownstreamRecoveryStatus.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ failureCategory: undefined, + updatedAt: { + gte: new Date('2026-07-01T16:00:00.000Z'), + lte: new Date('2026-07-08T15:59:59.999Z'), + }, }), })); }); diff --git a/api/src/operations/operations.service.ts b/api/src/operations/operations.service.ts index 2e2f22d..ec023b4 100644 --- a/api/src/operations/operations.service.ts +++ b/api/src/operations/operations.service.ts @@ -69,6 +69,8 @@ export interface DownstreamRecoveryStatusQuery { state?: string; failureCategory?: string; keyword?: string; + updatedAtFrom?: string; + updatedAtTo?: string; page?: number; pageSize?: number; } @@ -78,6 +80,13 @@ export interface MessageSegmentAuditQuery { messageRecordId?: string; } +export interface SignatureQualityQuery { + date?: string; + keyword?: string; + page?: number; + pageSize?: number; +} + @Injectable() export class OperationsService { constructor(private readonly prisma: PrismaService) {} @@ -274,7 +283,8 @@ export class OperationsService { ON billing."tenantId" = tenant.id AND billing."createdAt" >= ${businessDay.startAt} AND billing."createdAt" < ${businessDay.endAt} - WHERE (${query.tenantId ?? null}::text IS NULL OR tenant.id = ${query.tenantId ?? null}) + WHERE tenant.status <> 'deleted' + AND (${query.tenantId ?? null}::text IS NULL OR tenant.id = ${query.tenantId ?? null}) GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents" ORDER BY "todaySpendCents" DESC, tenant.name ASC `), @@ -675,6 +685,228 @@ export class OperationsService { return { date: day.key, summary, channels, signatures, applications }; } + async signatureQuality(query: SignatureQualityQuery) { + const day = qualityBusinessDay(query.date); + const page = positiveInteger(query.page, 1); + const pageSize = Math.min(50, positiveInteger(query.pageSize, 10)); + const keyword = query.keyword?.trim() || null; + const keywordPattern = keyword ? `%${keyword}%` : null; + const summaries = await this.prisma.$queryRaw>(Prisma.sql` + WITH base AS ( + SELECT + message."signatureId" AS signature_id, + message."applicationId" AS application_id, + message.status, + message."submitStatus" AS submit_status, + message."receiptStatus" AS receipt_status, + CASE + WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered') + AND message."submittedAt" IS NOT NULL + AND message."deliveredAt" >= message."submittedAt" + THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000 + END AS arrival_ms + FROM "SmsMessageRecord" message + WHERE message."signatureId" IS NOT NULL + AND message."queuedAt" >= ${day.startAt} + AND message."queuedAt" < ${day.endAt} + ) + SELECT + signature.id AS "signatureId", + signature.name AS "signatureName", + tenant.id AS "tenantId", + tenant.name AS "tenantName", + STRING_AGG(DISTINCT application.name, '、') FILTER (WHERE application.name IS NOT NULL) AS "applicationNames", + COUNT(*)::integer AS total, + COUNT(*) FILTER ( + WHERE COALESCE(base.status, '') <> 'submit_failed' + AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') + )::integer AS "acceptedCount", + COUNT(*) FILTER ( + WHERE base.status = 'submit_failed' + OR base.submit_status IN ('rejected', 'timeout') + )::integer AS "submitFailureCount", + COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount", + COUNT(*) FILTER ( + WHERE COALESCE(base.status, '') <> 'submit_failed' + AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') + AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) + AND NOT (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) + )::integer AS "unknownCount", + COUNT(*) FILTER ( + WHERE COALESCE(base.status, '') <> 'submit_failed' + AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') + AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) + AND (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) + )::integer AS "failureCount", + CASE + WHEN COUNT(*) FILTER ( + WHERE COALESCE(base.status, '') <> 'submit_failed' + AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') + ) = 0 THEN 0 + ELSE ROUND( + COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered') + * 100.0 + / COUNT(*) FILTER ( + WHERE COALESCE(base.status, '') <> 'submit_failed' + AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') + ), + 1 + )::double precision + END AS "successRate", + ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL))::integer AS "averageArrivalMs", + COUNT(*) OVER()::integer AS "rowCount" + FROM base + JOIN "SmsSignature" signature ON signature.id = base.signature_id + JOIN "Tenant" tenant ON tenant.id = signature."tenantId" + LEFT JOIN "SmsApplication" application ON application.id = base.application_id + WHERE ( + ${keyword}::text IS NULL + OR signature.name ILIKE ${keywordPattern} + OR tenant.name ILIKE ${keywordPattern} + OR application.name ILIKE ${keywordPattern} + ) + GROUP BY signature.id, signature.name, tenant.id, tenant.name + ORDER BY total DESC, signature.name + LIMIT ${pageSize} + OFFSET ${(page - 1) * pageSize} + `); + const signatureIds = summaries.map((item) => item.signatureId); + const breakdowns = signatureIds.length === 0 + ? [] + : await this.prisma.$queryRaw>(Prisma.sql` + WITH base AS ( + SELECT + message."signatureId" AS signature_id, + submit."channelId" AS channel_id, + channel.name AS channel_name, + COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier, + submit."submitStatus" AS submit_status, + receipt."deliveredAt" AS delivered_at, + failed_receipt."failedAt" AS failed_at, + COALESCE(segment_summary.segment_count, 0) AS segment_count, + COALESCE(segment_summary.delivered_count, 0) AS segment_delivered_count, + COALESCE(segment_summary.failure_count, 0) AS segment_failure_count, + CASE + WHEN segment_summary.segment_count > 0 + AND segment_summary.delivered_count = segment_summary.segment_count + AND segment_summary.completed_at >= COALESCE(submit."submittedAt", submit."createdAt") + THEN EXTRACT(EPOCH FROM (segment_summary.completed_at - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 + WHEN segment_summary.segment_count = 0 + AND receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt") + THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 + END AS arrival_ms + FROM "SmsSubmitRecord" submit + JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId" + JOIN "SmsChannel" channel ON channel.id = submit."channelId" + LEFT JOIN LATERAL ( + SELECT + COUNT(*)::integer AS segment_count, + COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count, + COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count, + MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at + FROM "SmsMessageSegmentAudit" segment + WHERE segment."submitRecordId" = submit.id + ) segment_summary ON TRUE + LEFT JOIN LATERAL ( + SELECT MIN(receipt."deliveredAt") AS "deliveredAt" + FROM "SmsReceiptRecord" receipt + WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" + AND receipt."channelId" = submit."channelId" + AND receipt."receiptStatus" = 'delivered' + ) receipt ON TRUE + LEFT JOIN LATERAL ( + SELECT MIN(receipt."deliveredAt") AS "failedAt" + FROM "SmsReceiptRecord" receipt + WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" + AND receipt."channelId" = submit."channelId" + AND receipt."receiptStatus" = 'undelivered' + ) failed_receipt ON TRUE + WHERE message."signatureId" IN (${Prisma.join(signatureIds)}) + AND submit."submitStatus" IN ('accepted', 'rejected', 'timeout') + AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt} + AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt} + ), classified AS ( + SELECT + *, + CASE + WHEN submit_status <> 'accepted' THEN 'submit_failed' + WHEN segment_count > 0 AND segment_failure_count > 0 THEN 'failure' + WHEN segment_count > 0 AND segment_delivered_count = segment_count THEN 'success' + WHEN segment_count = 0 AND failed_at IS NOT NULL THEN 'failure' + WHEN segment_count = 0 AND delivered_at IS NOT NULL THEN 'success' + ELSE 'unknown' + END AS delivery_status + FROM base + ) + SELECT + signature_id AS "signatureId", + channel_id AS "channelId", + MAX(channel_name) AS "channelName", + carrier, + COUNT(*)::integer AS total, + COUNT(*) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedCount", + COUNT(*) FILTER (WHERE delivery_status = 'submit_failed')::integer AS "submitFailureCount", + COUNT(*) FILTER (WHERE delivery_status = 'success')::integer AS "successCount", + COUNT(*) FILTER (WHERE delivery_status = 'unknown')::integer AS "unknownCount", + COUNT(*) FILTER (WHERE delivery_status = 'failure')::integer AS "failureCount", + CASE + WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 + ELSE ROUND( + COUNT(*) FILTER (WHERE delivery_status = 'success') + * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), + 1 + )::double precision + END AS "successRate", + ROUND(AVG(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL))::integer AS "averageArrivalMs" + FROM classified + GROUP BY signature_id, channel_id, carrier + ORDER BY signature_id, COUNT(*) DESC, channel_id, carrier + `); + const items = summaries.map(({ rowCount: _rowCount, ...summary }) => { + const signatureBreakdowns = breakdowns.filter((item) => item.signatureId === summary.signatureId); + return { + ...summary, + channelSubmitTotal: signatureBreakdowns.reduce((sum, item) => sum + item.total, 0), + breakdowns: signatureBreakdowns, + }; + }); + return { + date: day.key, + items, + total: summaries[0]?.rowCount ?? 0, + page, + pageSize, + }; + } + async auditLogs(query: { tenantId?: string; userId?: string; page?: number; pageSize?: number }) { const page = positiveInteger(query.page, 1); const pageSize = Math.min(100, positiveInteger(query.pageSize, 20)); @@ -1420,11 +1652,14 @@ function parseDateBoundary(value?: string, endOfDay = false) { } function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQuery) { + const updatedAtFrom = parseDateBoundary(query.updatedAtFrom, false); + const updatedAtTo = parseDateBoundary(query.updatedAtTo, true); return { tenantId: query.tenantId, applicationId: query.applicationId, state: query.state && query.state !== 'all' ? query.state : undefined, failureCategory: query.failureCategory && query.failureCategory !== 'all' ? query.failureCategory : undefined, + updatedAt: updatedAtFrom || updatedAtTo ? { gte: updatedAtFrom, lte: updatedAtTo } : undefined, OR: query.keyword ? [ { account: { contains: query.keyword } }, { gatewayInstanceId: { contains: query.keyword } }, diff --git a/api/src/report-materials/report-materials.controller.ts b/api/src/report-materials/report-materials.controller.ts index 4403b84..6e18ca5 100644 --- a/api/src/report-materials/report-materials.controller.ts +++ b/api/src/report-materials/report-materials.controller.ts @@ -3,7 +3,7 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { ApiTags } from '@nestjs/swagger'; import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator'; import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; -import { CreateImportProfileDto, CreateReportBatchDto, ImportCommitDto, ReportMaterialsService } from './report-materials.service'; +import { CreateImportProfileDto, CreateReportBatchDto, ImportCommitDto, ReportMaterialsService, ReviewImportItemsDto } from './report-materials.service'; type UploadedWorkbook = { originalname: string; mimetype: string; size: number; buffer: Buffer }; type DownloadResponse = { setHeader(name: string, value: string): void; send(content: Buffer): void }; @@ -14,8 +14,17 @@ export class ReportMaterialsController { constructor(private readonly service: ReportMaterialsService) {} @Get('pending') - listPending(@Query('reportType') reportType?: 'signature' | 'drainage', @Query('tenantId') tenantId?: string, @Query('applicationId') applicationId?: string) { - return this.service.listPending({ reportType, tenantId, applicationId }); + listPending( + @Query('reportType') reportType?: 'signature' | 'drainage', + @Query('tenantId') tenantId?: string, + @Query('applicationId') applicationId?: string, + @Query('keyword') keyword?: string, + @Query('startAt') startAt?: string, + @Query('endAt') endAt?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.service.listPending({ reportType, tenantId, applicationId, keyword, startAt, endAt, page: Number(page), pageSize: Number(pageSize) }); } @Get('templates/:reportType') @@ -62,9 +71,34 @@ export class ReportMaterialsController { return this.service.commitImport(id, { ...body, operatorId }); } + @Get('imports/review-batches') + listImportReviewBatches( + @Query('reportType') reportType?: 'signature' | 'drainage', + @Query('status') status?: string, + @Query('keyword') keyword?: string, + @Query('startAt') startAt?: string, + @Query('endAt') endAt?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.service.listImportReviewBatches({ reportType, status, keyword, startAt, endAt, page: Number(page), pageSize: Number(pageSize) }); + } + + @Post('imports/:id/review') + @RequireRecentAuthentication() + reviewImportItems(@Param('id') id: string, @Body() body: ReviewImportItemsDto, @CurrentSessionUserId() reviewerId?: string) { + return this.service.reviewImportItems(id, { ...body, reviewerId }); + } + @Get('batches') - listBatches() { - return this.service.listBatches(); + listBatches( + @Query('keyword') keyword?: string, + @Query('startAt') startAt?: string, + @Query('endAt') endAt?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.service.listBatches({ keyword, startAt, endAt, page: Number(page), pageSize: Number(pageSize) }); } @Post('batches/preflight') diff --git a/api/src/report-materials/report-materials.service.spec.ts b/api/src/report-materials/report-materials.service.spec.ts index 166c536..1ac5ef1 100644 --- a/api/src/report-materials/report-materials.service.spec.ts +++ b/api/src/report-materials/report-materials.service.spec.ts @@ -151,6 +151,120 @@ describe('ReportMaterialsService', () => { expect(prisma.reportMaterialBatch.create).not.toHaveBeenCalled(); }); + it('stages imported signatures for review without changing or approving business data', async () => { + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet('签名资料'); + sheet.addRow(['短信签名', '用途说明']); + sheet.addRow(['待审签名', '验证码']); + const content = Buffer.from(await workbook.xlsx.writeBuffer()); + const stagedRows: Array> = []; + const prisma = { + reportMaterialImportBatch: { + findUnique: jest.fn().mockResolvedValue({ + id: 'import-review-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + fileObjectId: 'source-1', + fileName: '签名资料.xlsx', + reportType: 'signature', + status: 'analyzed', + sheetName: '签名资料', + dataStartRow: 2, + }), + update: jest.fn().mockImplementation(({ data }: { data: Record }) => Promise.resolve({ id: 'import-review-1', ...data, items: stagedRows })), + }, + reportMaterialImportItem: { + createMany: jest.fn().mockImplementation(({ data }: { data: Array> }) => { + stagedRows.push(...data); + return Promise.resolve({ count: data.length }); + }), + }, + smsSignature: { findFirst: jest.fn().mockResolvedValue(null) }, + operationLog: { create: jest.fn().mockResolvedValue({ id: 'log-import-review' }) }, + $transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)), + }; + const files = { getDownload: jest.fn().mockResolvedValue({ content }) }; + const smsConfig = { + createSignature: jest.fn(), + updateSignature: jest.fn(), + approveSignature: jest.fn(), + }; + const service = new ReportMaterialsService(prisma as never, files as never, smsConfig as never); + + const result = await service.commitImport('import-review-1', { + operatorId: 'operator-1', + mappings: [ + { sourceHeader: '短信签名', sourceColumnIndex: 1, targetFieldCode: 'signature_name', targetKind: 'signatureName', fieldType: 'string', required: true }, + { sourceHeader: '用途说明', sourceColumnIndex: 2, targetFieldCode: 'purpose', targetKind: 'purpose', fieldType: 'string' }, + ], + }); + + expect(result).toMatchObject({ status: 'pending_review', successCount: 1, failedCount: 0 }); + expect(stagedRows).toEqual([expect.objectContaining({ + rowNumber: 2, + reportType: 'signature', + operation: 'create', + status: 'pending_review', + payload: expect.objectContaining({ name: '待审签名', purpose: '验证码' }), + })]); + expect(smsConfig.createSignature).not.toHaveBeenCalled(); + expect(smsConfig.updateSignature).not.toHaveBeenCalled(); + expect(smsConfig.approveSignature).not.toHaveBeenCalled(); + }); + + it('allows rejecting selected imported rows without requiring a reason', async () => { + const prisma = { + reportMaterialImportBatch: { + findUnique: jest.fn().mockResolvedValue({ + id: 'import-review-2', + items: [{ id: 'item-1', rowNumber: 2, status: 'pending_review' }], + }), + update: jest.fn().mockResolvedValue({}), + }, + reportMaterialImportItem: { + update: jest.fn().mockResolvedValue({}), + groupBy: jest.fn().mockResolvedValue([{ status: 'rejected', _count: { _all: 1 } }]), + }, + }; + const service = new ReportMaterialsService(prisma as never, {} as never, {} as never); + + await expect(service.reviewImportItems('import-review-2', { + decision: 'reject', + itemIds: ['item-1'], + reviewerId: 'reviewer-1', + })).resolves.toMatchObject({ status: 'rejected', rejectedCount: 1, failedCount: 0 }); + expect(prisma.reportMaterialImportItem.update).toHaveBeenCalledWith({ + where: { id: 'item-1' }, + data: expect.objectContaining({ status: 'rejected', reviewReason: undefined, reviewedById: 'reviewer-1' }), + }); + }); + + it('calculates generated batch totals and success rate from per-channel report tasks', async () => { + const prisma = { + reportMaterialBatch: { + findMany: jest.fn().mockResolvedValue([{ + id: 'batch-stats-1', + batchNo: 'RB-STATS-1', + exportFiles: [ + { items: [{ task: { id: 'task-1', status: 'approved' } }, { task: { id: 'task-2', status: 'rejected' } }] }, + { items: [{ task: { id: 'task-3', status: 'approved' } }] }, + ], + items: [], + }]), + count: jest.fn().mockResolvedValue(1), + }, + }; + const service = new ReportMaterialsService(prisma as never, {} as never, {} as never); + + await expect(service.listBatches({ keyword: 'RB-STATS', page: 2, pageSize: 10 })).resolves.toMatchObject({ + items: [{ id: 'batch-stats-1', reportTotal: 3, successCount: 2, successRate: 2 / 3 }], + total: 1, + page: 2, + pageSize: 10, + }); + expect(prisma.reportMaterialBatch.findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 10, take: 10 })); + }); + it('rejects malformed preflight items as a readable 400 before Prisma is called', async () => { const prisma = { smsSignature: { findUnique: jest.fn() } }; const service = new ReportMaterialsService(prisma as never, {} as never, {} as never); diff --git a/api/src/report-materials/report-materials.service.ts b/api/src/report-materials/report-materials.service.ts index 8db1ec7..de65b2c 100644 --- a/api/src/report-materials/report-materials.service.ts +++ b/api/src/report-materials/report-materials.service.ts @@ -38,6 +38,21 @@ export interface ImportCommitDto { operatorId?: string; } +export interface ReviewImportItemsDto { + decision: 'approve' | 'reject'; + itemIds?: string[]; + reason?: string; + reviewerId?: string; +} + +type PagedQuery = { + keyword?: string; + startAt?: string; + endAt?: string; + page?: number; + pageSize?: number; +}; + export interface CreateReportBatchDto { createdById?: string; idempotencyKey?: string; @@ -105,7 +120,7 @@ export class ReportMaterialsService { } async exportPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string }, operatorId?: string) { - const items = await this.listPending(query); + const items = await this.findPendingItems(query); const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('待报备资料', { views: [{ state: 'frozen', ySplit: 1 }] }); sheet.addRow(['资料类型', '企业', '企业应用', '签名/站点', '详情', '变更时间']); @@ -123,15 +138,53 @@ export class ReportMaterialsService { return { fileName, content: Buffer.from(await workbook.xlsx.writeBuffer()) }; } - async listPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string }) { + async listPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery) { + const items = await this.findPendingItems(query); + const page = normalizePage(query.page); + const pageSize = normalizePageSize(query.pageSize); + return { + items: items.slice((page - 1) * pageSize, page * pageSize), + total: items.length, + page, + pageSize, + }; + } + + private async findPendingItems(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery) { + const changedAt = dateRange(query.startAt, query.endAt); + const keyword = query.keyword?.trim(); const [signatures, drainageInfos] = await Promise.all([ query.reportType === 'drainage' ? Promise.resolve([]) : this.prisma.smsSignature.findMany({ - where: { pendingReport: true, auditStatus: 'approved', tenantId: query.tenantId, applicationId: query.applicationId }, + where: { + pendingReport: true, + auditStatus: 'approved', + tenantId: query.tenantId, + applicationId: query.applicationId, + reportChangedAt: changedAt, + OR: keyword ? [ + { name: { contains: keyword } }, + { tenant: { name: { contains: keyword } } }, + { application: { name: { contains: keyword } } }, + ] : undefined, + }, include: { tenant: true, application: true }, orderBy: { reportChangedAt: 'desc' }, }), query.reportType === 'signature' ? Promise.resolve([]) : this.prisma.smsDrainageInfo.findMany({ - where: { pendingReport: true, auditStatus: 'approved', tenantId: query.tenantId, applicationId: query.applicationId }, + where: { + pendingReport: true, + auditStatus: 'approved', + tenantId: query.tenantId, + applicationId: query.applicationId, + reportChangedAt: changedAt, + OR: keyword ? [ + { siteName: { contains: keyword } }, + { url: { contains: keyword } }, + { signature: { name: { contains: keyword } } }, + { tenant: { name: { contains: keyword } } }, + { application: { name: { contains: keyword } } }, + ] : undefined, + }, include: { tenant: true, application: true, signature: true }, orderBy: { reportChangedAt: 'desc' }, }), @@ -246,6 +299,7 @@ export class ReportMaterialsService { async commitImport(batchId: string, data: ImportCommitDto) { const batch = await this.prisma.reportMaterialImportBatch.findUnique({ where: { id: batchId } }); if (!batch) throw new NotFoundException('导入批次不存在'); + if (batch.status !== 'analyzed') throw new ConflictException('该导入批次已提交审核,不能重复导入'); if (!data.mappings?.length) throw new BadRequestException('请至少配置一个导入字段映射'); if (data.profile) await this.saveImportProfile({ ...data.profile, reportType: batch.reportType as 'signature' | 'drainage', columns: data.mappings }); const { content } = await this.files.getDownload(batch.fileObjectId); @@ -257,9 +311,10 @@ export class ReportMaterialsService { const imageByCell = new Map(images.map((image) => [`${image.row}:${image.column}`, image])); let successCount = 0; const failures: Array<{ rowNumber: number; reason: string }> = []; + const stagedItems: Prisma.ReportMaterialImportItemCreateManyInput[] = []; for (let rowNumber = batch.dataStartRow; rowNumber <= worksheet.rowCount; rowNumber += 1) { + const values: Record = {}; try { - const values: Record = {}; for (const mapping of data.mappings) { const image = imageByCell.get(`${rowNumber}:${mapping.sourceColumnIndex}`); if (image && mapping.fieldType !== 'string') { @@ -278,23 +333,47 @@ export class ReportMaterialsService { for (const mapping of data.mappings.filter((item) => item.required)) { if (!hasValue(values[mapping.targetFieldCode])) throw new Error(`缺少必填字段:${mapping.sourceHeader}`); } - if (batch.reportType === 'signature') await this.importSignatureRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values); - else await this.importDrainageRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values); + const staged = batch.reportType === 'signature' + ? await this.stageSignatureRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values) + : await this.stageDrainageRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values); + stagedItems.push({ + batchId, + rowNumber, + reportType: batch.reportType, + operation: staged.operation, + targetId: staged.targetId, + status: 'pending_review', + payload: staged.payload as Prisma.InputJsonValue, + originalSnapshot: staged.originalSnapshot as Prisma.InputJsonValue | undefined, + }); successCount += 1; } catch (error) { - failures.push({ rowNumber, reason: error instanceof Error ? error.message : '导入失败' }); + const reason = error instanceof Error ? error.message : '导入失败'; + failures.push({ rowNumber, reason }); + stagedItems.push({ + batchId, + rowNumber, + reportType: batch.reportType, + operation: 'invalid', + status: 'invalid', + payload: values as Prisma.InputJsonValue, + errorMessage: reason, + }); } } - const updated = await this.prisma.reportMaterialImportBatch.update({ - where: { id: batchId }, - data: { - status: failures.length ? (successCount ? 'partial_failed' : 'failed') : 'completed', - mapping: data.mappings as Prisma.InputJsonValue, - result: { failures } as Prisma.InputJsonValue, - successCount, - failedCount: failures.length, - completedAt: new Date(), - }, + const updated = await this.prisma.$transaction(async (tx) => { + if (stagedItems.length) await tx.reportMaterialImportItem.createMany({ data: stagedItems }); + return tx.reportMaterialImportBatch.update({ + where: { id: batchId }, + data: { + status: successCount ? 'pending_review' : 'failed', + mapping: data.mappings as Prisma.InputJsonValue, + result: { failures } as Prisma.InputJsonValue, + successCount, + failedCount: failures.length, + }, + include: { items: { orderBy: { rowNumber: 'asc' } } }, + }); }); await this.prisma.operationLog.create({ data: { tenantId: batch.tenantId, userId: data.operatorId, action: 'report_material.import_committed', resource: 'report_material_import', resourceId: batch.id, @@ -303,12 +382,167 @@ export class ReportMaterialsService { return updated; } - listBatches() { - return this.prisma.reportMaterialBatch.findMany({ - include: { exportFiles: true, items: true }, - orderBy: { createdAt: 'desc' }, - take: 100, + async listImportReviewBatches(query: PagedQuery & { reportType?: 'signature' | 'drainage'; status?: string } = {}) { + const page = normalizePage(query.page); + const pageSize = normalizePageSize(query.pageSize); + const where: Prisma.ReportMaterialImportBatchWhereInput = { + reportType: query.reportType, + status: query.status && query.status !== 'all' ? query.status : undefined, + createdAt: dateRange(query.startAt, query.endAt), + OR: query.keyword?.trim() ? [ + { fileName: { contains: query.keyword.trim() } }, + { id: { contains: query.keyword.trim() } }, + ] : undefined, + }; + const [batches, total] = await Promise.all([ + this.prisma.reportMaterialImportBatch.findMany({ + where, + include: { items: { orderBy: { rowNumber: 'asc' } } }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.reportMaterialImportBatch.count({ where }), + ]); + const tenantIds = [...new Set(batches.map((batch) => batch.tenantId))]; + const applicationIds = [...new Set(batches.map((batch) => batch.applicationId).filter((id): id is string => Boolean(id)))]; + const reviewerIds = [...new Set(batches.flatMap((batch) => [ + batch.reviewedById, + ...batch.items.map((item) => item.reviewedById), + ]).filter((id): id is string => Boolean(id)))]; + const [tenants, applications, reviewers] = await Promise.all([ + tenantIds.length ? this.prisma.tenant.findMany({ where: { id: { in: tenantIds } }, select: { id: true, name: true } }) : [], + applicationIds.length ? this.prisma.smsApplication.findMany({ where: { id: { in: applicationIds } }, select: { id: true, name: true } }) : [], + reviewerIds.length ? this.prisma.user.findMany({ where: { id: { in: reviewerIds } }, select: { id: true, username: true, displayName: true } }) : [], + ]); + const tenantById = new Map(tenants.map((item) => [item.id, item])); + const applicationById = new Map(applications.map((item) => [item.id, item])); + const reviewerById = new Map(reviewers.map((item) => [item.id, item])); + return { + items: batches.map((batch) => ({ + ...batch, + tenant: tenantById.get(batch.tenantId) ?? null, + application: batch.applicationId ? applicationById.get(batch.applicationId) ?? null : null, + reviewer: batch.reviewedById ? reviewerById.get(batch.reviewedById) ?? null : null, + items: batch.items.map((item) => ({ + ...item, + reviewer: item.reviewedById ? reviewerById.get(item.reviewedById) ?? null : null, + })), + })), + total, + page, + pageSize, + }; + } + + async reviewImportItems(batchId: string, data: ReviewImportItemsDto) { + if (!data.reviewerId) throw new BadRequestException('Reviewer session is required'); + if (!['approve', 'reject'].includes(data.decision)) throw new BadRequestException('Unsupported import review decision'); + const batch = await this.prisma.reportMaterialImportBatch.findUnique({ + where: { id: batchId }, + include: { items: { where: { id: data.itemIds?.length ? { in: data.itemIds } : undefined, status: 'pending_review' }, orderBy: { rowNumber: 'asc' } } }, }); + if (!batch) throw new NotFoundException('导入审核批次不存在'); + if (!batch.items.length) throw new BadRequestException('没有可审核的导入明细'); + let approvedCount = 0; + let rejectedCount = 0; + const failures: Array<{ itemId: string; rowNumber: number; reason: string }> = []; + for (const item of batch.items) { + if (data.decision === 'reject') { + await this.prisma.reportMaterialImportItem.update({ + where: { id: item.id }, + data: { status: 'rejected', reviewReason: data.reason?.trim(), reviewedById: data.reviewerId, reviewedAt: new Date() }, + }); + rejectedCount += 1; + continue; + } + try { + const targetId = await this.applyImportItem(batch, item, data.reviewerId); + await this.prisma.reportMaterialImportItem.update({ + where: { id: item.id }, + data: { targetId, status: 'approved', reviewReason: data.reason?.trim(), reviewedById: data.reviewerId, reviewedAt: new Date(), errorMessage: null }, + }); + approvedCount += 1; + } catch (error) { + const reason = error instanceof Error ? error.message : '导入审核应用失败'; + failures.push({ itemId: item.id, rowNumber: item.rowNumber, reason }); + await this.prisma.reportMaterialImportItem.update({ + where: { id: item.id }, + data: { status: 'invalid', errorMessage: reason, reviewedById: data.reviewerId, reviewedAt: new Date() }, + }); + } + } + const counts = await this.prisma.reportMaterialImportItem.groupBy({ + by: ['status'], + where: { batchId }, + _count: { _all: true }, + }); + const countByStatus = new Map(counts.map((item) => [item.status, item._count._all])); + const pendingCount = countByStatus.get('pending_review') ?? 0; + const totalApproved = countByStatus.get('approved') ?? 0; + const totalRejected = countByStatus.get('rejected') ?? 0; + const totalInvalid = countByStatus.get('invalid') ?? 0; + const status = pendingCount + ? 'partially_reviewed' + : totalApproved && (totalRejected || totalInvalid) + ? 'partially_approved' + : totalApproved + ? 'approved' + : totalRejected + ? 'rejected' + : 'failed'; + await this.prisma.reportMaterialImportBatch.update({ + where: { id: batchId }, + data: { + status, + reviewedById: pendingCount ? undefined : data.reviewerId, + reviewedAt: pendingCount ? undefined : new Date(), + completedAt: pendingCount ? undefined : new Date(), + }, + }); + return { batchId, status, approvedCount, rejectedCount, failedCount: failures.length, failures }; + } + + async listBatches(query: PagedQuery = {}) { + const page = normalizePage(query.page); + const pageSize = normalizePageSize(query.pageSize); + const where: Prisma.ReportMaterialBatchWhereInput = { + createdAt: dateRange(query.startAt, query.endAt), + batchNo: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined, + }; + const [batches, total] = await Promise.all([ + this.prisma.reportMaterialBatch.findMany({ + where, + include: { + exportFiles: { + include: { + items: { include: { task: { select: { id: true, status: true } } } }, + }, + }, + items: true, + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.reportMaterialBatch.count({ where }), + ]); + return { + items: batches.map((batch) => { + const reportItems = batch.exportFiles.flatMap((file) => file.items); + const reportTotal = reportItems.length; + const successCount = reportItems.filter((item) => item.task.status === 'approved').length; + return { + ...batch, + reportTotal, + successCount, + successRate: reportTotal ? successCount / reportTotal : 0, + }; + }), + total, + page, + pageSize, + }; } async createBatch(data: CreateReportBatchDto) { @@ -402,17 +636,35 @@ export class ReportMaterialsService { }; } - private async importSignatureRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record) { + private async stageSignatureRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record) { const name = mappedCoreValue(mappings, values, 'signatureName'); if (!name) throw new Error('缺少短信签名'); const purpose = mappedCoreValue(mappings, values, 'purpose'); const signatureReportValues = dynamicValues(mappings, values); const existing = await this.prisma.smsSignature.findFirst({ where: { tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } } }); - if (existing) return this.smsConfig.updateSignature(existing.id, { applicationId, name, purpose, drainageInfo: { ...jsonRecord(existing.drainageInfo), signatureReportValues } }); - return this.smsConfig.createSignature({ tenantId, applicationId, name, purpose, drainageInfo: { signatureReportValues } }, { initialAuditStatus: 'approved' }); + return { + operation: existing ? 'update' : 'create', + targetId: existing?.id, + payload: { + tenantId, + applicationId, + name, + purpose, + drainageInfo: { ...jsonRecord(existing?.drainageInfo), signatureReportValues }, + }, + originalSnapshot: existing ? { + id: existing.id, + applicationId: existing.applicationId, + name: existing.name, + purpose: existing.purpose, + drainageInfo: existing.drainageInfo, + auditStatus: existing.auditStatus, + updatedAt: existing.updatedAt, + } : undefined, + }; } - private async importDrainageRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record) { + private async stageDrainageRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record) { const signatureName = mappedCoreValue(mappings, values, 'signatureName'); const siteName = mappedCoreValue(mappings, values, 'siteName'); const url = mappedCoreValue(mappings, values, 'url'); @@ -422,8 +674,85 @@ export class ReportMaterialsService { const remark = mappedCoreValue(mappings, values, 'remark'); const reportValues = dynamicValues(mappings, values); const existing = await this.prisma.smsDrainageInfo.findFirst({ where: { signatureId: signature.id, url, auditStatus: { not: 'deleted' } } }); - if (existing) return this.smsConfig.updateDrainageInfo(existing.id, { siteName, url, remark, reportValues }, { initialAuditStatus: 'approved' }); - return this.smsConfig.createDrainageInfo(signature.id, { siteName, url, remark, reportValues }, { initialAuditStatus: 'approved' }); + return { + operation: existing ? 'update' : 'create', + targetId: existing?.id, + payload: { tenantId, applicationId, signatureId: signature.id, signatureName, siteName, url, remark, reportValues }, + originalSnapshot: existing ? { + id: existing.id, + siteName: existing.siteName, + url: existing.url, + remark: existing.remark, + reportValues: existing.reportValues, + auditStatus: existing.auditStatus, + updatedAt: existing.updatedAt, + } : undefined, + }; + } + + private async applyImportItem( + batch: { tenantId: string; applicationId: string | null; reportType: string }, + item: { reportType: string; targetId: string | null; payload: Prisma.JsonValue }, + reviewerId: string, + ) { + const payload = jsonRecord(item.payload); + if (item.reportType === 'signature') { + const name = String(payload.name ?? ''); + const applicationId = typeof payload.applicationId === 'string' ? payload.applicationId : undefined; + const body = { + applicationId, + name, + purpose: typeof payload.purpose === 'string' ? payload.purpose : undefined, + drainageInfo: jsonRecord(payload.drainageInfo), + }; + let targetId = item.targetId; + if (targetId) { + const current = await this.prisma.smsSignature.findUnique({ where: { id: targetId } }); + if (!current || current.auditStatus === 'deleted') throw new Error('原签名已删除,不能应用导入修改'); + await this.smsConfig.updateSignature(targetId, body, batch.tenantId); + } else { + const duplicate = await this.prisma.smsSignature.findFirst({ + where: { tenantId: batch.tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } }, + }); + if (duplicate) { + targetId = duplicate.id; + await this.smsConfig.updateSignature(targetId, body, batch.tenantId); + } else { + const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...body }); + targetId = created.id; + } + } + await this.smsConfig.approveSignature(targetId, { reviewerId, reason: `批量导入审核通过:${name}` }); + return targetId; + } + const signatureId = String(payload.signatureId ?? ''); + const siteName = String(payload.siteName ?? ''); + const url = String(payload.url ?? ''); + const body = { + siteName, + url, + remark: typeof payload.remark === 'string' ? payload.remark : undefined, + reportValues: jsonRecord(payload.reportValues), + }; + let targetId = item.targetId; + if (targetId) { + const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: targetId } }); + if (!current || current.auditStatus === 'deleted') throw new Error('原引流信息已删除,不能应用导入修改'); + await this.smsConfig.updateDrainageInfo(targetId, body, { initialAuditStatus: 'pending' }, batch.tenantId); + } else { + const duplicate = await this.prisma.smsDrainageInfo.findFirst({ + where: { signatureId, url, auditStatus: { not: 'deleted' } }, + }); + if (duplicate) { + targetId = duplicate.id; + await this.smsConfig.updateDrainageInfo(targetId, body, { initialAuditStatus: 'pending' }, batch.tenantId); + } else { + const created = await this.smsConfig.createDrainageInfo(signatureId, body, { initialAuditStatus: 'pending' }, batch.tenantId); + targetId = created.id; + } + } + await this.smsConfig.approveDrainageInfo(targetId, { reviewerId, reason: `批量导入审核通过:${siteName || url}` }); + return targetId; } private async prepareBatchItem(batchId: string, selected: CreateReportBatchDto['items'][number], inspection: ReportBatchInspection) { @@ -707,6 +1036,15 @@ function drainageCoreMapping(header: string): { code: string; kind: ImportMappin function normalizeHeader(value: string) { return value.toLowerCase().replace(/[\s**::()()_-]/g, ''); } function normalizeFieldCode(value: string) { return `import_${value.trim().toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_').slice(0, 40) || randomUUID().slice(0, 8)}`; } function clamp(value: number, minimum: number, maximum: number) { return Math.min(maximum, Math.max(minimum, Number.isFinite(value) ? Math.round(value) : minimum)); } +function normalizePage(value?: number) { return Math.max(1, Math.floor(Number(value) || 1)); } +function normalizePageSize(value?: number) { return Math.min(100, Math.max(1, Math.floor(Number(value) || 20))); } +function dateRange(startAt?: string, endAt?: string) { + const start = startAt ? new Date(`${startAt}T00:00:00+08:00`) : undefined; + const end = endAt ? new Date(`${endAt}T23:59:59.999+08:00`) : undefined; + if (start && Number.isNaN(start.getTime())) throw new BadRequestException('开始日期无效'); + if (end && Number.isNaN(end.getTime())) throw new BadRequestException('结束日期无效'); + return start || end ? { gte: start, lte: end } : undefined; +} function cellText(cell: ExcelJS.Cell) { const value = cell.value; diff --git a/api/src/reports/reports.service.spec.ts b/api/src/reports/reports.service.spec.ts index 1cc7644..5ea8597 100644 --- a/api/src/reports/reports.service.spec.ts +++ b/api/src/reports/reports.service.spec.ts @@ -41,6 +41,19 @@ describe('ReportsService', () => { expect(tx.$executeRaw).toHaveBeenCalledTimes(28); }); + it('calculates profit cost from channel unit price times delivered fragment count', async () => { + await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z')); + const firstDayQueries = tx.$executeRaw.mock.calls.slice(0, 7).map(([query]) => + Array.isArray(query?.strings) ? query.strings.join(' ') : String(query), + ); + const profitQueries = firstDayQueries.slice(1, 3).join('\n'); + + expect(profitQueries).toContain('"SmsMessageSegmentAudit"'); + expect(profitQueries).toContain(`FILTER (WHERE audit."receiptStatus" = 'delivered')`); + expect(profitQueries).toContain('submit."costUnitPrice"'); + expect(profitQueries).not.toContain('SUM(submit."costAmountCents")'); + }); + it('queries reconciliation reports with server-side filters and bounded pagination', async () => { await expect(service.listReconciliation({ dateFrom: '2026-07-01', diff --git a/api/src/reports/reports.service.ts b/api/src/reports/reports.service.ts index 04896a5..f29dddd 100644 --- a/api/src/reports/reports.service.ts +++ b/api/src/reports/reports.service.ts @@ -156,8 +156,31 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { FROM "SmsBillingRecord" GROUP BY "messageId" ), costs AS ( - SELECT submit."messageRecordId", SUM(submit."costAmountCents")::bigint AS cost + SELECT + submit."messageRecordId", + 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)::bigint AS cost 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' GROUP BY submit."messageRecordId" ) @@ -256,10 +279,22 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { ) OR submit."submitStatus" IN ('rejected', 'timeout')) THEN message."billingUnits" ELSE 0 END), 0)::integer, COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0)::bigint, COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.refund ELSE 0 END), 0)::bigint, - COALESCE(SUM(submit."costAmountCents"), 0)::bigint, - (COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costAmountCents"), 0))::bigint, + 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, + (COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - 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, CASE WHEN COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) = 0 THEN 0 - ELSE ROUND((COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costAmountCents"), 0)) * 10000.0 / + ELSE ROUND((COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - 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)) * 10000.0 / SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END))::integer END, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP @@ -267,6 +302,22 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId" JOIN "SmsChannel" channel ON channel.id = submit."channelId" LEFT JOIN billing ON billing."messageId" = message."messageId" + 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 COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt} AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt} diff --git a/api/src/risk-review/risk-review.service.spec.ts b/api/src/risk-review/risk-review.service.spec.ts index 32358e6..77b2342 100644 --- a/api/src/risk-review/risk-review.service.spec.ts +++ b/api/src/risk-review/risk-review.service.spec.ts @@ -61,6 +61,21 @@ function createPrismaMock(overrides: Record = {}) { } describe('RiskReviewService', () => { + it('includes the sending enterprise and application in SMS review rows', async () => { + const prisma = createPrismaMock(); + prisma.smsSendTask.findMany.mockResolvedValue([]); + const service = new RiskReviewService(prisma as never); + + await service.listTasks(undefined, 'pending_review'); + + expect(prisma.smsSendTask.findMany).toHaveBeenCalledWith(expect.objectContaining({ + include: expect.objectContaining({ + tenant: { select: { id: true, name: true } }, + application: { select: { id: true, name: true } }, + }), + })); + }); + it('groups identical CMPP template mismatches into a deterministic short review window', async () => { const prisma = createPrismaMock(); prisma.smsSendTask.findUnique.mockResolvedValue({ diff --git a/api/src/risk-review/risk-review.service.ts b/api/src/risk-review/risk-review.service.ts index 06cae97..7738519 100644 --- a/api/src/risk-review/risk-review.service.ts +++ b/api/src/risk-review/risk-review.service.ts @@ -200,6 +200,8 @@ export class RiskReviewService { }, include: { riskHits: true, + tenant: { select: { id: true, name: true } }, + application: { select: { id: true, name: true } }, reviewedBy: { select: { id: true, username: true, displayName: true } }, _count: { select: { messageRecords: true } }, }, diff --git a/api/src/send-chain/admin-send-chain.controller.ts b/api/src/send-chain/admin-send-chain.controller.ts index 7e72d6e..421d257 100644 --- a/api/src/send-chain/admin-send-chain.controller.ts +++ b/api/src/send-chain/admin-send-chain.controller.ts @@ -12,6 +12,16 @@ export class AdminSendChainController { return this.sendChain.listBatchTasks(tenantId, status); } + @Get('batch-tasks/:id/messages') + listBatchTaskMessages( + @Param('id') taskId: string, + @Query('phone') phone?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.sendChain.listAdminBatchTaskMessages(taskId, phone, Number(page), Number(pageSize)); + } + @Get('messages') listMessages( @Query('tenantId') tenantId?: string, diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index b1b92d1..1edf2ff 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -128,6 +128,7 @@ function createPrismaMock() { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...message, ...data })), createMany: jest.fn().mockResolvedValue({ count: 2 }), findMany: jest.fn().mockResolvedValue([{ id: 'record-1', batchTaskId: 'task-1' }]), + count: jest.fn().mockResolvedValue(1), findUnique: jest.fn().mockResolvedValue(message), findFirst: jest.fn().mockResolvedValue(message), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...message, ...data })), @@ -790,6 +791,35 @@ describe('SendChainService', () => { expect(prisma.smsMessageRecord.findMany).not.toHaveBeenCalled(); }); + it('paginates the real phone list for an admin batch task', async () => { + const { service, prisma } = createService(); + prisma.smsBatchTask.findFirst.mockResolvedValue({ id: 'task-1', sourceType: 'client' }); + prisma.smsMessageRecord.findMany.mockResolvedValue([ + { id: 'record-1', phoneNumber: '13800000001', province: '上海', carrier: 'mobile', status: 'delivered' }, + ]); + prisma.smsMessageRecord.count.mockResolvedValue(21); + + await expect(service.listAdminBatchTaskMessages('task-1', '138', 2, 20)).resolves.toEqual({ + items: [expect.objectContaining({ phoneNumber: '13800000001' })], + total: 21, + page: 2, + pageSize: 20, + }); + expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith({ + where: { batchTaskId: 'task-1', phoneNumber: { contains: '138' } }, + select: { + id: true, + phoneNumber: true, + province: true, + carrier: true, + status: true, + }, + orderBy: [{ queuedAt: 'asc' }, { id: 'asc' }], + skip: 20, + take: 20, + }); + }); + it('terminates non-final tasks by canceling unsubmitted messages', async () => { const { service, prisma } = createService(); prisma.smsBatchTask.findUnique.mockResolvedValue({ id: 'task-1', status: 'sending' }); diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index e8ea5c1..2f57ccb 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -599,6 +599,39 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { return this.listMessages({ tenantId, taskId }); } + async listAdminBatchTaskMessages(taskId: string, phone?: string, page = 1, pageSize = 20) { + const task = await this.prisma.smsBatchTask.findFirst({ + where: { id: taskId, sourceType: 'client' }, + select: { id: true }, + }); + if (!task) { + throw new NotFoundException('SMS batch task not found'); + } + const normalizedPage = Math.max(1, Math.floor(Number(page) || 1)); + const normalizedPageSize = Math.min(100, Math.max(1, Math.floor(Number(pageSize) || 20))); + const where: Prisma.SmsMessageRecordWhereInput = { + batchTaskId: taskId, + ...(phone?.trim() ? { phoneNumber: { contains: phone.trim() } } : {}), + }; + const [items, total] = await Promise.all([ + this.prisma.smsMessageRecord.findMany({ + where, + select: { + id: true, + phoneNumber: true, + province: true, + carrier: true, + status: true, + }, + orderBy: [{ queuedAt: 'asc' }, { id: 'asc' }], + skip: (normalizedPage - 1) * normalizedPageSize, + take: normalizedPageSize, + }), + this.prisma.smsMessageRecord.count({ where }), + ]); + return { items, total, page: normalizedPage, pageSize: normalizedPageSize }; + } + listMessages(query: { tenantId?: string; applicationId?: string; diff --git a/api/src/sms-config/admin-sms-config.controller.ts b/api/src/sms-config/admin-sms-config.controller.ts index fa1d704..d26dad3 100644 --- a/api/src/sms-config/admin-sms-config.controller.ts +++ b/api/src/sms-config/admin-sms-config.controller.ts @@ -96,14 +96,14 @@ export class AdminSmsConfigController { @Post('drainage-infos/:id/approve') @RequireRecentAuthentication() - approveDrainageInfo(@Param('id') itemId: string, @Body() body: ReviewDto) { - return this.smsConfig.approveDrainageInfo(itemId, body); + approveDrainageInfo(@Param('id') itemId: string, @Body() body: ReviewDto, @CurrentSessionUserId() reviewerId?: string) { + return this.smsConfig.approveDrainageInfo(itemId, { ...body, reviewerId }); } @Post('drainage-infos/:id/reject') @RequireRecentAuthentication() - rejectDrainageInfo(@Param('id') itemId: string, @Body() body: ReviewDto) { - return this.smsConfig.rejectDrainageInfo(itemId, body); + rejectDrainageInfo(@Param('id') itemId: string, @Body() body: ReviewDto, @CurrentSessionUserId() reviewerId?: string) { + return this.smsConfig.rejectDrainageInfo(itemId, { ...body, reviewerId }); } @Post('drainage-infos/:id/status') @@ -140,8 +140,8 @@ export class AdminSmsConfigController { @Post('signatures/:id/reject') @RequireRecentAuthentication() - rejectSignature(@Param('id') signatureId: string, @Body() body: ReviewDto) { - return this.smsConfig.rejectSignature(signatureId, body); + rejectSignature(@Param('id') signatureId: string, @Body() body: ReviewDto, @CurrentSessionUserId() reviewerId?: string) { + return this.smsConfig.rejectSignature(signatureId, { ...body, reviewerId }); } @Post('templates/:id/approve') @@ -152,8 +152,8 @@ export class AdminSmsConfigController { @Post('templates/:id/reject') @RequireRecentAuthentication() - rejectTemplate(@Param('id') templateId: string, @Body() body: ReviewDto) { - return this.smsConfig.rejectTemplate(templateId, body); + rejectTemplate(@Param('id') templateId: string, @Body() body: ReviewDto, @CurrentSessionUserId() reviewerId?: string) { + return this.smsConfig.rejectTemplate(templateId, { ...body, reviewerId }); } @Post('enterprise-applications/:id/status') diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index 8fb8d25..9df7351 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -1622,6 +1622,9 @@ export class SmsConfigService implements OnModuleInit, OnModuleDestroy { targetType, targetId, }, + include: { + reviewer: { select: { id: true, username: true, displayName: true } }, + }, orderBy: { createdAt: 'desc' }, }); } diff --git a/api/src/tenants/tenants.service.spec.ts b/api/src/tenants/tenants.service.spec.ts index 9a5142e..1b9fca5 100644 --- a/api/src/tenants/tenants.service.spec.ts +++ b/api/src/tenants/tenants.service.spec.ts @@ -10,7 +10,8 @@ function createPrismaMock() { createdAt: new Date('2026-07-03T00:00:00.000Z'), updatedAt: new Date('2026-07-03T00:00:00.000Z'), }; - return { + const prisma = { + $executeRaw: jest.fn().mockResolvedValue(1), tenant: { findMany: jest.fn().mockResolvedValue([{ ...tenant, enterpriseCertifications: [] }]), findUnique: jest.fn().mockResolvedValue({ ...tenant, enterpriseCertifications: [] }), @@ -27,6 +28,7 @@ function createPrismaMock() { }, tenantAccount: { findMany: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', balanceCents: 12000, status: 'active' }]), + findUnique: jest.fn().mockResolvedValue({ balanceCents: 0 }), }, smsMessageRecord: { groupBy: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', _sum: { amountCents: 350 } }]), @@ -35,6 +37,10 @@ function createPrismaMock() { groupBy: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', _sum: { amountCents: 125 } }]), }, }; + return { + ...prisma, + $transaction: jest.fn((callback: (tx: typeof prisma) => unknown) => callback(prisma)), + }; } describe('TenantsService', () => { @@ -96,6 +102,29 @@ describe('TenantsService', () => { }); }); + it('blocks enterprise deletion until a non-zero account balance is cleared', async () => { + const prisma = createPrismaMock(); + prisma.tenantAccount.findUnique.mockResolvedValue({ balanceCents: -1250 }); + const service = new TenantsService(prisma as never); + + await expect(service.delete('tenant-1')).rejects.toThrow('完成余额清算后方可删除,请给企业充值到金额为0'); + expect(prisma.tenant.update).not.toHaveBeenCalled(); + expect(prisma.smsApplication.count).not.toHaveBeenCalled(); + expect(prisma.$executeRaw).toHaveBeenCalledTimes(1); + }); + + it('allows enterprise deletion only when the account balance is zero and no applications are active', async () => { + const prisma = createPrismaMock(); + prisma.tenantAccount.findUnique.mockResolvedValue({ balanceCents: 0 }); + const service = new TenantsService(prisma as never); + + await expect(service.delete('tenant-1')).resolves.toMatchObject({ id: 'tenant-1' }); + expect(prisma.tenant.update).toHaveBeenCalledWith({ + where: { id: 'tenant-1' }, + data: { status: 'deleted' }, + }); + }); + it('rejects enterprise credit codes containing non-alphanumeric characters', async () => { const prisma = createPrismaMock(); const service = new TenantsService(prisma as never); diff --git a/api/src/tenants/tenants.service.ts b/api/src/tenants/tenants.service.ts index 95db091..2470f64 100644 --- a/api/src/tenants/tenants.service.ts +++ b/api/src/tenants/tenants.service.ts @@ -127,16 +127,29 @@ export class TenantsService { } private async deleteAfterApplicationCheck(id: string) { - await this.ensureTenant(id); - const blockingApplications = await this.prisma.smsApplication.count({ - where: { tenantId: id, status: { in: ['active', 'disabling'] } }, - }); - if (blockingApplications > 0) { - throw new BadRequestException(`该企业还有 ${blockingApplications} 个启用或停用中的企业应用,请先完成应用停用`); - } - return this.prisma.tenant.update({ - where: { id }, - data: { status: 'deleted' }, + return this.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'tenant-account:' + id}, 0))`; + const tenant = await tx.tenant.findUnique({ where: { id } }); + if (!tenant) { + throw new NotFoundException('Tenant not found'); + } + const account = await tx.tenantAccount.findUnique({ + where: { tenantId: id }, + select: { balanceCents: true }, + }); + if (moneyToNumber(account?.balanceCents) !== 0) { + throw new BadRequestException('完成余额清算后方可删除,请给企业充值到金额为0'); + } + const blockingApplications = await tx.smsApplication.count({ + where: { tenantId: id, status: { in: ['active', 'disabling'] } }, + }); + if (blockingApplications > 0) { + throw new BadRequestException(`该企业还有 ${blockingApplications} 个启用或停用中的企业应用,请先完成应用停用`); + } + return tx.tenant.update({ + where: { id }, + data: { status: 'deleted' }, + }); }); } diff --git a/api/tools/seed-signature-quality-demo.ts b/api/tools/seed-signature-quality-demo.ts new file mode 100644 index 0000000..30b64cc --- /dev/null +++ b/api/tools/seed-signature-quality-demo.ts @@ -0,0 +1,259 @@ +import { PrismaService } from '../src/prisma/prisma.service'; + +const databaseUrl = process.env.DATABASE_URL + ?? 'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public'; +const databaseHost = new URL(databaseUrl).hostname; +if (process.env.NODE_ENV === 'production') { + throw new Error('Refusing to seed signature-quality demo data in production mode'); +} +if (process.env.ALLOW_LOCAL_SIGNATURE_QUALITY_DEMO !== 'true') { + throw new Error('Set ALLOW_LOCAL_SIGNATURE_QUALITY_DEMO=true explicitly to seed local demo data'); +} +if (!['localhost', '127.0.0.1', '::1'].includes(databaseHost)) { + throw new Error(`Refusing to seed a non-local database host: ${databaseHost}`); +} + +const prisma = new PrismaService(); + +async function main() { + const date = shanghaiDateKey(); + const messagePrefix = `LOCAL-SIGSTAT-${date.replaceAll('-', '')}-`; + const existing = await prisma.smsMessageRecord.count({ + where: { messageId: { startsWith: messagePrefix } }, + }); + if (existing > 0) { + console.log(`Local signature-quality demo already exists for ${date}: ${existing} messages`); + return; + } + + const tenant = await prisma.tenant.upsert({ + where: { code: 'LOCAL-SIG-QUALITY-DEMO' }, + update: { name: '本地统计演示企业', status: 'active' }, + create: { + id: 'local-signature-quality-tenant', + code: 'LOCAL-SIG-QUALITY-DEMO', + name: '本地统计演示企业', + status: 'active', + certificationStatus: 'approved', + }, + }); + const application = await prisma.smsApplication.upsert({ + where: { cmppAccount: 'LOCAL_SIG_QUALITY_DEMO' }, + update: { name: '本地短信统计演示应用', status: 'active', interfaceEnabled: false }, + create: { + id: 'local-signature-quality-application', + tenantId: tenant.id, + name: '本地短信统计演示应用', + cmppAccount: 'LOCAL_SIG_QUALITY_DEMO', + cmppEnterpriseCode: 'LOCAL', + secretHash: 'local-demo-not-for-authentication', + interfaceEnabled: false, + status: 'active', + }, + }); + const channelDefinitions = [ + { id: 'local-signature-quality-channel-fulong', code: 'LOCAL-DEMO-FULONG', name: '本地演示-富泷' }, + { id: 'local-signature-quality-channel-tiebushan', code: 'LOCAL-DEMO-TIEBUSHAN', name: '本地演示-铁布衫' }, + { id: 'local-signature-quality-channel-relay', code: 'LOCAL-DEMO-RELAY', name: '本地演示-行业中转' }, + ]; + const channels = await Promise.all(channelDefinitions.map((channel) => prisma.smsChannel.upsert({ + where: { code: channel.code }, + update: { name: channel.name, status: 'inactive' }, + create: { + ...channel, + carrier: null, + gatewayHost: '127.0.0.1', + gatewayPort: 65535, + account: channel.code, + passwordCipher: 'local-demo', + srcId: '10690000', + status: 'inactive', + }, + }))); + const signatureDefinitions = [ + { id: 'local-signature-quality-signature-property', name: '【本地演示物业】', count: 24 }, + { id: 'local-signature-quality-signature-aerospace', name: '【本地演示航信】', count: 18 }, + { id: 'local-signature-quality-signature-member', name: '【本地会员服务】', count: 12 }, + ]; + const signatures = await Promise.all(signatureDefinitions.map((signature) => prisma.smsSignature.upsert({ + where: { id: signature.id }, + update: { + name: signature.name, + tenantId: tenant.id, + applicationId: application.id, + auditStatus: 'approved', + }, + create: { + id: signature.id, + tenantId: tenant.id, + applicationId: application.id, + name: signature.name, + purpose: '本地数据统计页面演示', + auditStatus: 'approved', + reportStatus: 'approved', + pendingReport: false, + }, + }))); + + const messages: Array> = []; + const submits: Array> = []; + const receipts: Array> = []; + const dayStart = new Date(`${date}T00:00:00+08:00`); + const carriers = ['mobile', 'unicom', 'telecom']; + let sequence = 0; + + signatureDefinitions.forEach((definition, signatureIndex) => { + for (let index = 0; index < definition.count; index += 1) { + sequence += 1; + const key = `${messagePrefix}${String(sequence).padStart(3, '0')}`; + const messageRecordId = `local-signature-quality-message-${date}-${sequence}`; + const carrier = carriers[(index + signatureIndex) % carriers.length]; + const primaryChannel = channels[(index + signatureIndex) % channels.length]; + const retryChannel = channels[(index + signatureIndex + 1) % channels.length]; + const queuedAt = new Date(dayStart.getTime() + (8 * 60 + sequence * 7) * 60_000); + const submittedAt = new Date(queuedAt.getTime() + 500); + const isRetry = index % 11 === 0; + const isSubmitFailure = !isRetry && index % 9 === 0; + const isUnknown = !isRetry && !isSubmitFailure && index % 7 === 0; + const isFailure = !isRetry && !isSubmitFailure && !isUnknown && index % 5 === 0; + const deliveredAt = isRetry || (!isSubmitFailure && !isUnknown && !isFailure) + ? new Date(submittedAt.getTime() + 1_400 + (index % 8) * 650) + : null; + const finalChannel = isRetry ? retryChannel : primaryChannel; + const messageStatus = isSubmitFailure + ? 'submit_failed' + : isUnknown + ? 'submitted' + : isFailure + ? 'failed' + : 'delivered'; + messages.push({ + id: messageRecordId, + tenantId: tenant.id, + applicationId: application.id, + signatureId: signatures[signatureIndex].id, + messageId: key, + phoneNumber: `1390000${String(sequence).padStart(4, '0')}`, + carrier, + province: '上海', + content: `${definition.name}本地数据统计页面演示短信`, + channelId: finalChannel.id, + status: messageStatus, + submitStatus: isSubmitFailure ? 'rejected' : 'accepted', + receiptStatus: isFailure ? 'undelivered' : deliveredAt ? 'delivered' : null, + queuedAt, + submittedAt, + deliveredAt, + }); + + const addAttempt = ({ + attempt, + channelId, + submitStatus, + receiptStatus, + attemptSubmittedAt, + attemptDeliveredAt, + }: { + attempt: number; + channelId: string; + submitStatus: string; + receiptStatus?: 'delivered' | 'undelivered'; + attemptSubmittedAt: Date; + attemptDeliveredAt?: Date; + }) => { + const submitRecordId = `local-signature-quality-submit-${date}-${sequence}-${attempt}`; + const submitId = `LOCAL-SUB-${date.replaceAll('-', '')}-${sequence}-${attempt}`; + const gatewayMessageId = `LOCAL-GW-${date.replaceAll('-', '')}-${sequence}-${attempt}`; + submits.push({ + id: submitRecordId, + tenantId: tenant.id, + messageRecordId, + channelId, + submitId, + gatewayMessageId, + submitStatus, + submittedAt: attemptSubmittedAt, + createdAt: attemptSubmittedAt, + }); + if (receiptStatus && attemptDeliveredAt) { + receipts.push({ + id: `local-signature-quality-receipt-${date}-${sequence}-${attempt}`, + tenantId: tenant.id, + messageRecordId, + receiptKey: `LOCAL-RECEIPT-${date.replaceAll('-', '')}-${sequence}-${attempt}`, + channelId, + messageId: key, + gatewayMessageId, + phoneNumber: `1390000${String(sequence).padStart(4, '0')}`, + receiptStatus, + rawStatus: receiptStatus === 'delivered' ? 'DELIVRD' : 'UNDELIV', + deliveredAt: attemptDeliveredAt, + createdAt: attemptDeliveredAt, + }); + } + }; + + if (isRetry) { + addAttempt({ + attempt: 0, + channelId: primaryChannel.id, + submitStatus: 'accepted', + receiptStatus: 'undelivered', + attemptSubmittedAt: submittedAt, + attemptDeliveredAt: new Date(submittedAt.getTime() + 2_100), + }); + const retrySubmittedAt = new Date(submittedAt.getTime() + 2_500); + addAttempt({ + attempt: 1, + channelId: retryChannel.id, + submitStatus: 'accepted', + receiptStatus: 'delivered', + attemptSubmittedAt: retrySubmittedAt, + attemptDeliveredAt: deliveredAt ?? new Date(retrySubmittedAt.getTime() + 2_000), + }); + } else { + addAttempt({ + attempt: 0, + channelId: primaryChannel.id, + submitStatus: isSubmitFailure ? 'rejected' : 'accepted', + receiptStatus: isFailure ? 'undelivered' : deliveredAt ? 'delivered' : undefined, + attemptSubmittedAt: submittedAt, + attemptDeliveredAt: isFailure ? new Date(submittedAt.getTime() + 3_300) : deliveredAt ?? undefined, + }); + } + } + }); + + await prisma.$transaction([ + prisma.smsMessageRecord.createMany({ data: messages as never[] }), + prisma.smsSubmitRecord.createMany({ data: submits as never[] }), + prisma.smsReceiptRecord.createMany({ data: receipts as never[] }), + ]); + console.log(JSON.stringify({ + date, + tenant: tenant.name, + application: application.name, + signatures: signatures.map((signature) => signature.name), + messages: messages.length, + submitAttempts: submits.length, + receipts: receipts.length, + }, null, 2)); +} + +function shanghaiDateKey(value = new Date()) { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(value); + const byType = new Map(parts.map((part) => [part.type, part.value])); + return `${byType.get('year')}-${byType.get('month')}-${byType.get('day')}`; +} + +main() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(() => prisma.$disconnect()); diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 712a349..da1ba41 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -303,7 +303,7 @@ - 已实现下游恢复控制第一版:Gateway 对恢复候选账号增加账号级恢复锁、失败/等待连接退避和恢复状态持久化,避免同一账号被并发重复恢复或每轮高频空转;控制面新增 `GET /downstream/recovery-statuses` 可查看最近一次恢复状态、重试次数、下一次可恢复时间和错误原因。 - 已实现下游恢复观测第一版:控制面新增 `GET /downstream/recovery-overview`,一次性返回恢复候选账号与恢复状态,便于联调和生产排查。 - 已实现下游恢复状态回流第一版:Gateway 在每次恢复状态变化后,调用 NestJS `/api/gateway/events/downstream/recovery-status` 真实回传账号恢复状态;NestJS 将状态写入 Prisma/PostgreSQL `GatewayDownstreamRecoveryStatus`。 -- 已实现下游恢复状态运营化第一版:运营端新增独立“恢复状态管理”页面,支持真实列表、分页、详情查看和当前筛选结果 CSV 导出;原“下游投递记录”页面只保留投递记录本身,不再混放恢复状态区块。 +- 已实现下游恢复状态运营化第一版:运营端新增独立“恢复状态管理”页面,明确说明该页用于观察客户重连或 Gateway 重启后的未完成下游投递恢复,每个账号展示当前或最近一次恢复状态;支持按最近更新时间筛选(默认近 7 天)、真实列表、分页、详情查看和当前筛选结果 CSV 导出。原“下游投递记录”页面只保留逐条投递记录并默认查询近 7 天,不再混放恢复状态区块。 - 已实现下游恢复失败分类第一版:Gateway/NestJS 共同维护 `failureCategory`,覆盖 `client_disconnected`、`backoff`、`lock_contended`、`lock_lost`、`flush_failed`、`partial_delivery_failed`、`unknown`;运营端“恢复状态管理”页面支持失败分类筛选、分类分布统计、详情展示和导出字段。 - 已实现多 Gateway 恢复抢占协调第一版:恢复锁从单纯实例名升级为 Redis token 租约,状态记录 `lockOwner/lockExpiresAt`;恢复完成时必须通过 Lua 原子校验锁 token,只有持锁实例才能写入最终恢复状态并释放锁,避免旧实例超时后误删新实例锁或覆盖新实例恢复结果;运营端详情/列表可查看锁持有实例。 - 已实现长短信分片审计第一版:Gateway `SubmitResult` 回传真实 `segments[]`,包含 `segmentTotal/segmentIndex/sequenceId/gatewayMessageId/submitStatus/submittedAt`;NestJS 写入 Prisma/PostgreSQL `SmsMessageSegmentAudit`,回执按 `gatewayMessageId` 回填分片回执状态,补偿归因可记录 `compensationType`;运营端短信记录详情可查看真实分片提交、回执和补偿审计。 @@ -555,8 +555,8 @@ - 在“数据详单”之后增加“报表对账”一级菜单,包含“对账单”和“利润报表”两个二级菜单;页面必须读取真实 NestJS API 与 PostgreSQL 报表表,不得在前端按明细临时拼接或使用静态数据。 - 对账单按发送日期、企业、企业应用汇总日发送条数和成功条数。发送条数、成功条数均按短信计费条数 `billingUnits` 统计,成功以最终 `delivered` 状态为准。 - 利润报表按发送日期汇总日发送条数、成功条数、消费金额、成本金额、利润和利润率,支持在“企业应用”和“通道”两个统计维度间切换。 -- 企业应用维度的消费金额只统计仍为 `charged` 的客户账单,最终失败并退款的短信不再形成收入;成本金额统计该应用短信所有上游 `accepted` 提交的通道成本,包括补发产生的真实额外成本。 -- 通道维度按实际上游 `accepted` 提交统计发送量和成本,按同一 Gateway 消息回执统计成功量;客户收入只归属最终有效提交,避免补发时重复计算收入。通道成本单价和成本金额必须在提交记录创建时快照,后续修改通道单价不得改写历史成本。 +- 企业应用维度的消费金额只统计仍为 `charged` 的客户账单,最终失败并退款的短信不再形成收入;成本金额按每次真实提交的通道成本单价快照乘以该次提交最终成功的短信分片数计算。补发只有产生成功分片时才增加对应通道成本,失败、未知或尚未收到成功回执的分片不计成本。 +- 通道维度按实际上游 `accepted` 提交统计发送量,按分片回执统计成功量和成本;客户收入只归属最终有效提交,避免补发时重复计算收入。通道成本单价必须在提交记录创建时快照,后续修改通道单价不得改写历史成本;历史缺少分片审计但存在明确成功回执时,才按该次短信计费分片数兼容计算。 - 利润等于消费金额减成本金额;利润率等于利润除以消费金额,消费金额为 0 时利润率按 0 展示。所有金额使用 `0.0001 元`整数金额单位持久化并按四位小数展示。 - 报表按北京时间 T+1 生成,不生成当天未完整数据;每日刷新时必须在同一事务内重新生成 T-4 至 T-1 四个完整自然日,使 72 小时内到达或变化的回执能够修正发送成功和利润结果。 - API 启动后自动补生成最近四个完整自然日,并按日执行滚动刷新;报表查询支持服务端日期、企业、应用、通道和维度过滤及分页。 @@ -1506,6 +1506,12 @@ 4. 创建批次时按每条资料所属企业应用的当前生效路由规则展开所有通道;一个签名走多个通道时,必须为每个通道创建或重置独立报备任务并生成一份该通道的 `.xlsx`。无生效路由、通道未配置字段或缺少通道必填资料时,该资料继续保留在待报备池,任务进入“资料待补充”,不得伪装为已完成。 5. 通道“配置签名报备字段”和“配置引流信息字段”弹窗使用字段池,按资料类型分别配置。每列包含标准字段、通道导出表头、列顺序、必填、说明、列宽、文本转换、缺省值以及图片宽高;导出表头和列顺序必须严格使用通道配置,不受导入表格原始名称和顺序影响。 6. 通道导出文件必须为 WPS/Excel 可打开的 `.xlsx`,图片直接内嵌到对应单元格区域,而不是仅写 MinIO URL 或本地路径。批次保留所选材料版本快照、通道文件、行号和通道任务关联,可从最近批次直接下载每个通道文件。 +7. 2026-07-28 起,导入“确认”只把每一行保存为待审核明细,不得立即创建、修改或自动审核通过签名/引流信息。签名和引流审核中心分别提供“导入批次审核”页签,可查看整批行明细,一次通过全部、通过勾选项或驳回勾选项;审核通过后才将该行应用到真实业务对象并进入正常待报备流程,行校验失败不得阻断同批其他行。 +8. 导入审核必须同时支持新增和修改:页面明确展示每行操作类型、原对象、目标资料、错误原因和审核结果。驳回原因可选,不得因未填写原因阻止常用批量操作;审核人、审核时间和实际处理结果必须落 PostgreSQL。 +9. “待生成报备批次”页面由“待生成资料”和“已生成批次”两个页签组成。两个页签均使用后端分页,并可按关键字和时间范围查询;待生成资料可继续按签名/引流类型筛选。 +10. 已生成批次展示报备总数、成功数和成功率。报备总数以批次导出文件中的通道报备明细数为准,成功数以对应通道报备任务当前为通过的明细数为准;不展示“已导入回执”“等待回执”等当前无明确业务需求的字段。 +11. 报备明细是一条签名或引流信息在一个具体通道上的当前报备状态。常用操作为逐条人工修改状态,弹窗只要求选择目标状态,修改原因可选;详情展示所属企业/应用、来源批次、导出文件行号和时间顺序的状态轨迹。 +12. 本阶段不提供新的回执导入入口,不实现批量回执文件规范、解析或自动状态覆盖。已有历史数据和后端兼容代码保留以便追溯,后续只有在回执文件格式、匹配键、批量结果语义和异常处理规则明确后再立项。 ## HTTP 客户接口第一版 ### 管理端企业应用配置 @@ -1703,6 +1709,7 @@ ## 2026-07-26 企业应用停用与回执清算补充要求 - 删除企业前必须检查其企业应用;只要存在`active`或`disabling`应用就阻止删除,并提示先完成应用停用。 +- 删除企业前还必须在企业账户事务锁内检查真实余额;余额大于或小于 0 均禁止删除,并提示“完成余额清算后方可删除,请给企业充值到金额为0”。只有余额恰好为 0 且不存在启用或停用中的企业应用时才允许逻辑删除,避免删除检查与并发充值、扣费或退款竞争。 - 点击停用应用时,系统必须统计尚未收到供应商回执的短信、待发送回执、等待`CMPP_DELIVER_RESP`的回执、仍可重试的失败投递及待投递上行。 - 无待清算数据时,应用直接转为`disabled`并断开该账号的全部下游CMPP连接;存在待清算数据时,运营可选择“等待回执后停用”或“强制停用并断开连接”。 - “等待回执后停用”将应用转为`disabling`:立即拒绝新短信Submit,但保留或允许下游账号重新连接以接收历史回执;待清算数据归零后自动停用。 @@ -1770,3 +1777,24 @@ 5. 通道测试短信处于回执等待状态时与普通短信使用相同状态样式,不得因“测试短信”说明显示红色失败;发送测试弹窗的接入号和网关密码必须使用独立表单名称及自动填充语义,避免密码管理器串填。 6. 企业应用的通道组选择控件不得突破卡片宽度;通用输入控件在有无提示文案时控制区顶部对齐。 7. 分片补偿审计按`createdAt`从早到晚展示,并显示审计时间;同一时刻按分片序号和主键稳定排序。 + +## 2026-07-28 运营端列表与审核详情补充要求 + +1. 报备字段库的“被通道引用”只统计仍未删除的通道,并按不同通道去重;已删除通道的历史映射不再阻止字段删除。 +2. 通道报备详情只展示仍未删除的企业签名;已删除签名的历史报备记录继续保留在数据库审计链路中,但不进入当前业务列表。 +3. 运营看板“今日企业消费”只排行仍未删除的企业;消费金额继续按北京时间当天真实已计费短信聚合。 +4. 短信审核列表展示发送企业和企业应用,不展示审核任务号和审核原因;任务号、原因及号码明细保留在详情和查询能力中。 +5. 短信任务进度的号码数量提供“查看列表”入口,号码弹窗必须从真实短信记录服务端分页和搜索,并展示手机号、归属地、运营商和短信状态。 +6. 运营端企业签名的引流资料只保留“引流url或号码”业务字段,不再要求填写独立“引流信息”;新增和编辑均以该字段作为真实保存与检索值。 +7. 审核中心各审核页面的查看入口统一命名为“详情”;详情必须展示审核时间和审核人员用户名。运营自动通过显示“系统自动”,无法追溯审核人的历史记录显示“-”,不得伪造人员。 +8. 企业管理列表不展示实现来源类注释,不向运营人员暴露“数据来自真实接口”等研发说明。 +## 数据统计:签名在通道与运营商维度的发送质量 + +- 数据统计页默认查询北京时间当天,并允许选择任意一个不晚于今天的自然日。 +- 仅统计短信记录已关联平台签名的记录;正文中出现但企业未在平台登记、未形成`signatureId`关联的签名不纳入本统计。 +- 签名总览按业务短信记录统计发送量、送达成功、送达失败、提交失败、成功率和平均到达时间;同一业务短信在总览中只计算一次。 +- 签名通道明细按真实`SmsSubmitRecord`提交尝试统计。短信发生切换通道补发时,各次提交分别归入实际通道,因此通道提交次数允许大于业务短信数。 +- 运营商取短信发送时识别并持久化的真实号码运营商;通道与运营商是实际发送组合,不假设一个通道只支持一个运营商,也不把通道永久挂在某一个运营商下。 +- 查看签名明细时使用“通道行 × 运营商列”矩阵,每个有数据的单元格展示提交次数、成功率、平均到达时间及提交失败数量;所选日期无真实提交显示`—`,不据此推断通道不支持该运营商。 +- 平均到达时间只统计成功送达的提交尝试,从该通道提交受理时间开始,到该通道成功回执完成为止;长短信以全部成功分片完成时间为准。 +- 签名列表提供签名、企业或应用关键字查询和后端分页;查看详情不应依赖前端模拟数据或浏览器本地存储。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index bca8db7..0cdc5ef 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -1591,6 +1591,8 @@ - 失败分类分布来自后端聚合,筛选后列表与统计同步变化。 - 导出文件来自真实后端接口,包含失败分类字段,内容与当前筛选结果一致。 - 页面刷新后恢复状态仍然存在,可继续用于生产排查。 + - 页面解释恢复状态与逐条下游投递记录的用途差异;恢复状态和下游投递记录默认均选择近 7 天。 + - 恢复状态按更新时间区间筛选,摘要、失败分类、列表和 CSV 导出使用同一时间口径;列表标题与外框保持正常内边距,最后错误/跳过原因列具备可读宽度。 ### TC-GW-025 多 Gateway 恢复抢占协调 @@ -3443,7 +3445,7 @@ npm run verify:phase8 | 用例 | 细化执行点 | 必查断言 | | --- | --- | --- | | TC-REPORT-001 | 在同一发送日准备多个企业和应用的单条、长短信,覆盖 delivered、failed、unknown;次日执行报表刷新并按日期、企业、应用查询对账单。 | 只生成 T-1 及更早完整日期;发送和成功均按 `billingUnits` 汇总;成功只包含最终 delivered;企业与应用隔离正确;API 使用 PostgreSQL 报表表和服务端分页。 | -| TC-REPORT-002 | 准备已扣费成功、最终失败退款、同通道成功和跨通道补发成功短信,分别按企业应用和通道查看利润报表。 | 企业应用消费只包含 charged;退款不算收入;所有 accepted 尝试均按成本快照计入成本;通道维度收入只归属最终提交且不重复;利润=消费-成本,利润率计算正确,收入为 0 时显示 0%。 | +| TC-REPORT-002 | 准备短短信成功、三分片长短信仅两片成功、最终失败退款、同通道成功和跨通道补发成功短信,分别按企业应用和通道查看利润报表。 | 企业应用消费只包含 charged;退款不算收入;成本严格等于各次提交的通道成本单价快照乘以该次成功分片数,失败和未知分片成本为0;通道维度收入只归属最终提交且不重复;利润=消费-成本,利润率计算正确,收入为0时显示0%。 | | TC-REPORT-003 | 首次生成后,在 T-3 短信上补录 delivered 回执并将另一条 T-2 短信最终失败退款,再执行次日定时刷新。 | 每次刷新准确覆盖 T-4、T-3、T-2、T-1;对应日期旧行在事务内重建,成功数、消费、利润同步修正;T-5 及更早报表不被本次任务改写。 | | TC-REPORT-004 | 先按成本价发送并 accepted,再修改通道单价,随后生成和重复刷新报表。 | `SmsSubmitRecord.costUnitPrice/costAmountCents` 保存提交时快照;历史成本不随通道当前单价变化;新提交使用新单价。 | | TC-REPORT-005 | 打开运营端菜单和两张报表,切换日期、企业、应用及通道维度并翻页。 | “报表对账”位于“数据详单”之后且包含两个二级菜单;筛选和分页调用真实 `/admin/reports/*` API;页面展示生成时间及 T+1/T-4~T-1 口径,不使用 mock、静态数组或 localStorage 数据。 | @@ -3593,14 +3595,19 @@ npm run verify:phase8 | 用例编号 | 操作 | 预期结果 | | --- | --- | --- | -| TC-REPORT-MATERIAL-IMPORT-001 | 将含两行表头、文本列和营业执照/身份证等内嵌图片的 WPS 在线表格另存为 `.xlsx`,选择企业、应用和签名资料后解析。 | NestJS 读取真实工作表及图片锚点,返回列、组合表头、前十行和图片数预览;原文件写 MinIO,导入批次写 PostgreSQL;未确认前不改签名、不建通道任务。 | -| TC-REPORT-MATERIAL-IMPORT-002 | 将源列分别映射到短信签名、签名用途和动态报备字段,调整数据类型/必填/转换规则,保存映射方案后确认导入;再用列顺序不同但表头相同的文件复用方案。 | 新签名或已存在签名的资料真实入库,内嵌图片拆出并写 MinIO 引用,材料版本递增且进入待报备池;映射方案持久化并可再次选择,源列顺序不影响目标字段。 | -| TC-REPORT-MATERIAL-IMPORT-003 | 导入引流资料,将所属签名、站点、URL、备注和动态图片映射后确认;其中一行引用不存在或未审核签名。 | 合法行创建/更新真实 `SmsDrainageInfo` 并进入待报备池;非法行记录行号和原因,批次为部分失败,不因单行错误回滚其他合法行,也不自动创建报备任务。 | +| TC-REPORT-MATERIAL-IMPORT-001 | 将含两行表头、文本列和营业执照/身份证等内嵌图片的 WPS 在线表格另存为 `.xlsx`,选择企业、应用和签名资料后解析。 | NestJS 读取真实工作表及图片锚点,返回列、组合表头、前十行和图片数预览;原文件写 MinIO,导入批次写 PostgreSQL;解析和提交审核均不直接修改签名、不建通道任务。 | +| TC-REPORT-MATERIAL-IMPORT-002 | 将源列映射到签名及动态报备字段后提交导入,再进入短信签名审核的“导入批次审核”页签查看100行数据并一次通过其中勾选的多行。 | 每行先以新增/修改/无效状态落待审核明细;只有通过行才创建或修改真实签名并写审核人、审核时间,随后进入待生成资料池;未选行保持待审核,页面不要求逐行打开确认。 | +| TC-REPORT-MATERIAL-IMPORT-003 | 导入引流资料,其中一行引用不存在或未审核签名;在引流审核页批量通过合法行并驳回部分行,不填写驳回原因。 | 合法行审核通过后创建/更新真实 `SmsDrainageInfo` 并进入待生成池;非法行保留行号和原因;空驳回原因可正常提交,同批其他行不受影响,也不自动创建通道报备任务。 | +| TC-REPORT-MATERIAL-IMPORT-004 | 导入文件中同时包含已存在对象的修改和不存在对象的新增,提交审核前后分别读取业务表。 | 提交审核前业务表完全不变;审核页展示新增/修改及原数据快照;通过后才应用变更,重复点击已处理行不会再次递增材料版本或重复创建对象。 | +| TC-REPORT-MATERIAL-IMPORT-005 | 分别在签名和引流审核页面按文件名、状态、时间筛选导入批次,翻页后选择整批或部分明细审核。 | 查询、总数和分页来自真实后端;批次汇总待审、通过、驳回、无效数量,刷新后保持一致。 | | TC-REPORT-CHANNEL-FIELD-001 | 在同一通道分别打开签名和引流字段配置,添加字段、修改通道表头、上下排序、设置必填/列宽/图片宽高后保存并刷新。 | 两类配置相互独立且完整持久化;刷新后字段池、映射表头和顺序一致;重复字段、停用字段和非法尺寸由 API 拒绝或归一化。 | | TC-REPORT-BATCH-001 | 一个应用配置两个生效通道,选择一个待报备签名创建统一批次。 | 系统从真实应用路由展开两个通道,生成两个独立通道任务和两个 `.xlsx`;每个文件表头名称、列顺序和列宽均来自对应通道配置,批次可下载两份文件。 | | TC-REPORT-BATCH-002 | 两个通道对同一标准字段配置不同表头和顺序,并包含图片列,生成批次后分别用 WPS 打开。 | 两份工作簿各自使用对应通道映射,图片直接显示在数据行内且尺寸按通道配置;文件不是 URL 清单,文本与图片属于同一材料快照。 | | TC-REPORT-BATCH-003 | 分别制造无生效路由、通道未配置字段、缺少通道必填图片,再创建批次。 | 对应资料不会清除待报备标记;有通道但资料不全时任务为 `waiting_material` 并记录原因;批次为部分失败,无任何假成功任务。 | | TC-REPORT-BATCH-004 | 同一签名修改资料后再次选择生成批次。 | 材料版本递增;复用同一签名/通道任务并重置到新一轮状态,批次项目保留当次版本和快照,历史导出文件仍可追溯。 | +| TC-REPORT-BATCH-005 | 打开“待生成报备批次”,分别切换“待生成资料”和“已生成批次”,按时间范围和关键字查询并翻页。 | 两个页签均使用后端分页与时间查询;切换、重置筛选后查询条件正确,不读取上一页签的旧条件。 | +| TC-REPORT-BATCH-006 | 生成包含3条通道报备明细的批次,将其中2条任务人工改为通过后刷新已生成批次。 | 批次显示报备总数3、成功数2、成功率66.67%;不显示已导入回执或等待回执。 | +| TC-REPORT-TASK-STATUS-001 | 在报备明细页逐条修改签名或引流信息的通道状态,分别填写和不填写修改原因。 | 两种操作均成功;状态和时间轨迹写真实任务/记录,原因空时不阻断提交;页面无生成同范围任务和导入回执入口。 | ### 17.13 Gateway 提交异常与通道级 TPS 限速 @@ -3881,6 +3888,7 @@ npm run verify:phase8 | 用例编号 | 场景 | 预期结果 | |---|---|---| | TC-TENANT-DELETE-001 | 删除仍有启用或停用中应用的企业 | 后端拒绝删除,确认弹窗保持打开,并在弹窗内显示应用数量及先停用应用的原因 | +| TC-TENANT-DELETE-001A | 分别删除账户余额为正数、负数和0的企业,并在删除检查期间并发发起充值 | 正数和负数均被后端拒绝,弹窗提示“完成余额清算后方可删除,请给企业充值到金额为0”;余额为0且无活动应用时才允许删除;账户事务锁保证删除检查与余额变更不发生竞态 | | TC-TENANT-DELETE-002 | 删除请求处理中重复点击或关闭弹窗 | 确认、取消和关闭均被禁用,不产生重复请求 | | TC-TENANT-DELETE-003 | 删除无阻塞依赖的企业 | 删除成功后才关闭弹窗,并刷新企业列表 | @@ -3947,3 +3955,33 @@ npm run verify:phase8 | TC-APP-ROUTE-WIDTH-010 | 企业应用通道组名称很长,分别使用桌面和窄屏 | 选择框及下拉选项不超出通道组卡片,长文本省略且可正常选择 | | TC-USER-ADMIN-011 | 删除/停用企业最后一个管理员,再删除/停用平台最后一个管理员 | 企业管理员操作成功并写日志;平台管理员操作仍返回`LAST_PLATFORM_ADMIN` | | TC-INPUT-ALIGN-012 | 打开新增用户弹窗,对比有提示和无提示的文本输入框 | 标签和输入控制区顶部对齐,提示文本仅占自身下方空间 | + +## 2026-07-28 运营端列表与审核详情回归用例 + +| 用例编号 | 场景 | 预期结果 | +|---|---|---| +| TC-REPORT-FIELD-ACTIVE-001 | 同一报备字段被一个有效通道重复配置,并被一个已删除通道引用 | 引用数按有效通道去重后为1;删除通道不计数 | +| TC-REPORT-FIELD-ACTIVE-002 | 报备字段只剩已删除通道的历史映射 | 引用数为0,字段可删除,同时清理失效映射,不影响历史通道审计数据 | +| TC-CHANNEL-REPORT-SIGNATURE-003 | 通道同时存在有效签名和已删除签名的报备任务 | 报备详情只展示有效签名,已删除签名不再进入当前列表 | +| TC-DASHBOARD-SPEND-009 | 已删除企业和有效企业当日均有charged计费记录 | 今日企业消费只显示有效企业,金额与真实计费聚合一致 | +| TC-SMS-AUDIT-LIST-010 | 打开短信审核列表及任意详情 | 列表展示企业和企业应用,不展示审核任务号、审核原因;详情仍可查看任务号、原因和号码 | +| TC-SMS-TASK-PHONES-011 | 在短信任务进度点击号码数量,搜索号码并切换页码、每页条数 | 打开真实号码列表;手机号、归属地、运营商、状态来自服务端,搜索和分页总数准确 | +| TC-DRAINAGE-FIELD-012 | 运营端新增和编辑企业签名引流资料 | 弹窗仅显示“引流url或号码”,不显示“引流信息”;保存、刷新和搜索均使用真实后端值 | +| TC-AUDIT-DETAIL-013 | 依次打开企业认证、短信、模板、签名、引流信息审核 | 查看按钮统一为“详情”;所有详情展示审核时间和审核人员用户名,自动审核与历史缺失值展示准确 | +| TC-CUSTOMER-NOTE-014 | 打开运营端企业管理 | “企业列表”下不出现“数据来自租户、账户真实接口。”研发说明 | +## 数据统计:签名通道与运营商发送质量 + +| 用例编号 | 场景 | 预期结果 | +| --- | --- | --- | +| TC-ANALYTICS-SIGNATURE-001 | 不传日期进入数据统计页 | 默认使用北京时间当天,签名列表与页面顶部统计日期一致 | +| TC-ANALYTICS-SIGNATURE-002 | 选择历史自然日后查询 | 总览、签名列表和矩阵全部切换至所选日期 | +| TC-ANALYTICS-SIGNATURE-003 | 短信正文有签名但`signatureId`为空 | 该记录不进入已登记签名统计 | +| TC-ANALYTICS-SIGNATURE-004 | 同一签名分别通过移动、联通、电信发送 | 明细按实际运营商分别形成矩阵列 | +| TC-ANALYTICS-SIGNATURE-005 | 同一通道实际发送多个运营商号码 | 同一通道行的多个运营商单元格分别展示真实数据 | +| TC-ANALYTICS-SIGNATURE-006 | 同一短信首通道失败并切换下一通道 | 业务短信只计1条,两个通道各计1次提交,通道提交总数为2 | +| TC-ANALYTICS-SIGNATURE-007 | 供应商提交拒绝或超时 | 计入提交失败,不混入已受理短信的送达失败率分母 | +| TC-ANALYTICS-SIGNATURE-008 | 已受理短信收到失败回执 | 计入该通道与运营商组合的送达失败 | +| TC-ANALYTICS-SIGNATURE-009 | 长短信全部分片成功 | 以最后成功分片时间计算该提交的到达耗时 | +| TC-ANALYTICS-SIGNATURE-010 | 关键字查询签名、企业或应用 | 后端返回匹配签名并保持总数和分页正确 | +| TC-ANALYTICS-SIGNATURE-011 | 点击“查看明细” | 打开右侧详情抽屉,展示运营商概览及通道×运营商矩阵;Esc、关闭按钮和遮罩均可关闭 | +| TC-ANALYTICS-SIGNATURE-012 | 所选日期没有已登记签名发送 | 返回真实空状态,不显示演示或历史日期数据 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index ff2d948..94d11d5 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -2579,3 +2579,65 @@ git diff --check - 部署后真实数据库直接验证待审核签名`【安徽航天信息】`返回`allowedActions=["approve","reject"]`、`blockedReasons=[]`且配置必填字段数为0,证明不再套用旧固定资格项。`SmsSubmitRecord`共530条,其中512条已回填通道组ID及名称;剩余18条无法唯一归因的测试或历史提交保持空值,未伪造归因。真实`OperationsService`可返回当天charged计费企业消费排行(首位“启瑞中转企业”77220内部计费单位)及包含`acceptedCount/submitFailureCount`的签名统计。 - `.deployed-commit=7c1a0287a0b68e6f3ecdbd16243dd05bb8a263e1`;API、Gateway、Nginx、PostgreSQL、MinIO均active,Redis`PONG`,`12026/17890/8090/3000/6379/5432/9000`均监听。Gateway重启后3个客户CMPP账号因旧进程连接行尚在90秒心跳期限内首次重连收到连接数限制,超时清理后均由客户端自动重连成功,最终4个客户CMPP应用均有实时心跳;4个启用供应商通道也全部恢复`connected/currentConnections=1/desiredConnections=1`。Redis Stream消费者1、`pending=0`、`lag=0`。 - 公网首页、运营端、客户端和API health均HTTP 200,公网CMPP 17890 TCP可连接。应用内浏览器验证运营登录页标题为“聆界短信管理平台”,1280px视口`scrollWidth=clientWidth=1280`且控制台0条error/warn;当前无可接管的已登录会话且页面存在图形验证码,未绕过认证,因此十项登录后交互的最终可见验收保留为持有有效运营会话后的人工复核项。本轮未发送真实测试短信。 + +## 2026-07-28 运营端有效数据口径与审核详情补齐(本地未提交) + +- 报备字段库引用数改为只统计未删除通道并按通道去重;字段只剩已删除通道历史映射时可正常删除,并同步清理失效映射。通道报备详情同时排除已删除签名。 +- 运营看板今日企业消费在原有北京时间当日真实charged计费口径上排除已删除企业。 +- 短信审核列表改为展示发送企业和企业应用,移除审核任务号与审核原因列;详情仍保留任务、原因和真实号码明细。 +- 短信任务进度号码数量新增真实列表入口,后端按批次提供手机号搜索、分页及手机号、归属地、运营商、短信状态字段,不在浏览器内截断或拼装。 +- 企业签名引流资料新增/编辑统一为“引流url或号码”,移除独立“引流信息”输入;企业管理移除“数据来自租户、账户真实接口。”研发注释。 +- 企业认证、短信、模板、签名、引流信息审核入口统一为“详情”,缺失详情入口的页面已补齐;所有详情展示审核时间和审核人员用户名。新审核动作保存真实审核用户,历史缺失审核人不伪造。 +- Node.js v24.14.0下前端TypeScript、API TypeScript和Vite生产构建通过;字典、通道、运营统计、风控审核、企业认证5 suites / 94 tests及发送链1 suite / 101 tests定向通过,临时本地Redis下API全量26 suites / 358 tests通过,测试结束后已停止临时Redis;`git diff --check`通过。应用内浏览器访问本地生产构建的短信审核路由时,真实鉴权守卫跳转运营登录页;本地预览未连接API而返回502,且图形验证码阻止登录后页面验收,未绕过认证或将登录页冒充目标页面。 +- 本轮按用户要求保持未提交、未推送、未部署;既有构建缓存、`outputs/`及空文件`=`继续作为其他会话/历史临时产物保留。 + +## 2026-07-28 签名/引流导入审核与报备批次页面重构(本地未提交) + +- Excel 导入从“确认后直接写业务对象”改为真实待审核明细:新增 `ReportMaterialImportItem` 保存行号、新增/修改类型、目标对象、资料载荷、原快照、校验错误、审核人及审核时间。提交导入只进入 `pending_review`,不会创建、修改或自动审核通过签名/引流信息。 +- 短信签名审核和引流信息审核分别增加“导入批次审核”页签,支持按批次查看行明细、勾选多行或整批通过、批量驳回;驳回原因可选。审核通过后才复用真实短信配置服务应用新增/修改并进入正常待报备流程,非法行独立记录错误。 +- 企业签名管理新增“批量导入签名及引流资料”入口;导入弹窗文案明确为提交审核,不再从待生成资料页混入导入操作。 +- “待生成报备批次”改为“待生成资料/已生成批次”两个页签,两个页签均提供后端分页、关键字和时间查询;筛选重置使用显式空条件重新查询,避免 React 状态异步导致旧条件残留。 +- 已生成批次按真实导出文件明细统计报备总数,按关联通道任务当前通过状态统计成功数和成功率;移除已导入回执、等待回执等无明确当前需求的展示。 +- 原“报备任务”页面收敛为逐通道“报备明细”,移除无真实产物的“生成同范围任务”和回执导入入口。人工状态弹窗只选择目标状态,原因可选;详情展示企业、应用、来源批次/文件行号和按时间顺序排列的状态轨迹。历史回执表及后端兼容接口暂不删除,避免破坏既有数据追溯。 +- 新增 migration `20260728153000_stage_report_material_import_reviews`。Node.js v24.14.0 下报备资料与通道服务定向 2 suites / 51 tests 通过,覆盖导入仅暂存不改业务对象、无原因驳回、批次总数/成功数/成功率及通道报备关联读取;临时本地 Redis 下 API 全量 26 suites / 361 tests 通过,测试结束后已停止临时 Redis。前端/API TypeScript、Prisma validate、Vite 生产构建及 `git diff --check` 通过,Vite 只保留既有大 chunk 提示。 +- 应用内浏览器连接失败后按浏览器控制技能切换到可用 Chrome,在本地生产构建访问 `/admin/report-materials`;前端路由和登录守卫正常,但本地未启动 NestJS API,认证请求返回 502 并跳转登录页,且没有可接管的已登录运营会话。未绕过验证码,因此双页签、批次审核和人工状态弹窗的登录后视觉验收仍需有效会话复核,不能以登录页冒充完成。 +- 本轮按用户要求保持未提交、未推送、未部署;工作区其他会话及历史未提交修改继续原样保留。 + +## 2026-07-28 企业营业执照、利润成本与删除余额门禁(本地未提交) + +- 新建/编辑企业共用页面将“企业照片”统一改为“企业营业执照”,同步调整已上传占位、上传按钮、默认文件名和失败提示;对象存储既有 purpose/prefix 保持兼容,不迁移历史文件。 +- 利润报表成本改为“提交时通道成本单价快照 × 该次提交成功短信分片数”。新数据优先按 `SmsMessageSegmentAudit` 中 `receiptStatus=delivered` 的分片数统计;历史缺少分片审计但存在明确成功回执时按短信计费分片数兼容,失败、未知和未收到成功回执的分片不计成本。企业应用和通道两个利润维度使用同一口径,利润及利润率随成本同步重算。 +- 企业删除在与充值、扣费、退款相同的 `tenant-account:` PostgreSQL 事务锁内读取真实账户余额;余额非0时拒绝删除并提示“完成余额清算后方可删除,请给企业充值到金额为0”,余额为0后仍继续执行活动企业应用门禁。 +- Node.js v24.14.0 下报表和企业服务定向 2 suites / 14 tests、API 全量 26 suites / 364 tests、前端与 API TypeScript、Vite 生产构建及 `git diff --check` 通过;Vite 仅保留既有大 chunk 提示。 +- 本地 PostgreSQL 启动后确认目标为 `localhost:5432/cmpp_platform`,应用至75条 migration;本地 API 在3000端口、前端生产预览在4173端口、Redis在6379端口运行,健康接口和页面均返回 HTTP 200。真实 PostgreSQL 已使用新成本 SQL 成功重算 2026-07-24 至 2026-07-27,未出现 SQL 语法或字段关联错误。 +- 本地未启动 Gateway,API 启动时本地库两个历史 active 通道的恢复连接请求按预期失败;发送 worker 和回执超时扫描已关闭,不连接供应商、不发送短信,不影响运营页面查看。 +- 本轮继续保留为未提交、未推送、未部署状态;本地服务按用户要求保持运行。 + +## 2026-07-28 恢复状态与下游投递近七天筛选(本地未提交) + +- “恢复状态管理”补充用途说明:该页按客户账号展示 Gateway 在客户重连或实例重启后,对未完成状态回执和上行短信续投的当前/最近一次恢复状态;逐条消息的投递、重试与客户端 ACK 仍在“下游投递记录”查看。 +- 恢复状态增加按最近更新时间的真实后端时间区间筛选,默认今天在内的近 7 个自然日;摘要、失败分类、分页列表和 CSV 导出统一使用同一筛选条件,重置后恢复默认近 7 天。 +- 恢复状态列表标题区补充卡片内边距,避免标题紧贴外框;“最后错误/跳过原因”列固定为 320px 可读宽度。下游投递记录创建日期默认及重置均改为近 7 天。 +- Operations 专项 1 suite / 22 tests 通过,覆盖恢复状态列表和 CSV 导出的北京时间起止边界;前端 TypeScript、API 正式构建配置 TypeScript、Vite 生产构建及 `git diff --check` 通过,Vite 仅保留既有大 chunk 提示。真实本地 PostgreSQL 使用 2026-07-22 至 2026-07-28 条件执行恢复状态查询成功,本地库当前返回 0 条。 +- API 全量 Jest 在本轮等待 120 秒后仍未结束且未输出最终汇总,确认遗留测试进程仍在运行后已只停止该本轮测试进程;不把它记录为通过或失败。专项测试及正式构建结果不受影响。 +- 本地 API 已重启并在 3000 端口健康运行,前端生产预览继续在 4173 端口运行;Gateway 未启动,发送 worker 与回执超时扫描保持关闭。本地浏览器已打开运营登录页,受图形验证码保护,未绕过认证。 +- 本轮保持未提交、未推送、未部署,工作区其他会话及历史未提交修改继续原样保留。 + +## 2026-07-28 签名通道与运营商发送质量(本地未提交) + +- 数据统计页新增已登记签名发送质量区域,默认北京时间当天并跟随现有日期查询;支持按签名、企业或应用关键字查询及后端分页。未关联`signatureId`的正文签名按用户最新决定不纳入统计。 +- 签名主表按业务短信记录展示业务短信、送达成功、送达失败、提交失败、最终成功率和平均到达时间;通道提交数按真实`SmsSubmitRecord`尝试统计,明确允许补发时大于业务短信数。 +- “查看明细”使用右侧大尺寸抽屉,先按移动、联通、电信汇总,再以通道为行、运营商为列展示提交次数、成功率、平均到达时间和提交失败;同一通道可同时出现多个运营商,不建立错误的一对一归属关系。 +- 新增独立真实后端接口`GET /admin/operations/signature-quality`,签名汇总只连接平台`SmsSignature`,通道质量复用分片审计及供应商回执完成口径;平均到达时间仅统计成功送达尝试,长短信以全部成功分片完成为准。 +- Operations定向1 suite / 24 tests通过,新增覆盖分页签名矩阵合并和真实空状态;API TypeScript正式构建、前端TypeScript检查及Vite生产构建通过,Vite仅保留既有约1.98MB单chunk提示。 +- 新SQL已对本地PostgreSQL真实执行。为便于本地视觉验收,新增必须显式设置`ALLOW_LOCAL_SIGNATURE_QUALITY_DEMO=true`、只允许localhost数据库且在`NODE_ENV=production`下无条件拒绝执行的演示数据脚本,并写入明确标记的“本地统计演示企业/应用”、3个已登记签名、54条业务短信、61次通道提交和51条回执;演示通道均为inactive,Gateway未启动,不连接供应商、不发送短信。 +- 本地API和前端生产预览分别运行于3000、4173端口,健康检查及数据统计页均HTTP 200。应用内浏览器以真实本地运营会话验证签名列表、运营商概览和通道×运营商矩阵;窄窗口下矩阵横向滚动已限制在矩阵内部,不再撑宽整个详情抽屉。 +- 本轮按用户要求只运行在本地,保持未提交、未推送、未部署;工作区其他会话已有未提交修改继续保留。 + +## 2026-07-28 工作区汇总发布门禁 + +- 本次汇总范围包含:报备字段和有效通道引用口径、审核详情与号码列表、签名/引流资料导入审核、待生成资料与已生成批次双页签、报备明细人工状态、企业营业执照文案、利润成功分片成本、企业余额删除门禁、恢复状态和下游投递近7天筛选,以及签名通道×运营商发送质量统计。 +- 演示数据脚本仅作为本地视觉验收工具纳入源码,不属于部署初始化或migration;脚本必须显式设置`ALLOW_LOCAL_SIGNATURE_QUALITY_DEMO=true`、数据库主机必须为localhost,并在`NODE_ENV=production`时无条件拒绝执行,预发布部署不会写入演示数据。 +- 发布前重新确认`HEAD`与`origin/main`均为`352a6293b47f95653fbb079f2cf528fba4d59818`,工作区修改来自前序多个会话,按用户明确要求统一归入本次发布;`outputs/`、空文件`=`、`api/tsconfig.build.tsbuildinfo`和`tsconfig.tsbuildinfo`继续作为构建或临时产物排除。 +- 首次API全量测试因本地Redis未运行导致3个发送链用例连接等待超时;恢复本地Redis后重跑,API全量26 suites / 366 tests全部通过。Prisma format/validate/generate、API TypeScript正式构建、前端TypeScript/Vite生产构建、Gateway `go test ./...`与`go vet ./...`、依赖安全门禁和`git diff --check`均通过;Vite仅保留既有大chunk提示,Jest保留既有`--forceExit`异步句柄提示。 +- 本节为提交和部署前门禁记录;提交、推送、备份、migration、服务重启和预发布验收结果在发布完成后补记。 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index e6de39c..4b4da86 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -229,9 +229,24 @@ export type EnterpriseCertification = { rejectReason?: string | null; submittedAt: string; reviewedAt?: string | null; + reviewer?: { id: string; username: string; displayName: string } | null; tenant?: { id: string; name: string; code: string }; }; +export type AuditRecord = { + id: string; + tenantId?: string | null; + targetType: string; + targetId: string; + action: string; + statusBefore?: string | null; + statusAfter: string; + reason?: string | null; + reviewerId?: string | null; + reviewer?: { id: string; username: string; displayName: string } | null; + createdAt: string; +}; + export type SmsTemplateAudit = { id: string; tenantId: string; @@ -384,6 +399,47 @@ export type SignatureQualityStat = { averageArrivalMs?: number | null; }; +export type SignatureChannelCarrierQualityStat = { + signatureId: string; + channelId: string; + channelName: string; + carrier: string; + total: number; + acceptedCount: number; + submitFailureCount: number; + successCount: number; + unknownCount: number; + failureCount: number; + successRate: number; + averageArrivalMs?: number | null; +}; + +export type SignatureChannelQualityItem = { + signatureId: string; + signatureName: string; + tenantId: string; + tenantName: string; + applicationNames?: string | null; + total: number; + acceptedCount: number; + submitFailureCount: number; + successCount: number; + unknownCount: number; + failureCount: number; + successRate: number; + averageArrivalMs?: number | null; + channelSubmitTotal: number; + breakdowns: SignatureChannelCarrierQualityStat[]; +}; + +export type SignatureChannelQualityResponse = { + date: string; + items: SignatureChannelQualityItem[]; + total: number; + page: number; + pageSize: number; +}; + export type DailySendSummary = { total: number; successCount: number; @@ -828,6 +884,61 @@ export type ReportMaterialPendingItem = { application?: ClientSmsApplication | null; }; +export type PagedResult = { + items: T[]; + total: number; + page: number; + pageSize: number; +}; + +export type ReportMaterialBatch = { + id: string; + batchNo: string; + status: string; + selectedCount: number; + channelCount: number; + fileCount: number; + reportTotal: number; + successCount: number; + successRate: number; + createdAt: string; + completedAt?: string | null; + exportFiles: Array<{ id: string; fileObjectId?: string | null; fileName: string; rowCount: number; channelId?: string | null }>; +}; + +export type ReportImportReviewItem = { + id: string; + rowNumber: number; + reportType: 'signature' | 'drainage'; + operation: 'create' | 'update' | 'invalid'; + targetId?: string | null; + status: string; + payload: Record; + originalSnapshot?: Record | null; + errorMessage?: string | null; + reviewReason?: string | null; + reviewedAt?: string | null; + reviewer?: { id: string; username: string; displayName: string } | null; +}; + +export type ReportImportReviewBatch = { + id: string; + tenantId: string; + applicationId?: string | null; + fileName: string; + reportType: 'signature' | 'drainage'; + status: string; + rowCount: number; + successCount: number; + failedCount: number; + createdAt: string; + reviewedAt?: string | null; + tenant?: { id: string; name: string } | null; + application?: { id: string; name: string } | null; + reviewer?: { id: string; username: string; displayName: string } | null; + items: ReportImportReviewItem[]; +}; + export type ReportMaterialPreflightTarget = { id: string; name: string; @@ -925,9 +1036,33 @@ export type ReportTask = DictionaryItem & { reportType?: 'signature' | 'drainage'; drainageItemId?: string | null; status: string; - signature?: { id: string; name: string; purpose?: string | null; drainageInfo?: Record | null }; + signature?: { + id: string; + name: string; + purpose?: string | null; + drainageInfo?: Record | null; + tenant?: { id: string; name: string }; + application?: { id: string; name: string } | null; + }; drainageInfo?: SmsDrainageInfo | null; channel?: { id: string; name: string; code: string }; + reason?: string | null; + createdAt?: string; + updatedAt?: string; + exportItems?: Array<{ + id: string; + rowNumber: number; + exportFile: { id: string; fileObjectId?: string | null; fileName: string; rowCount: number; batchId?: string | null }; + batchItem: { id: string; materialVersion: number; batch: { id: string; batchNo: string; createdAt: string } }; + }>; + records?: Array<{ + id: string; + action: string; + statusBefore?: string | null; + statusAfter: string; + reason?: string | null; + createdAt: string; + }>; deliveryStats?: { total: number; acceptedCount: number; @@ -1001,6 +1136,8 @@ export type RiskReviewTask = { rejectReason?: string | null; createdAt: string; reviewedAt?: string | null; + tenant?: { id: string; name: string } | null; + application?: { id: string; name: string } | null; reviewedBy?: { id: string; username: string; displayName: string } | null; riskHits?: Array<{ id: string; ruleName: string; reason: string }>; _count?: { messageRecords: number }; @@ -1036,6 +1173,8 @@ export type RiskTaskMessagePage = { pageSize: number; }; +export type BatchTaskMessagePage = RiskTaskMessagePage; + export type TenantAccount = { id: string; tenantId: string; @@ -1483,6 +1622,8 @@ export type DownstreamRecoveryStatusExportQuery = { state?: string; failureCategory?: string; keyword?: string; + updatedAtFrom?: string; + updatedAtTo?: string; }; function withQuery(path: string, query: Record) { @@ -1539,6 +1680,8 @@ export const adminApi = { request(`/admin/users/${id}/password`, { method: 'POST', body: JSON.stringify({ password, operatorId }) }), getDashboard: (tenantId?: string) => request(withQuery('/admin/operations/dashboard/statistics', { tenantId })), getSendQuality: (date?: string) => request(withQuery('/admin/operations/send-quality', { date })), + getSignatureQuality: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) => + request(withQuery('/admin/operations/signature-quality', query)), listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) => request(withQuery('/admin/system-logs', query)), listProtocolInteractionLogs: (query: { protocol?: string; direction?: string; eventType?: string; status?: string; keyword?: string; range?: string; page?: number; pageSize?: number }) => @@ -1655,6 +1798,8 @@ export const adminApi = { request(`/admin/enterprise-signatures/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }), listDrainageInfos: (query: { tenantId?: string; signatureId?: string; keyword?: string; status?: string } = {}) => request(withQuery('/admin/drainage-infos', query)), + listAuditRecords: (query: { targetType?: string; targetId?: string } = {}) => + request(withQuery('/admin/audit-records', query)), createDrainageInfo: (signatureId: string, body: { siteName: string; url: string; remark?: string; reportValues?: Record }) => request(`/admin/enterprise-signatures/${signatureId}/drainage-infos`, { method: 'POST', body: JSON.stringify(body) }), updateDrainageInfo: (id: string, body: { siteName?: string; url?: string; remark?: string; reportValues?: Record }) => @@ -1709,8 +1854,8 @@ export const adminApi = { request('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }), replaceChannelReportFields: (channelId: string, reportType: 'signature' | 'drainage', fields: Array>) => request(`/admin/channels/${channelId}/report-fields/${reportType}`, { method: 'PUT', body: JSON.stringify({ fields }) }), - listPendingReportMaterials: (query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } = {}) => - request(withQuery('/admin/report-materials/pending', query)), + listPendingReportMaterials: (query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string; keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}) => + request>(withQuery('/admin/report-materials/pending', query)), listReportImportProfiles: (reportType?: 'signature' | 'drainage') => request(withQuery('/admin/report-materials/import-profiles', { reportType })), saveReportImportProfile: (body: Omit & { id?: string }) => @@ -1724,7 +1869,12 @@ export const adminApi = { }, commitReportMaterialImport: (id: string, body: { mappings: ReportImportMapping[]; profile?: Omit & { id?: string } }) => request>(`/admin/report-materials/imports/${id}/commit`, { method: 'PUT', body: JSON.stringify(body) }), - listReportMaterialBatches: () => request>>('/admin/report-materials/batches'), + listReportImportReviewBatches: (query: { reportType?: 'signature' | 'drainage'; status?: string; keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}) => + request>(withQuery('/admin/report-materials/imports/review-batches', query)), + reviewReportImportItems: (id: string, body: { decision: 'approve' | 'reject'; itemIds?: string[]; reason?: string }) => + request<{ batchId: string; status: string; approvedCount: number; rejectedCount: number; failedCount: number }>(`/admin/report-materials/imports/${id}/review`, { method: 'POST', body: JSON.stringify(body) }), + listReportMaterialBatches: (query: { keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}) => + request>(withQuery('/admin/report-materials/batches', query)), preflightReportMaterialBatch: (body: { items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion?: number }> }) => request('/admin/report-materials/batches/preflight', { method: 'POST', body: JSON.stringify(body) }), createReportMaterialBatch: (body: { idempotencyKey: string; items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion: number }> }) => @@ -1741,6 +1891,8 @@ export const adminApi = { listReportRecords: (query: { taskId?: string; channelId?: string } = {}) => request(withQuery('/admin/report-records', query)), listAdminBatchTasks: (query: { tenantId?: string; status?: string } = {}) => request(withQuery('/admin/send/batch-tasks', query)), + listAdminBatchTaskMessages: (id: string, query: { phone?: string; page?: number; pageSize?: number } = {}) => + request(withQuery(`/admin/send/batch-tasks/${id}/messages`, query)), terminateAdminBatchTask: (id: string) => request(`/admin/send/batch-tasks/${id}/terminate`, { method: 'POST', body: JSON.stringify({}) }), listAdminMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; taskId?: string; phoneNumber?: string; status?: string } = {}) => @@ -1761,7 +1913,7 @@ export const adminApi = { listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) => request>>(withQuery('/admin/operations/statistics', query)), getDownstreamDeliveryDashboard: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; createdAtFrom?: string; createdAtTo?: string } = {}) => request(withQuery('/admin/operations/downstream-deliveries/dashboard', query)), - listDownstreamRecoveryStatuses: (query: { tenantId?: string; applicationId?: string; state?: string; failureCategory?: string; keyword?: string; page?: number; pageSize?: number } = {}) => + listDownstreamRecoveryStatuses: (query: { tenantId?: string; applicationId?: string; state?: string; failureCategory?: string; keyword?: string; updatedAtFrom?: string; updatedAtTo?: string; page?: number; pageSize?: number } = {}) => request(withQuery('/admin/operations/downstream-recovery-statuses', query)), getDownstreamRecoveryStatus: (id: string) => request(`/admin/operations/downstream-recovery-statuses/${id}`), diff --git a/src/apps/admin/AdminAnalyticsPage.tsx b/src/apps/admin/AdminAnalyticsPage.tsx index 6b3cd68..ab1156b 100644 --- a/src/apps/admin/AdminAnalyticsPage.tsx +++ b/src/apps/admin/AdminAnalyticsPage.tsx @@ -1,27 +1,77 @@ import { useEffect, useMemo, useState } from 'react'; -import { BarChart3 } from 'lucide-react'; -import { adminApi, type SendQualityResponse } from '@/api/adminApi'; -import { Breadcrumb, Button, Chart, Input, Tag } from '@/components/ui'; +import { BarChart3, Eye, Search, X } from 'lucide-react'; +import { + adminApi, + type SendQualityResponse, + type SignatureChannelCarrierQualityStat, + type SignatureChannelQualityItem, + type SignatureChannelQualityResponse, +} from '@/api/adminApi'; +import { Breadcrumb, Button, Chart, Input, Pagination, Table, Tag, type TableColumn } from '@/components/ui'; import { createBarOption, createPieOption } from '@/theme/chartOptions'; +const carrierOrder = ['mobile', 'unicom', 'telecom', 'unknown']; +const carrierLabels: Record = { + mobile: '移动', + unicom: '联通', + telecom: '电信', + unknown: '未知', +}; + export function AdminAnalyticsPage() { const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey()); const [quality, setQuality] = useState(null); + const [signatureQuality, setSignatureQuality] = useState(null); + const [signatureKeyword, setSignatureKeyword] = useState(''); + const [appliedKeyword, setAppliedKeyword] = useState(''); + const [selectedSignature, setSelectedSignature] = useState(null); + const [loading, setLoading] = useState(false); const [error, setError] = useState(''); - function loadData() { - adminApi.getSendQuality(statisticsDate) - .then((qualityData) => { - setQuality(qualityData); - setError(''); - }) - .catch((failure: Error) => setError(failure.message || '统计数据加载失败')); + async function loadData(page = 1, keyword = appliedKeyword) { + setLoading(true); + try { + const [qualityData, signatureData] = await Promise.all([ + adminApi.getSendQuality(statisticsDate), + adminApi.getSignatureQuality({ + date: statisticsDate, + keyword: keyword || undefined, + page, + pageSize: 10, + }), + ]); + setQuality(qualityData); + setSignatureQuality(signatureData); + setAppliedKeyword(keyword); + setSelectedSignature((current) => current + ? signatureData.items.find((item) => item.signatureId === current.signatureId) ?? null + : null); + setError(''); + } catch (failure) { + setError(failure instanceof Error ? failure.message : '统计数据加载失败'); + } finally { + setLoading(false); + } } useEffect(() => { - loadData(); + void loadData(1, ''); }, []); + useEffect(() => { + if (!selectedSignature) return undefined; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setSelectedSignature(null); + }; + document.addEventListener('keydown', handleKeyDown); + return () => { + document.body.style.overflow = previousOverflow; + document.removeEventListener('keydown', handleKeyDown); + }; + }, [selectedSignature]); + const applicationOption = useMemo(() => createBarOption({ labels: quality?.applications.map((item) => item.applicationName) ?? [], series: [{ name: '发送量', data: quality?.applications.map((item) => item.total) ?? [] }], @@ -32,6 +82,88 @@ export function AdminAnalyticsPage() { }), [quality]); const effectiveDate = quality?.date ?? statisticsDate; + const signatureColumns: Array> = [ + { + key: 'signature', + title: '短信签名', + width: '260px', + render: (record) => ( +
+ {record.signatureName} + {record.tenantName} + {record.applicationNames || '全部企业应用'} +
+ ), + }, + { + key: 'businessTotal', + title: '业务短信', + width: '110px', + align: 'right', + render: (record) => record.total.toLocaleString('zh-CN'), + }, + { + key: 'channelSubmitTotal', + title: '通道提交', + width: '110px', + align: 'right', + render: (record) => record.channelSubmitTotal.toLocaleString('zh-CN'), + }, + { + key: 'successCount', + title: '送达成功', + width: '110px', + align: 'right', + render: (record) => {record.successCount.toLocaleString('zh-CN')}, + }, + { + key: 'failureCount', + title: '送达失败', + width: '110px', + align: 'right', + render: (record) => {record.failureCount.toLocaleString('zh-CN')}, + }, + { + key: 'submitFailureCount', + title: '提交失败', + width: '110px', + align: 'right', + render: (record) => {record.submitFailureCount.toLocaleString('zh-CN')}, + }, + { + key: 'successRate', + title: '成功率', + width: '170px', + render: (record) => , + }, + { + key: 'averageArrivalMs', + title: '平均到达时间', + width: '140px', + align: 'right', + render: (record) => formatDuration(record.averageArrivalMs), + }, + { + key: 'actions', + title: '操作', + width: '110px', + align: 'right', + render: (record) => ( + + ), + }, + ]; + + function queryStatistics() { + void loadData(1, signatureKeyword.trim()); + } + + function changeSignaturePage(page: number) { + void loadData(page, appliedKeyword); + } + return (
@@ -40,13 +172,15 @@ export function AdminAnalyticsPage() {
setStatisticsDate(event.target.value)} type="date" value={statisticsDate} /> - +
{error ?

{error}

: null} @@ -86,10 +220,257 @@ export function AdminAnalyticsPage() { + +
+
+
+
+

签名通道发送质量

+ 已登记签名 +
+

+ {signatureQuality?.date ?? effectiveDate} 按签名查看业务结果,明细按真实通道提交尝试拆分运营商与通道。 +

+
+
+ setSignatureKeyword(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') queryStatistics(); + }} + placeholder="搜索签名、企业或应用" + value={signatureKeyword} + /> + +
+
+
+ 统计说明: + 业务短信按消息记录去重;发生补发时会产生多次通道提交,因此“通道提交”可能大于“业务短信”。 +
+ + {(signatureQuality?.total ?? 0) > 0 ? ( + = Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? 10))} + onNext={() => changeSignaturePage((signatureQuality?.page ?? 1) + 1)} + onPageChange={changeSignaturePage} + onPrevious={() => changeSignaturePage((signatureQuality?.page ?? 1) - 1)} + page={signatureQuality?.page ?? 1} + previousDisabled={(signatureQuality?.page ?? 1) <= 1} + total={signatureQuality?.total ?? 0} + totalPages={Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? 10))} + /> + ) : null} + + + {selectedSignature ? ( + setSelectedSignature(null)} + /> + ) : null} ); } +function SignatureQualityDrawer({ + date, + item, + onClose, +}: { + date: string; + item: SignatureChannelQualityItem; + onClose: () => void; +}) { + const carriers = aggregateByCarrier(item.breakdowns); + const channels = [...new Map(item.breakdowns.map((entry) => [entry.channelId, entry.channelName])).entries()] + .map(([channelId, channelName]) => ({ channelId, channelName })); + const visibleCarriers = carrierOrder.filter((carrier) => item.breakdowns.some((entry) => normalizeCarrier(entry.carrier) === carrier)); + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }}> +
+ + + + {visibleCarriers.map((carrier) => )} + + + + {channels.map((channel) => ( + + + {visibleCarriers.map((carrier) => { + const metric = item.breakdowns.find((entry) => ( + entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier + )); + return ( + + ); + })} + + ))} + +
通道名称{carrierLabel(carrier)}
{channel.channelName} + {metric ? : } +
+
+
+ +

+ 平均到达时间从该通道提交受理开始计算,到该通道全部成功回执完成为止,仅统计成功送达的提交尝试。 +

+ + + + ); +} + +function QualityMetric({ label, value, tone = 'default' }: { label: string; value: string; tone?: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function MatrixMetric({ metric }: { metric: SignatureChannelCarrierQualityStat }) { + return ( +
+ {metric.total.toLocaleString('zh-CN')} 次 + + {metric.successRate.toFixed(1)}% + + {formatDuration(metric.averageArrivalMs)} + {metric.submitFailureCount > 0 ? 提交失败 {metric.submitFailureCount} : null} +
+ ); +} + +function QualityRate({ value }: { value: number }) { + return ( +
+
+ {value.toFixed(1)}% +
+ ); +} + +function aggregateByCarrier(items: SignatureChannelCarrierQualityStat[]) { + return carrierOrder + .map((carrier) => { + const entries = items.filter((item) => normalizeCarrier(item.carrier) === carrier); + if (entries.length === 0) return null; + const acceptedCount = entries.reduce((sum, item) => sum + item.acceptedCount, 0); + const successCount = entries.reduce((sum, item) => sum + item.successCount, 0); + const arrivalWeight = entries.reduce((sum, item) => ( + sum + (item.averageArrivalMs == null ? 0 : item.averageArrivalMs * item.successCount) + ), 0); + const arrivalCount = entries.reduce((sum, item) => sum + (item.averageArrivalMs == null ? 0 : item.successCount), 0); + return { + carrier, + total: entries.reduce((sum, item) => sum + item.total, 0), + successRate: acceptedCount === 0 ? 0 : successCount * 100 / acceptedCount, + averageArrivalMs: arrivalCount === 0 ? null : Math.round(arrivalWeight / arrivalCount), + channelCount: new Set(entries.map((item) => item.channelId)).size, + }; + }) + .filter((item): item is NonNullable => item !== null); +} + +function normalizeCarrier(value: string) { + const normalized = value.toLowerCase(); + if (['mobile', 'cmcc', '移动'].includes(normalized)) return 'mobile'; + if (['unicom', 'cucc', '联通'].includes(normalized)) return 'unicom'; + if (['telecom', 'ctcc', '电信'].includes(normalized)) return 'telecom'; + return 'unknown'; +} + +function carrierLabel(value: string) { + return carrierLabels[normalizeCarrier(value)] ?? '未知'; +} + +function carrierTagTone(value: string): 'info' | 'accent' | 'warning' | 'neutral' { + const carrier = normalizeCarrier(value); + if (carrier === 'mobile') return 'info'; + if (carrier === 'unicom') return 'accent'; + if (carrier === 'telecom') return 'warning'; + return 'neutral'; +} + +function rateTone(value: number) { + if (value >= 98) return 'success'; + if (value >= 95) return 'warning'; + return 'danger'; +} + +function formatDuration(value?: number | null) { + if (value == null) return '—'; + if (value < 1000) return `${Math.round(value)} 毫秒`; + return `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)} 秒`; +} + function shanghaiDateKey(value = new Date()) { const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', diff --git a/src/apps/admin/AdminCustomerFormPage.tsx b/src/apps/admin/AdminCustomerFormPage.tsx index 44767f1..8a3e310 100644 --- a/src/apps/admin/AdminCustomerFormPage.tsx +++ b/src/apps/admin/AdminCustomerFormPage.tsx @@ -73,7 +73,7 @@ function formFromTenant(tenant: TenantOption, creditCents = 0): EnterpriseForm { contactPhone: profile?.contactPhone ?? '', contactEmail: profile?.contactEmail ?? '', photoFileObjectId: profile?.photoFileObjectId ?? '', - photoFileName: profile?.photoFileObjectId ? '已上传企业照片' : '', + photoFileName: profile?.photoFileObjectId ? '已上传企业营业执照' : '', photoContentType: '', }; } @@ -161,12 +161,12 @@ export function AdminCustomerFormPage() { })); setError(''); }) - .catch((failure: Error) => setError(failure.message || '企业照片上传失败')) + .catch((failure: Error) => setError(failure.message || '企业营业执照上传失败')) .finally(() => setUploadingPhoto(false)); } const photoFile: FileRef | null = form.photoFileObjectId - ? { contentType: form.photoContentType, fileName: form.photoFileName || '企业照片', fileObjectId: form.photoFileObjectId } + ? { contentType: form.photoContentType, fileName: form.photoFileName || '企业营业执照', fileObjectId: form.photoFileObjectId } : null; return ( @@ -189,10 +189,10 @@ export function AdminCustomerFormPage() {
- 企业照片 + 企业营业执照