From 8c3336600e900667d8bb77fd6e0fb5fe40986c11 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Wed, 15 Jul 2026 14:22:50 +0800 Subject: [PATCH] feat: add reconciliation and quality reporting --- .../migration.sql | 59 +++ .../migration.sql | 23 + .../migration.sql | 65 +++ api/prisma/schema.prisma | 95 ++++ api/src/app.module.ts | 2 + api/src/channels/channels.service.ts | 2 + .../dictionaries/dictionaries.controller.ts | 16 + .../dictionaries/dictionaries.service.spec.ts | 36 +- api/src/dictionaries/dictionaries.service.ts | 61 ++- api/src/reports/reports.controller.ts | 49 +++ api/src/reports/reports.module.ts | 11 + api/src/reports/reports.service.spec.ts | 81 ++++ api/src/reports/reports.service.ts | 409 ++++++++++++++++++ api/src/send-chain/send-chain.service.spec.ts | 27 +- api/src/send-chain/send-chain.service.ts | 45 +- .../sms-config/admin-sms-config.controller.ts | 5 + .../client-sms-config.controller.ts | 11 +- api/src/sms-config/sms-config.service.spec.ts | 57 +++ api/src/sms-config/sms-config.service.ts | 104 +++-- .../first-version-development-requirements.md | 22 +- docs/production-deployment.md | 4 + docs/system-functional-test-cases.md | 22 +- docs/testing-progress.md | 23 + src/api/adminApi.ts | 82 +++- src/apps/admin/AdminDrainageFieldsPage.tsx | 86 +++- .../admin/AdminEnterpriseSignaturesPage.tsx | 145 ++----- src/apps/admin/AdminProfitReportsPage.tsx | 94 ++++ src/apps/admin/AdminQualityReportsPage.tsx | 113 +++++ .../admin/AdminReconciliationReportsPage.tsx | 100 +++++ src/apps/client/ClientSignaturesPage.tsx | 66 +-- src/layouts/AdminLayout.tsx | 9 + src/routes/AppRoutes.tsx | 6 + 32 files changed, 1730 insertions(+), 200 deletions(-) create mode 100644 api/prisma/migrations/20260715123000_add_daily_reconciliation_and_profit_reports/migration.sql create mode 100644 api/prisma/migrations/20260715170000_add_common_report_fields/migration.sql create mode 100644 api/prisma/migrations/20260715173000_add_daily_quality_reports/migration.sql create mode 100644 api/src/reports/reports.controller.ts create mode 100644 api/src/reports/reports.module.ts create mode 100644 api/src/reports/reports.service.spec.ts create mode 100644 api/src/reports/reports.service.ts create mode 100644 src/apps/admin/AdminProfitReportsPage.tsx create mode 100644 src/apps/admin/AdminQualityReportsPage.tsx create mode 100644 src/apps/admin/AdminReconciliationReportsPage.tsx diff --git a/api/prisma/migrations/20260715123000_add_daily_reconciliation_and_profit_reports/migration.sql b/api/prisma/migrations/20260715123000_add_daily_reconciliation_and_profit_reports/migration.sql new file mode 100644 index 0000000..3aa7c29 --- /dev/null +++ b/api/prisma/migrations/20260715123000_add_daily_reconciliation_and_profit_reports/migration.sql @@ -0,0 +1,59 @@ +ALTER TABLE "SmsSubmitRecord" +ADD COLUMN "costUnitPrice" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "costAmountCents" INTEGER NOT NULL DEFAULT 0; + +UPDATE "SmsSubmitRecord" submit +SET + "costUnitPrice" = channel."unitPrice", + "costAmountCents" = channel."unitPrice" * message."billingUnits" +FROM "SmsChannel" channel, "SmsMessageRecord" message +WHERE submit."channelId" = channel.id + AND submit."messageRecordId" = message.id; + +CREATE TABLE "DailyReconciliationReport" ( + "id" TEXT NOT NULL, + "reportDate" DATE NOT NULL, + "tenantId" TEXT NOT NULL, + "tenantName" TEXT NOT NULL, + "applicationId" TEXT NOT NULL, + "applicationName" TEXT NOT NULL, + "sentUnits" INTEGER NOT NULL DEFAULT 0, + "successUnits" INTEGER NOT NULL DEFAULT 0, + "generatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "DailyReconciliationReport_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "DailyProfitReport" ( + "id" TEXT NOT NULL, + "reportDate" DATE NOT NULL, + "dimensionType" TEXT NOT NULL, + "dimensionId" TEXT NOT NULL, + "dimensionName" TEXT NOT NULL, + "tenantId" TEXT, + "tenantName" TEXT, + "applicationId" TEXT, + "channelId" TEXT, + "sentUnits" INTEGER NOT NULL DEFAULT 0, + "successUnits" INTEGER NOT NULL DEFAULT 0, + "revenueCents" INTEGER NOT NULL DEFAULT 0, + "costCents" INTEGER NOT NULL DEFAULT 0, + "profitCents" INTEGER NOT NULL DEFAULT 0, + "profitRateBps" INTEGER NOT NULL DEFAULT 0, + "generatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "DailyProfitReport_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "DailyReconciliationReport_reportDate_tenantId_applicationId_key" +ON "DailyReconciliationReport"("reportDate", "tenantId", "applicationId"); +CREATE INDEX "DailyReconciliationReport_reportDate_idx" ON "DailyReconciliationReport"("reportDate"); +CREATE INDEX "DailyReconciliationReport_tenantId_reportDate_idx" ON "DailyReconciliationReport"("tenantId", "reportDate"); +CREATE INDEX "DailyReconciliationReport_applicationId_reportDate_idx" ON "DailyReconciliationReport"("applicationId", "reportDate"); + +CREATE UNIQUE INDEX "DailyProfitReport_reportDate_dimensionType_dimensionId_key" +ON "DailyProfitReport"("reportDate", "dimensionType", "dimensionId"); +CREATE INDEX "DailyProfitReport_dimensionType_reportDate_idx" ON "DailyProfitReport"("dimensionType", "reportDate"); +CREATE INDEX "DailyProfitReport_tenantId_reportDate_idx" ON "DailyProfitReport"("tenantId", "reportDate"); +CREATE INDEX "DailyProfitReport_applicationId_reportDate_idx" ON "DailyProfitReport"("applicationId", "reportDate"); +CREATE INDEX "DailyProfitReport_channelId_reportDate_idx" ON "DailyProfitReport"("channelId", "reportDate"); diff --git a/api/prisma/migrations/20260715170000_add_common_report_fields/migration.sql b/api/prisma/migrations/20260715170000_add_common_report_fields/migration.sql new file mode 100644 index 0000000..92f6a77 --- /dev/null +++ b/api/prisma/migrations/20260715170000_add_common_report_fields/migration.sql @@ -0,0 +1,23 @@ +CREATE TABLE "CommonReportField" ( + "id" TEXT NOT NULL, + "drainageFieldId" TEXT NOT NULL, + "reportType" TEXT NOT NULL, + "required" BOOLEAN NOT NULL DEFAULT false, + "sortOrder" INTEGER NOT NULL DEFAULT 100, + "status" TEXT NOT NULL DEFAULT 'active', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CommonReportField_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "CommonReportField_drainageFieldId_reportType_key" +ON "CommonReportField"("drainageFieldId", "reportType"); + +CREATE INDEX "CommonReportField_reportType_status_sortOrder_idx" +ON "CommonReportField"("reportType", "status", "sortOrder"); + +ALTER TABLE "CommonReportField" +ADD CONSTRAINT "CommonReportField_drainageFieldId_fkey" +FOREIGN KEY ("drainageFieldId") REFERENCES "DrainageField"("id") +ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/api/prisma/migrations/20260715173000_add_daily_quality_reports/migration.sql b/api/prisma/migrations/20260715173000_add_daily_quality_reports/migration.sql new file mode 100644 index 0000000..f1b7327 --- /dev/null +++ b/api/prisma/migrations/20260715173000_add_daily_quality_reports/migration.sql @@ -0,0 +1,65 @@ +ALTER TABLE "SmsMessageRecord" ADD COLUMN "drainageInfoId" TEXT; + +ALTER TABLE "SmsMessageRecord" +ADD CONSTRAINT "SmsMessageRecord_drainageInfoId_fkey" +FOREIGN KEY ("drainageInfoId") REFERENCES "SmsDrainageInfo"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE INDEX "SmsMessageRecord_drainageInfoId_queuedAt_idx" +ON "SmsMessageRecord"("drainageInfoId", "queuedAt"); + +WITH drainage_matches AS ( + SELECT + message.id AS "messageRecordId", + drainage.id AS "drainageInfoId", + ROW_NUMBER() OVER ( + PARTITION BY message.id + ORDER BY LENGTH(drainage.url) DESC, drainage."updatedAt" DESC, drainage.id + ) AS match_rank, + COUNT(*) OVER ( + PARTITION BY message.id, LENGTH(drainage.url) + ) AS same_length_matches + FROM "SmsMessageRecord" message + JOIN "SmsDrainageInfo" drainage + ON drainage."signatureId" = message."signatureId" + AND drainage."auditStatus" = 'approved' + AND LENGTH(BTRIM(drainage.url)) > 0 + AND POSITION(BTRIM(drainage.url) IN message.content) > 0 + WHERE message."drainageInfoId" IS NULL +) +UPDATE "SmsMessageRecord" message +SET "drainageInfoId" = matched."drainageInfoId" +FROM drainage_matches matched +WHERE message.id = matched."messageRecordId" + AND matched.match_rank = 1 + AND matched.same_length_matches = 1; + +CREATE TABLE "DailyQualityReport" ( + "id" TEXT NOT NULL, + "reportDate" DATE NOT NULL, + "dimensionType" TEXT NOT NULL, + "dimensionId" TEXT NOT NULL, + "dimensionName" TEXT NOT NULL, + "tenantId" TEXT, + "tenantName" TEXT, + "applicationId" TEXT, + "channelId" TEXT, + "signatureId" TEXT, + "drainageInfoId" TEXT, + "sentUnits" INTEGER NOT NULL DEFAULT 0, + "successUnits" INTEGER NOT NULL DEFAULT 0, + "successRateBps" INTEGER NOT NULL DEFAULT 0, + "avgArrivalMs" INTEGER, + "generatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "DailyQualityReport_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "DailyQualityReport_reportDate_dimensionType_dimensionId_key" +ON "DailyQualityReport"("reportDate", "dimensionType", "dimensionId"); +CREATE INDEX "DailyQualityReport_dimensionType_reportDate_sentUnits_idx" +ON "DailyQualityReport"("dimensionType", "reportDate", "sentUnits"); +CREATE INDEX "DailyQualityReport_tenantId_reportDate_idx" ON "DailyQualityReport"("tenantId", "reportDate"); +CREATE INDEX "DailyQualityReport_applicationId_reportDate_idx" ON "DailyQualityReport"("applicationId", "reportDate"); +CREATE INDEX "DailyQualityReport_channelId_reportDate_idx" ON "DailyQualityReport"("channelId", "reportDate"); +CREATE INDEX "DailyQualityReport_signatureId_reportDate_idx" ON "DailyQualityReport"("signatureId", "reportDate"); +CREATE INDEX "DailyQualityReport_drainageInfoId_reportDate_idx" ON "DailyQualityReport"("drainageInfoId", "reportDate"); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 5e76424..428f33c 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -261,6 +261,7 @@ model DrainageField { updatedAt DateTime @updatedAt channelReportFields ChannelReportField[] + commonReportFields CommonReportField[] } model TenantAccount { @@ -449,6 +450,7 @@ model SmsDrainageInfo { signature SmsSignature @relation(fields: [signatureId], references: [id], onDelete: Cascade) application SmsApplication? @relation(fields: [applicationId], references: [id]) reportTasks ChannelSignatureReportTask[] + messageRecords SmsMessageRecord[] @@index([tenantId, auditStatus, updatedAt]) @@index([signatureId, auditStatus]) @@ -716,6 +718,22 @@ model ChannelReportField { @@index([drainageFieldId, reportType]) } +model CommonReportField { + id String @id @default(cuid()) + drainageFieldId String + reportType String + required Boolean @default(false) + sortOrder Int @default(100) + status String @default("active") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + drainageField DrainageField @relation(fields: [drainageFieldId], references: [id], onDelete: Restrict) + + @@unique([drainageFieldId, reportType]) + @@index([reportType, status, sortOrder]) +} + model SignatureReportMaterial { id String @id @default(cuid()) signatureId String @@ -974,6 +992,7 @@ model SmsMessageRecord { applicationId String? templateId String? signatureId String? + drainageInfoId String? reviewTaskId String? messageId String @unique phoneNumber String @@ -1004,6 +1023,7 @@ model SmsMessageRecord { application SmsApplication? @relation(fields: [applicationId], references: [id]) template SmsTemplate? @relation(fields: [templateId], references: [id]) signature SmsSignature? @relation(fields: [signatureId], references: [id]) + drainageInfo SmsDrainageInfo? @relation(fields: [drainageInfoId], references: [id]) reviewTask SmsSendTask? @relation(fields: [reviewTaskId], references: [id]) channel SmsChannel? @relation(fields: [channelId], references: [id]) submitRecords SmsSubmitRecord[] @@ -1018,6 +1038,7 @@ model SmsMessageRecord { @@index([reviewTaskId, status]) @@index([phoneNumber]) @@index([gatewayMessageId]) + @@index([drainageInfoId, queuedAt]) } model CmppSubmitSession { @@ -1048,6 +1069,8 @@ model SmsSubmitRecord { sequenceId Int? gatewayMessageId String? submitStatus String @default("queued") + costUnitPrice Int @default(0) + costAmountCents Int @default(0) errorCode String? errorMessage String? submittedAt DateTime? @@ -1066,6 +1089,78 @@ model SmsSubmitRecord { @@index([gatewayMessageId]) } +model DailyReconciliationReport { + id String @id @default(cuid()) + reportDate DateTime @db.Date + tenantId String + tenantName String + applicationId String + applicationName String + sentUnits Int @default(0) + successUnits Int @default(0) + generatedAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([reportDate, tenantId, applicationId]) + @@index([reportDate]) + @@index([tenantId, reportDate]) + @@index([applicationId, reportDate]) +} + +model DailyProfitReport { + id String @id @default(cuid()) + reportDate DateTime @db.Date + dimensionType String + dimensionId String + dimensionName String + tenantId String? + tenantName String? + applicationId String? + channelId String? + sentUnits Int @default(0) + successUnits Int @default(0) + revenueCents Int @default(0) + costCents Int @default(0) + profitCents Int @default(0) + profitRateBps Int @default(0) + generatedAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([reportDate, dimensionType, dimensionId]) + @@index([dimensionType, reportDate]) + @@index([tenantId, reportDate]) + @@index([applicationId, reportDate]) + @@index([channelId, reportDate]) +} + +model DailyQualityReport { + id String @id @default(cuid()) + reportDate DateTime @db.Date + dimensionType String + dimensionId String + dimensionName String + tenantId String? + tenantName String? + applicationId String? + channelId String? + signatureId String? + drainageInfoId String? + sentUnits Int @default(0) + successUnits Int @default(0) + successRateBps Int @default(0) + avgArrivalMs Int? + generatedAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([reportDate, dimensionType, dimensionId]) + @@index([dimensionType, reportDate, sentUnits]) + @@index([tenantId, reportDate]) + @@index([applicationId, reportDate]) + @@index([channelId, reportDate]) + @@index([signatureId, reportDate]) + @@index([drainageInfoId, reportDate]) +} + model SmsMessageSegmentAudit { id String @id @default(cuid()) tenantId String? diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 0af3398..584ed10 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -13,6 +13,7 @@ import { HealthController } from './health.controller'; import { OperationsModule } from './operations/operations.module'; import { PrismaModule } from './prisma/prisma.module'; import { RiskReviewModule } from './risk-review/risk-review.module'; +import { ReportsModule } from './reports/reports.module'; import { SendChainModule } from './send-chain/send-chain.module'; import { SmsConfigModule } from './sms-config/sms-config.module'; import { TenantsModule } from './tenants/tenants.module'; @@ -36,6 +37,7 @@ import { UsersModule } from './users/users.module'; SmsConfigModule, ChannelsModule, RiskReviewModule, + ReportsModule, SendChainModule, OperationsModule, ], diff --git a/api/src/channels/channels.service.ts b/api/src/channels/channels.service.ts index 1388a38..ec20186 100644 --- a/api/src/channels/channels.service.ts +++ b/api/src/channels/channels.service.ts @@ -484,6 +484,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { sessionId: session.id, submitId, submitStatus: 'queued', + costUnitPrice: channel.unitPrice, + costAmountCents: channel.unitPrice * messageRecord.billingUnits, }, }); const command = buildChannelTestSubmitCommand({ diff --git a/api/src/dictionaries/dictionaries.controller.ts b/api/src/dictionaries/dictionaries.controller.ts index 3468c6e..e17407b 100644 --- a/api/src/dictionaries/dictionaries.controller.ts +++ b/api/src/dictionaries/dictionaries.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { + CreateCommonReportFieldDto, CreateBlacklistDto, CreateDrainageFieldDto, CreatePhoneCarrierRuleDto, @@ -118,4 +119,19 @@ export class DictionariesController { deleteDrainageField(@Param('id') id: string) { return this.dictionaries.deleteDrainageField(id); } + + @Get('common-report-fields') + listCommonReportFields() { + return this.dictionaries.listCommonReportFields(); + } + + @Post('common-report-fields') + createCommonReportField(@Body() body: CreateCommonReportFieldDto) { + return this.dictionaries.createCommonReportField(body); + } + + @Delete('common-report-fields/:id') + deleteCommonReportField(@Param('id') id: string) { + return this.dictionaries.deleteCommonReportField(id); + } } diff --git a/api/src/dictionaries/dictionaries.service.spec.ts b/api/src/dictionaries/dictionaries.service.spec.ts index aec3cb9..8755780 100644 --- a/api/src/dictionaries/dictionaries.service.spec.ts +++ b/api/src/dictionaries/dictionaries.service.spec.ts @@ -28,12 +28,20 @@ function createPrismaMock() { }, drainageField: { findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })), delete: jest.fn().mockResolvedValue({ id: 'field-1' }), }, channelReportField: { count: jest.fn().mockResolvedValue(0), }, + commonReportField: { + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn().mockResolvedValue(null), + count: jest.fn().mockResolvedValue(0), + create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'common-1', ...data })), + delete: jest.fn().mockResolvedValue({ id: 'common-1' }), + }, smsApplication: { findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }), }, @@ -55,14 +63,38 @@ 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 } }]); + prisma.drainageField.findMany.mockResolvedValue([{ id: 'field-1', code: 'license', _count: { channelReportFields: 2, commonReportFields: 0 } }]); 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 }]); + 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(); }); + + it('creates and deletes real common signature and drainage field configurations', async () => { + const prisma = createPrismaMock(); + prisma.drainageField.findUnique = jest.fn().mockResolvedValue({ id: 'field-1', code: 'license', required: false, status: 'active' }); + const service = new DictionariesService(prisma as never); + + await service.createCommonReportField({ drainageFieldId: 'field-1', reportType: 'signature', required: true }); + await service.deleteCommonReportField('common-1'); + + expect(prisma.commonReportField.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ drainageFieldId: 'field-1', reportType: 'signature', required: true, status: 'active' }), + include: { drainageField: true }, + }); + expect(prisma.commonReportField.delete).toHaveBeenCalledWith({ where: { id: 'common-1' } }); + }); + + it('blocks deleting a field referenced by a common configuration', async () => { + const prisma = createPrismaMock(); + prisma.commonReportField.count.mockResolvedValue(1); + const service = new DictionariesService(prisma as never); + + await expect(service.deleteDrainageField('field-1')).rejects.toThrow('通用配置'); + expect(prisma.drainageField.delete).not.toHaveBeenCalled(); + }); it('paginates phone segments with a real database count', async () => { const prisma = createPrismaMock(); prisma.phoneSegment.findMany.mockResolvedValue([ diff --git a/api/src/dictionaries/dictionaries.service.ts b/api/src/dictionaries/dictionaries.service.ts index 507f26e..9caf005 100644 --- a/api/src/dictionaries/dictionaries.service.ts +++ b/api/src/dictionaries/dictionaries.service.ts @@ -53,6 +53,13 @@ export interface CreateDrainageFieldDto { description?: string; } +export interface CreateCommonReportFieldDto { + drainageFieldId: string; + reportType: 'signature' | 'drainage'; + required?: boolean; + sortOrder?: number; +} + export interface DictionaryStatusDto { status?: string; operatorId?: string; @@ -264,10 +271,14 @@ export class DictionariesService { async listDrainageFields() { const fields = await this.prisma.drainageField.findMany({ - include: { _count: { select: { channelReportFields: true } } }, + include: { _count: { select: { channelReportFields: true, commonReportFields: true } } }, orderBy: { createdAt: 'desc' }, }); - return fields.map(({ _count, ...field }) => ({ ...field, usageCount: _count.channelReportFields })); + return fields.map(({ _count, ...field }) => ({ + ...field, + usageCount: _count.channelReportFields, + commonUsageCount: _count.commonReportFields, + })); } createDrainageField(data: CreateDrainageFieldDto) { @@ -291,13 +302,53 @@ export class DictionariesService { } async deleteDrainageField(id: string) { - const usageCount = await this.prisma.channelReportField.count({ where: { drainageFieldId: id } }); - if (usageCount > 0) { - throw new BadRequestException(`该字段已被 ${usageCount} 个通道使用,不能删除`); + const [usageCount, commonUsageCount] = await Promise.all([ + this.prisma.channelReportField.count({ where: { drainageFieldId: id } }), + this.prisma.commonReportField.count({ where: { drainageFieldId: id } }), + ]); + if (usageCount > 0 || commonUsageCount > 0) { + throw new BadRequestException(`该字段已被 ${usageCount} 个通道和 ${commonUsageCount} 个通用配置使用,不能删除`); } return this.prisma.drainageField.delete({ where: { id } }); } + listCommonReportFields() { + return this.prisma.commonReportField.findMany({ + include: { drainageField: true }, + orderBy: [{ reportType: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }], + }); + } + + async createCommonReportField(data: CreateCommonReportFieldDto) { + if (data.reportType !== 'signature' && data.reportType !== 'drainage') { + throw new BadRequestException('reportType must be signature or drainage'); + } + const field = await this.prisma.drainageField.findUnique({ where: { id: data.drainageFieldId } }); + if (!field || field.status !== 'active') { + throw new BadRequestException('报备字段库字段不存在或已停用'); + } + const existing = await this.prisma.commonReportField.findUnique({ + where: { drainageFieldId_reportType: { drainageFieldId: field.id, reportType: data.reportType } }, + }); + if (existing) { + throw new BadRequestException('该字段已配置为对应类型的通用字段'); + } + return this.prisma.commonReportField.create({ + data: { + drainageFieldId: field.id, + reportType: data.reportType, + required: data.required ?? field.required, + sortOrder: data.sortOrder ?? 100, + status: 'active', + }, + include: { drainageField: true }, + }); + } + + deleteCommonReportField(id: string) { + return this.prisma.commonReportField.delete({ where: { id } }); + } + private writeOperationLog(userId: string | undefined, action: string, resource: string, resourceId: string, detail: Record) { return this.prisma.operationLog.create({ data: { diff --git a/api/src/reports/reports.controller.ts b/api/src/reports/reports.controller.ts new file mode 100644 index 0000000..3f3c463 --- /dev/null +++ b/api/src/reports/reports.controller.ts @@ -0,0 +1,49 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { ReportsService } from './reports.service'; + +@ApiTags('reports') +@Controller('admin/reports') +export class ReportsController { + constructor(private readonly reports: ReportsService) {} + + @Get('reconciliation') + reconciliation( + @Query('dateFrom') dateFrom?: string, + @Query('dateTo') dateTo?: string, + @Query('tenantId') tenantId?: string, + @Query('applicationId') applicationId?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.reports.listReconciliation({ dateFrom, dateTo, tenantId, applicationId, page: Number(page), pageSize: Number(pageSize) }); + } + + @Get('profit') + profit( + @Query('dateFrom') dateFrom?: string, + @Query('dateTo') dateTo?: string, + @Query('dimensionType') dimensionType?: string, + @Query('tenantId') tenantId?: string, + @Query('applicationId') applicationId?: string, + @Query('channelId') channelId?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.reports.listProfit({ dateFrom, dateTo, dimensionType, tenantId, applicationId, channelId, page: Number(page), pageSize: Number(pageSize) }); + } + + @Get('quality') + quality( + @Query('dateFrom') dateFrom?: string, + @Query('dateTo') dateTo?: string, + @Query('dimensionType') dimensionType?: string, + @Query('tenantId') tenantId?: string, + @Query('applicationId') applicationId?: string, + @Query('channelId') channelId?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.reports.listQuality({ dateFrom, dateTo, dimensionType, tenantId, applicationId, channelId, page: Number(page), pageSize: Number(pageSize) }); + } +} diff --git a/api/src/reports/reports.module.ts b/api/src/reports/reports.module.ts new file mode 100644 index 0000000..2845f32 --- /dev/null +++ b/api/src/reports/reports.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ReportsController } from './reports.controller'; +import { ReportsService } from './reports.service'; + +@Module({ + imports: [PrismaModule], + controllers: [ReportsController], + providers: [ReportsService], +}) +export class ReportsModule {} diff --git a/api/src/reports/reports.service.spec.ts b/api/src/reports/reports.service.spec.ts new file mode 100644 index 0000000..aa2225a --- /dev/null +++ b/api/src/reports/reports.service.spec.ts @@ -0,0 +1,81 @@ +import { ReportsService } from './reports.service'; + +describe('ReportsService', () => { + const tx = { + dailyReconciliationReport: { deleteMany: jest.fn() }, + dailyProfitReport: { deleteMany: jest.fn() }, + dailyQualityReport: { deleteMany: jest.fn() }, + $executeRaw: jest.fn(), + }; + const prisma = { + dailyReconciliationReport: { findMany: jest.fn(), count: jest.fn() }, + dailyProfitReport: { findMany: jest.fn(), count: jest.fn() }, + dailyQualityReport: { findMany: jest.fn(), count: jest.fn() }, + $transaction: jest.fn((callback: (client: typeof tx) => unknown) => callback(tx)), + }; + let service: ReportsService; + + beforeEach(() => { + jest.clearAllMocks(); + tx.dailyReconciliationReport.deleteMany.mockResolvedValue({ count: 0 }); + tx.dailyProfitReport.deleteMany.mockResolvedValue({ count: 0 }); + tx.dailyQualityReport.deleteMany.mockResolvedValue({ count: 0 }); + tx.$executeRaw.mockResolvedValue(0); + prisma.dailyReconciliationReport.findMany.mockResolvedValue([{ id: 'recon-1' }]); + prisma.dailyReconciliationReport.count.mockResolvedValue(1); + prisma.dailyProfitReport.findMany.mockResolvedValue([{ id: 'profit-1' }]); + prisma.dailyProfitReport.count.mockResolvedValue(1); + prisma.dailyQualityReport.findMany.mockResolvedValue([{ id: 'quality-1' }]); + prisma.dailyQualityReport.count.mockResolvedValue(1); + service = new ReportsService(prisma as never); + }); + + it('rebuilds exactly T-4 through T-1 in independent transactions', async () => { + await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).resolves.toEqual({ + refreshedDates: ['2026-07-11', '2026-07-12', '2026-07-13', '2026-07-14'], + }); + expect(prisma.$transaction).toHaveBeenCalledTimes(4); + expect(tx.dailyReconciliationReport.deleteMany).toHaveBeenCalledTimes(4); + expect(tx.dailyProfitReport.deleteMany).toHaveBeenCalledTimes(4); + expect(tx.dailyQualityReport.deleteMany).toHaveBeenCalledTimes(4); + expect(tx.$executeRaw).toHaveBeenCalledTimes(28); + }); + + it('queries reconciliation reports with server-side filters and bounded pagination', async () => { + await expect(service.listReconciliation({ + dateFrom: '2026-07-01', + dateTo: '2026-07-14', + tenantId: 'tenant-1', + applicationId: 'app-1', + page: 2, + pageSize: 500, + })).resolves.toEqual({ items: [{ id: 'recon-1' }], total: 1, page: 2, pageSize: 100 }); + expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1' }), + skip: 100, + take: 100, + })); + }); + + it('keeps application and channel profit filters separate', async () => { + await service.listProfit({ dimensionType: 'channel', tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1' }); + expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + dimensionType: 'channel', + tenantId: undefined, + applicationId: undefined, + channelId: 'channel-1', + }), + })); + }); + + it('sorts quality reports by send volume and keeps the selected dimension', async () => { + await expect(service.listQuality({ dimensionType: 'drainage', tenantId: 'tenant-1', page: 1, pageSize: 20 })).resolves.toEqual({ + items: [{ id: 'quality-1' }], total: 1, page: 1, pageSize: 20, dimensionType: 'drainage', + }); + expect(prisma.dailyQualityReport.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ dimensionType: 'drainage', tenantId: 'tenant-1' }), + orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }], + })); + }); +}); diff --git a/api/src/reports/reports.service.ts b/api/src/reports/reports.service.ts new file mode 100644 index 0000000..313e8f0 --- /dev/null +++ b/api/src/reports/reports.service.ts @@ -0,0 +1,409 @@ +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; + +const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000; +const DAY_MS = 24 * 60 * 60 * 1000; +const DEFAULT_REFRESH_INTERVAL_MS = 60 * 60 * 1000; + +export type ReportListQuery = { + dateFrom?: string; + dateTo?: string; + tenantId?: string; + applicationId?: string; + channelId?: string; + dimensionType?: string; + page?: number; + pageSize?: number; +}; + +@Injectable() +export class ReportsService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(ReportsService.name); + private refreshTimer?: ReturnType; + private refreshRunning = false; + private lastRefreshBusinessDate?: string; + + constructor(private readonly prisma: PrismaService) {} + + onModuleInit() { + if (process.env.REPORT_DAILY_REFRESH_ENABLED === 'false') return; + const startupTimer = setTimeout(() => void this.runScheduledRefresh(), 15_000); + startupTimer.unref?.(); + this.refreshTimer = setInterval( + () => void this.runScheduledRefresh(), + positiveInteger(process.env.REPORT_REFRESH_INTERVAL_MS, DEFAULT_REFRESH_INTERVAL_MS), + ); + this.refreshTimer.unref?.(); + } + + onModuleDestroy() { + if (this.refreshTimer) clearInterval(this.refreshTimer); + } + + async listReconciliation(query: ReportListQuery) { + const { page, pageSize, skip } = pagination(query); + const where: Prisma.DailyReconciliationReportWhereInput = { + reportDate: dateFilter(query.dateFrom, query.dateTo), + tenantId: query.tenantId || undefined, + applicationId: query.applicationId || undefined, + }; + const [items, total] = await Promise.all([ + this.prisma.dailyReconciliationReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }], skip, take: pageSize }), + this.prisma.dailyReconciliationReport.count({ where }), + ]); + return { items, total, page, pageSize }; + } + + async listProfit(query: ReportListQuery) { + const { page, pageSize, skip } = pagination(query); + const dimensionType = query.dimensionType === 'channel' ? 'channel' : 'application'; + const where: Prisma.DailyProfitReportWhereInput = { + dimensionType, + reportDate: dateFilter(query.dateFrom, query.dateTo), + tenantId: dimensionType === 'application' ? query.tenantId || undefined : undefined, + applicationId: dimensionType === 'application' ? query.applicationId || undefined : undefined, + channelId: dimensionType === 'channel' ? query.channelId || undefined : undefined, + }; + const [items, total] = await Promise.all([ + this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }), + this.prisma.dailyProfitReport.count({ where }), + ]); + return { items, total, page, pageSize, dimensionType }; + } + + async listQuality(query: ReportListQuery) { + const { page, pageSize, skip } = pagination(query); + const allowedDimensions = new Set(['application', 'channel', 'signature', 'drainage']); + const dimensionType = allowedDimensions.has(String(query.dimensionType)) ? String(query.dimensionType) : 'application'; + const where: Prisma.DailyQualityReportWhereInput = { + dimensionType, + reportDate: dateFilter(query.dateFrom, query.dateTo), + tenantId: query.tenantId || undefined, + applicationId: query.applicationId || undefined, + channelId: query.channelId || undefined, + }; + const [items, total] = await Promise.all([ + this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }), + this.prisma.dailyQualityReport.count({ where }), + ]); + return { items, total, page, pageSize, dimensionType }; + } + + async refreshRollingWindow(now = new Date()) { + const days = completedBusinessDays(now, 4); + for (const day of days) await this.refreshBusinessDay(day); + return { refreshedDates: days.map((day) => day.key) }; + } + + private async runScheduledRefresh() { + const businessDate = shanghaiDateKey(new Date()); + if (this.refreshRunning || this.lastRefreshBusinessDate === businessDate) return; + this.refreshRunning = true; + try { + const result = await this.refreshRollingWindow(); + this.lastRefreshBusinessDate = businessDate; + this.logger.log(`Daily reports refreshed for ${result.refreshedDates.join(', ')}`); + } catch (error) { + this.logger.error('Daily report refresh failed', error instanceof Error ? error.stack : String(error)); + } finally { + this.refreshRunning = false; + } + } + + private async refreshBusinessDay(day: BusinessDay) { + await this.prisma.$transaction(async (tx) => { + await tx.dailyReconciliationReport.deleteMany({ where: { reportDate: day.reportDate } }); + await tx.dailyProfitReport.deleteMany({ where: { reportDate: day.reportDate } }); + await tx.dailyQualityReport.deleteMany({ where: { reportDate: day.reportDate } }); + + await tx.$executeRaw(Prisma.sql` + INSERT INTO "DailyReconciliationReport" ( + "id", "reportDate", "tenantId", "tenantName", "applicationId", "applicationName", + "sentUnits", "successUnits", "generatedAt", "updatedAt" + ) + SELECT + CONCAT('recon-', MD5(${day.key} || ':' || tenant.id || ':' || application.id)), + ${day.reportDate}::date, + tenant.id, + tenant.name, + application.id, + application.name, + COALESCE(SUM(message."billingUnits"), 0)::integer, + COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END), 0)::integer, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + FROM "SmsMessageRecord" message + JOIN "Tenant" tenant ON tenant.id = message."tenantId" + JOIN "SmsApplication" application ON application.id = message."applicationId" + WHERE message."queuedAt" >= ${day.startAt} + AND message."queuedAt" < ${day.endAt} + GROUP BY tenant.id, tenant.name, application.id, application.name + `); + + await tx.$executeRaw(Prisma.sql` + WITH billing AS ( + SELECT "messageId", SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::integer AS revenue + FROM "SmsBillingRecord" + GROUP BY "messageId" + ), costs AS ( + SELECT submit."messageRecordId", SUM(submit."costAmountCents")::integer AS cost + FROM "SmsSubmitRecord" submit + WHERE submit."submitStatus" = 'accepted' + GROUP BY submit."messageRecordId" + ) + INSERT INTO "DailyProfitReport" ( + "id", "reportDate", "dimensionType", "dimensionId", "dimensionName", + "tenantId", "tenantName", "applicationId", "channelId", + "sentUnits", "successUnits", "revenueCents", "costCents", "profitCents", "profitRateBps", + "generatedAt", "updatedAt" + ) + SELECT + CONCAT('profit-app-', MD5(${day.key} || ':' || application.id)), + ${day.reportDate}::date, + 'application', + application.id, + application.name, + tenant.id, + tenant.name, + application.id, + NULL, + COALESCE(SUM(message."billingUnits"), 0)::integer, + COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END), 0)::integer, + COALESCE(SUM(billing.revenue), 0)::integer, + COALESCE(SUM(costs.cost), 0)::integer, + (COALESCE(SUM(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0))::integer, + CASE WHEN COALESCE(SUM(billing.revenue), 0) = 0 THEN 0 + ELSE ROUND((COALESCE(SUM(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0)) * 10000.0 / SUM(billing.revenue))::integer END, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + FROM "SmsMessageRecord" message + JOIN "Tenant" tenant ON tenant.id = message."tenantId" + JOIN "SmsApplication" application ON application.id = message."applicationId" + LEFT JOIN billing ON billing."messageId" = message."messageId" + LEFT JOIN costs ON costs."messageRecordId" = message.id + WHERE message."queuedAt" >= ${day.startAt} + AND message."queuedAt" < ${day.endAt} + GROUP BY tenant.id, tenant.name, application.id, application.name + `); + + await tx.$executeRaw(Prisma.sql` + WITH billing AS ( + SELECT "messageId", SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::integer AS revenue + FROM "SmsBillingRecord" + GROUP BY "messageId" + ) + INSERT INTO "DailyProfitReport" ( + "id", "reportDate", "dimensionType", "dimensionId", "dimensionName", + "tenantId", "tenantName", "applicationId", "channelId", + "sentUnits", "successUnits", "revenueCents", "costCents", "profitCents", "profitRateBps", + "generatedAt", "updatedAt" + ) + SELECT + CONCAT('profit-channel-', MD5(${day.key} || ':' || channel.id)), + ${day.reportDate}::date, + 'channel', + channel.id, + channel.name, + NULL, + NULL, + NULL, + channel.id, + COALESCE(SUM(message."billingUnits"), 0)::integer, + COALESCE(SUM(CASE WHEN EXISTS ( + SELECT 1 FROM "SmsReceiptRecord" receipt + WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" + AND receipt."channelId" = submit."channelId" + AND receipt."receiptStatus" = 'delivered' + ) THEN message."billingUnits" ELSE 0 END), 0)::integer, + COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0)::integer, + COALESCE(SUM(submit."costAmountCents"), 0)::integer, + (COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costAmountCents"), 0))::integer, + 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 / + SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END))::integer END, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + FROM "SmsSubmitRecord" submit + JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId" + JOIN "SmsChannel" channel ON channel.id = submit."channelId" + LEFT JOIN billing ON billing."messageId" = message."messageId" + WHERE submit."submitStatus" = 'accepted' + AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt} + AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt} + GROUP BY channel.id, channel.name + `); + + await tx.$executeRaw(qualityByMessageDimensionSql(day, 'application')); + await tx.$executeRaw(qualityByChannelSql(day)); + await tx.$executeRaw(qualityByMessageDimensionSql(day, 'signature')); + await tx.$executeRaw(qualityByMessageDimensionSql(day, 'drainage')); + }); + } +} + +function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'application' | 'signature' | 'drainage') { + const dimensionTypeSql = Prisma.raw(`'${dimensionType}'`); + const dimensionId = dimensionType === 'application' + ? Prisma.sql`application.id` + : dimensionType === 'signature' + ? Prisma.sql`COALESCE(signature.id, 'unmatched:' || COALESCE(application.id, tenant.id))` + : Prisma.sql`COALESCE(drainage.id, 'unmatched:' || COALESCE(application.id, tenant.id))`; + const dimensionName = dimensionType === 'application' + ? Prisma.sql`application.name` + : dimensionType === 'signature' + ? Prisma.sql`COALESCE(signature.name, '未关联签名')` + : Prisma.sql`COALESCE(drainage."siteName", '未关联引流信息')`; + const applicationJoin = dimensionType === 'application' + ? Prisma.sql`JOIN "SmsApplication" application ON application.id = message."applicationId"` + : Prisma.sql`LEFT JOIN "SmsApplication" application ON application.id = message."applicationId"`; + + return Prisma.sql` + WITH base AS ( + SELECT + ${dimensionId} AS dimension_id, + ${dimensionName} AS dimension_name, + tenant.id AS tenant_id, + tenant.name AS tenant_name, + application.id AS application_id, + message."billingUnits" AS billing_units, + CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END AS success_units, + 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 + JOIN "Tenant" tenant ON tenant.id = message."tenantId" + ${applicationJoin} + LEFT JOIN "SmsSignature" signature ON signature.id = message."signatureId" + LEFT JOIN "SmsDrainageInfo" drainage ON drainage.id = message."drainageInfoId" + WHERE message."queuedAt" >= ${day.startAt} + AND message."queuedAt" < ${day.endAt} + ), thresholds AS ( + SELECT dimension_id, PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY arrival_ms) AS p95_ms + FROM base WHERE arrival_ms IS NOT NULL GROUP BY dimension_id + ) + INSERT INTO "DailyQualityReport" ( + "id", "reportDate", "dimensionType", "dimensionId", "dimensionName", + "tenantId", "tenantName", "applicationId", "channelId", "signatureId", "drainageInfoId", + "sentUnits", "successUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt" + ) + SELECT + CONCAT('quality-', ${dimensionTypeSql}, '-', MD5(${day.key} || ':' || base.dimension_id)), + ${day.reportDate}::date, + ${dimensionTypeSql}, + base.dimension_id, + MAX(base.dimension_name), + MAX(base.tenant_id), + MAX(base.tenant_name), + CASE WHEN ${dimensionTypeSql} = 'application' THEN base.dimension_id ELSE MAX(base.application_id) END, + NULL, + CASE WHEN ${dimensionTypeSql} = 'signature' AND base.dimension_id NOT LIKE 'unmatched:%' THEN base.dimension_id ELSE NULL END, + CASE WHEN ${dimensionTypeSql} = 'drainage' AND base.dimension_id NOT LIKE 'unmatched:%' THEN base.dimension_id ELSE NULL END, + SUM(base.billing_units)::integer, + SUM(base.success_units)::integer, + CASE WHEN SUM(base.billing_units) = 0 THEN 0 ELSE ROUND(SUM(base.success_units) * 10000.0 / SUM(base.billing_units))::integer END, + ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL AND base.arrival_ms <= thresholds.p95_ms))::integer, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + FROM base + LEFT JOIN thresholds ON thresholds.dimension_id = base.dimension_id + GROUP BY base.dimension_id + `; +} + +function qualityByChannelSql(day: BusinessDay) { + return Prisma.sql` + WITH base AS ( + SELECT + channel.id AS dimension_id, + channel.name AS dimension_name, + message."billingUnits" AS billing_units, + CASE WHEN receipt."deliveredAt" IS NOT NULL THEN message."billingUnits" ELSE 0 END AS success_units, + CASE WHEN 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 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 + WHERE submit."submitStatus" = 'accepted' + AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt} + AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt} + ), thresholds AS ( + SELECT dimension_id, PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY arrival_ms) AS p95_ms + FROM base WHERE arrival_ms IS NOT NULL GROUP BY dimension_id + ) + INSERT INTO "DailyQualityReport" ( + "id", "reportDate", "dimensionType", "dimensionId", "dimensionName", + "tenantId", "tenantName", "applicationId", "channelId", "signatureId", "drainageInfoId", + "sentUnits", "successUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt" + ) + SELECT + CONCAT('quality-channel-', MD5(${day.key} || ':' || base.dimension_id)), + ${day.reportDate}::date, + 'channel', + base.dimension_id, + MAX(base.dimension_name), + NULL, NULL, NULL, base.dimension_id, NULL, NULL, + SUM(base.billing_units)::integer, + SUM(base.success_units)::integer, + CASE WHEN SUM(base.billing_units) = 0 THEN 0 ELSE ROUND(SUM(base.success_units) * 10000.0 / SUM(base.billing_units))::integer END, + ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL AND base.arrival_ms <= thresholds.p95_ms))::integer, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + FROM base + LEFT JOIN thresholds ON thresholds.dimension_id = base.dimension_id + GROUP BY base.dimension_id + `; +} + +type BusinessDay = { key: string; reportDate: Date; startAt: Date; endAt: Date }; + +function completedBusinessDays(now: Date, count: number): BusinessDay[] { + const shifted = new Date(now.getTime() + SHANGHAI_OFFSET_MS); + const today = Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate()); + return Array.from({ length: count }, (_, index) => businessDay(today - (count - index) * DAY_MS)); +} + +function businessDay(localDateUtc: number): BusinessDay { + const reportDate = new Date(localDateUtc); + return { + key: reportDate.toISOString().slice(0, 10), + reportDate, + startAt: new Date(localDateUtc - SHANGHAI_OFFSET_MS), + endAt: new Date(localDateUtc - SHANGHAI_OFFSET_MS + DAY_MS), + }; +} + +function shanghaiDateKey(now: Date) { + return new Date(now.getTime() + SHANGHAI_OFFSET_MS).toISOString().slice(0, 10); +} + +function dateFilter(from?: string, to?: string): Prisma.DateTimeFilter | undefined { + const gte = parseDate(from); + const lte = parseDate(to); + if (!gte && !lte) return undefined; + return { gte, lte }; +} + +function parseDate(value?: string) { + if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined; + const date = new Date(`${value}T00:00:00.000Z`); + return Number.isNaN(date.getTime()) ? undefined : date; +} + +function pagination(query: ReportListQuery) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 20))); + return { page, pageSize, skip: (page - 1) * pageSize }; +} + +function positiveInteger(value: string | undefined, fallback: number) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index bb43a21..9e29c99 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -99,6 +99,9 @@ function createPrismaMock() { smsSignature: { findFirst: jest.fn().mockResolvedValue({ id: 'sig-1', name: '签名', auditStatus: 'approved', reportStatus: 'reporting' }), }, + smsDrainageInfo: { + findMany: jest.fn().mockResolvedValue([]), + }, smsSendTask: { findUnique: jest.fn().mockResolvedValue(null), }, @@ -348,6 +351,28 @@ describe('SendChainService', () => { expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1'); }); + it('persists the unique longest approved drainage URL match on new message records', async () => { + const { service, prisma } = createService(); + service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); + prisma.smsTemplate.findUnique.mockResolvedValue({ + id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', auditStatus: 'approved', + signature: { id: 'sig-1', auditStatus: 'approved', reportStatus: 'reporting' }, + }); + prisma.smsDrainageInfo.findMany.mockResolvedValue([ + { id: 'drain-short', url: 'https://a.example', updatedAt: new Date('2026-07-01') }, + { id: 'drain-long', url: 'https://a.example/landing', updatedAt: new Date('2026-07-02') }, + ]); + + await service.createBatchTask({ + tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', + content: '详情请访问 https://a.example/landing', phones: ['13800000001'], + }); + + expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ signatureId: 'sig-1', drainageInfoId: 'drain-long' })], + }); + }); + it('creates scheduled tasks without immediate enqueue and dispatches due tasks later', async () => { const { service, prisma, billing } = createService(); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); @@ -934,7 +959,7 @@ describe('SendChainService', () => { }); expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith({ - where: { OR: [{ submitId: 'SUB-1' }, { messageRecordId: 'record-1' }] }, + where: { submitId: 'SUB-1' }, data: expect.objectContaining({ sequenceId: 7, gatewayMessageId: 'GW-1', submitStatus: 'accepted' }), }); expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({ diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index 4611cb5..fd00ecb 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -282,6 +282,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { const phones = [...new Set(data.phones ?? [])]; const schedule = parseSchedule(data); await this.validateSendResources(data.tenantId, data.applicationId, data.templateId); + const messageClassification = await this.resolveTemplateMessageClassification(data.templateId, data.content); const unitPrice = await this.resolveUnitPrice(data.tenantId, data.applicationId); const queuePriority = await this.resolveQueuePriority(data.tenantId, data.applicationId); const risk = await this.riskReview.evaluateTask({ @@ -366,6 +367,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { batchTaskId: task.id, applicationId: data.applicationId, templateId: data.templateId, + signatureId: messageClassification.signatureId, + drainageInfoId: messageClassification.drainageInfoId, messageId: `MSG-${randomUUID()}`, phoneNumber: phone, content: data.content, @@ -750,7 +753,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { : null; const submittedAt = data.submittedAt ? new Date(data.submittedAt) : new Date(); await this.prisma.smsSubmitRecord.updateMany({ - where: { OR: [{ submitId: data.submitId }, { messageRecordId: message.id }] }, + where: data.submitId ? { submitId: data.submitId } : { messageRecordId: message.id }, data: { sequenceId: data.sequenceId, gatewayMessageId: data.gatewayMessageId, @@ -1744,6 +1747,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { await this.recordCmppFailureReceipt(message, code, reason); }; const queueAfterRiskChecks = async (options: { templateId?: string; signatureId?: string }) => { + const drainageInfoId = await this.resolveDrainageInfoId(options.signatureId, data.content); const risk = await this.riskReview.evaluateTask({ tenantId: application.tenantId, applicationId: application.id, @@ -1759,7 +1763,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { if (risk.status === 'pending_review') { await this.prisma.smsMessageRecord.update({ where: { id: message.id }, - data: { status: 'pending_review', signatureId: options.signatureId }, + data: { status: 'pending_review', signatureId: options.signatureId, drainageInfoId }, }); await this.prisma.smsBatchTask.update({ where: { id: task.id }, @@ -1786,7 +1790,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } await this.prisma.smsMessageRecord.update({ where: { id: message.id }, - data: { status: 'queued', signatureId: options.signatureId }, + data: { status: 'queued', signatureId: options.signatureId, drainageInfoId }, }); await this.prisma.smsBatchTask.update({ where: { id: task.id }, @@ -1831,7 +1835,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { }); } const reviewTask = risk.status === 'pending_review' && risk.task - ? await this.attachMessageToReviewTask(risk.task.id, message.id, signature.id) + ? await this.attachMessageToReviewTask(risk.task.id, message.id, signature.id, await this.resolveDrainageInfoId(signature.id, data.content)) : await this.riskReview.aggregateTemplateMismatch({ tenantId: application.tenantId, applicationId: application.id, @@ -1986,6 +1990,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { sessionId: session.id, submitId, submitStatus: 'queued', + costUnitPrice: channel.unitPrice ?? 0, + costAmountCents: (channel.unitPrice ?? 0) * Math.max(1, message.billingUnits ?? 1), }, }); await this.prisma.smsMessageRecord.update({ @@ -2276,10 +2282,37 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { }); } - private async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string) { + private async resolveTemplateMessageClassification(templateId: string | undefined, content: string) { + if (!templateId) return { signatureId: undefined, drainageInfoId: undefined }; + const template = await this.prisma.smsTemplate.findUnique({ + where: { id: templateId }, + select: { signatureId: true }, + }); + const signatureId = template?.signatureId ?? undefined; + return { signatureId, drainageInfoId: await this.resolveDrainageInfoId(signatureId, content) }; + } + + private async resolveDrainageInfoId(signatureId: string | undefined, content: string) { + if (!signatureId) return undefined; + const candidates = await this.prisma.smsDrainageInfo.findMany({ + where: { signatureId, auditStatus: 'approved' }, + select: { id: true, url: true, updatedAt: true }, + orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }], + }); + const matches = candidates + .map((item) => ({ ...item, normalizedUrl: item.url.trim() })) + .filter((item) => item.normalizedUrl.length > 0 && content.includes(item.normalizedUrl)) + .sort((left, right) => right.normalizedUrl.length - left.normalizedUrl.length || right.updatedAt.getTime() - left.updatedAt.getTime()); + if (matches.length === 0) return undefined; + const longestLength = matches[0].normalizedUrl.length; + const longestMatches = matches.filter((item) => item.normalizedUrl.length === longestLength); + return longestMatches.length === 1 ? longestMatches[0].id : undefined; + } + + private async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) { await this.prisma.smsMessageRecord.update({ where: { id: messageRecordId }, - data: { reviewTaskId, signatureId, status: 'pending_review' }, + data: { reviewTaskId, signatureId, drainageInfoId, status: 'pending_review' }, }); return this.prisma.smsSendTask.findUnique({ where: { id: reviewTaskId } }); } diff --git a/api/src/sms-config/admin-sms-config.controller.ts b/api/src/sms-config/admin-sms-config.controller.ts index 38d06a8..d0b3691 100644 --- a/api/src/sms-config/admin-sms-config.controller.ts +++ b/api/src/sms-config/admin-sms-config.controller.ts @@ -23,6 +23,11 @@ export class AdminSmsConfigController { return this.smsConfig.getApplicationReportFields(applicationId, reportType); } + @Get('report-fields/common') + getCommonReportFields(@Query('reportType') reportType?: 'signature' | 'drainage') { + return this.smsConfig.getApplicationReportFields(undefined, reportType); + } + @Post('enterprise-applications') @RequireRecentAuthentication() createApplication(@Body() body: CreateSmsApplicationDto) { diff --git a/api/src/sms-config/client-sms-config.controller.ts b/api/src/sms-config/client-sms-config.controller.ts index 6da8a59..af303c5 100644 --- a/api/src/sms-config/client-sms-config.controller.ts +++ b/api/src/sms-config/client-sms-config.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { TenantId } from '../common/tenant-id.decorator'; import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator'; @@ -35,8 +35,13 @@ export class ClientSmsConfigController { } @Get('applications/:id/report-fields') - getApplicationReportFields(@Param('id') applicationId: string, @TenantId() tenantId?: string) { - return this.smsConfig.getApplication(applicationId, tenantId).then(() => this.smsConfig.getApplicationReportFields(applicationId, 'drainage')); + getApplicationReportFields(@Param('id') applicationId: string, @Query('reportType') reportType: 'signature' | 'drainage' = 'drainage', @TenantId() tenantId?: string) { + return this.smsConfig.getApplication(applicationId, tenantId).then(() => this.smsConfig.getApplicationReportFields(applicationId, reportType)); + } + + @Get('report-fields/common') + getCommonReportFields(@Query('reportType') reportType: 'signature' | 'drainage' = 'drainage') { + return this.smsConfig.getApplicationReportFields(undefined, reportType); } @Post('applications/:id/secret/reset') diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index 9219a59..9440859 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -52,6 +52,10 @@ function createPrismaMock() { { id: 'rule-2', applicationId: 'app-1', groupId: 'group-unicom', carrier: 'unicom', priority: 20, status: 'active' }, ]), }, + commonReportField: { + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + }, smsSignature: { findMany: jest.fn().mockResolvedValue([{ id: 'sig-1', @@ -544,6 +548,59 @@ describe('SmsConfigService', () => { ]); }); + it('merges common report fields into every target channel requirement', async () => { + const prisma = createPrismaMock(); + prisma.commonReportField.findMany.mockResolvedValue([{ + id: 'common-1', reportType: 'signature', required: true, status: 'active', + drainageField: { id: 'field-common', code: 'creditCode', name: '统一社会信用代码', fieldType: 'string', description: null, status: 'active' }, + }]); + prisma.channelRouteRule.findMany.mockResolvedValue([{ + id: 'route-1', priority: 10, + group: { + id: 'group-1', name: '默认通道组', + items: [{ channel: { id: 'channel-1', code: 'CH-1', name: '通道一', reportFields: [] } }], + }, + }] as never); + const service = new SmsConfigService(prisma as never); + + await expect(service.getApplicationReportFields('app-1', 'signature')).resolves.toEqual([ + expect.objectContaining({ + id: 'field-common', + required: true, + reportTypes: ['signature'], + commonReportTypes: ['signature'], + channels: [expect.objectContaining({ id: 'channel-1', source: 'common', required: true })], + }), + ]); + }); + + it('requires common signature fields even when a signature is not bound to an application', async () => { + const prisma = createPrismaMock(); + prisma.commonReportField.findMany.mockResolvedValue([{ + id: 'common-1', reportType: 'signature', required: true, status: 'active', + drainageField: { id: 'field-common', code: 'creditCode', name: '统一社会信用代码', fieldType: 'string', description: null, status: 'active' }, + }]); + const service = new SmsConfigService(prisma as never); + + await expect(service.createSignature({ tenantId: 'tenant-1', name: '无应用签名' })) + .rejects.toThrow('缺少必填签名报备资料:统一社会信用代码'); + expect(prisma.smsSignature.create).not.toHaveBeenCalled(); + }); + + it('requires common drainage fields when adding drainage info without an application', async () => { + const prisma = createPrismaMock(); + prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: null, auditStatus: 'approved' }); + prisma.commonReportField.findMany.mockResolvedValue([{ + id: 'common-2', reportType: 'drainage', required: true, status: 'active', + drainageField: { id: 'field-site', code: 'siteOwner', name: '网站主体', fieldType: 'string', description: null, status: 'active' }, + }]); + const service = new SmsConfigService(prisma as never); + + await expect(service.createDrainageInfo('sig-1', { siteName: '官网', url: 'https://example.com', reportValues: {} }, {}, 'tenant-1')) + .rejects.toThrow('引流信息缺少必填报备资料:网站主体'); + expect(prisma.smsDrainageInfo.create).not.toHaveBeenCalled(); + }); + it('validates and persists dynamic signature report values by channel without bypassing drainage audit', async () => { const prisma = createPrismaMock(); prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'draft' }); diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index 1601717..6cb2950 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -223,27 +223,38 @@ export class SmsConfigService { return application; } - async getApplicationReportFields(applicationId: string, reportType?: 'signature' | 'drainage') { - await this.getApplication(applicationId); - const routes = await this.prisma.channelRouteRule.findMany({ - where: { applicationId, status: 'active' }, - include: { - group: { - include: { - items: { - include: { - channel: { - include: { - reportFields: { include: { drainageField: true }, orderBy: { sortOrder: 'asc' } }, + async getApplicationReportFields(applicationId?: string, reportType?: 'signature' | 'drainage') { + if (applicationId) await this.getApplication(applicationId); + const [commonFields, routes] = await Promise.all([ + this.prisma.commonReportField.findMany({ + where: { + status: 'active', + reportType, + drainageField: { status: 'active' }, + }, + include: { drainageField: true }, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], + }), + applicationId ? this.prisma.channelRouteRule.findMany({ + where: { applicationId, status: 'active' }, + include: { + group: { + include: { + items: { + include: { + channel: { + include: { + reportFields: { include: { drainageField: true }, orderBy: { sortOrder: 'asc' } }, + }, }, }, }, }, }, }, - }, - orderBy: { priority: 'asc' }, - }); + orderBy: { priority: 'asc' }, + }) : Promise.resolve([]), + ]); type MergedReportField = { id: string; code: string; @@ -252,9 +263,43 @@ export class SmsConfigService { required: boolean; description?: string | null; reportTypes: string[]; - channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: string }>; + commonReportTypes: string[]; + channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: string; source: 'common' | 'channel' | 'both' }>; }; const merged = new Map(); + const routeChannels = new Map(); + for (const route of routes) { + if (!route.group) continue; + for (const item of route.group.items) { + if (!routeChannels.has(item.channel.id)) { + routeChannels.set(item.channel.id, { + id: item.channel.id, + code: item.channel.code, + name: item.channel.name, + groupId: route.group.id, + groupName: route.group.name, + }); + } + } + } + for (const configured of commonFields) { + merged.set(configured.drainageField.id, { + id: configured.drainageField.id, + code: configured.drainageField.code, + name: configured.drainageField.name, + fieldType: configured.drainageField.fieldType, + required: configured.required, + description: configured.drainageField.description, + reportTypes: [configured.reportType], + commonReportTypes: [configured.reportType], + channels: Array.from(routeChannels.values()).map((channel) => ({ + ...channel, + required: configured.required, + reportType: configured.reportType, + source: 'common' as const, + })), + }); + } for (const route of routes) { if (!route.group) continue; for (const item of route.group.items) { @@ -270,11 +315,17 @@ export class SmsConfigService { required: false, description: configured.drainageField.description, reportTypes: [], + commonReportTypes: [], channels: [], }; current.required = current.required || configured.required; if (!current.reportTypes.includes(configured.reportType)) current.reportTypes.push(configured.reportType); - if (!current.channels.some((channel) => channel.id === item.channel.id)) { + const existingChannel = current.channels.find((channel) => channel.id === item.channel.id); + if (existingChannel) { + existingChannel.required = existingChannel.required || configured.required; + existingChannel.reportType = configured.reportType; + existingChannel.source = existingChannel.source === 'common' ? 'both' : existingChannel.source; + } else { current.channels.push({ id: item.channel.id, code: item.channel.code, @@ -283,6 +334,7 @@ export class SmsConfigService { groupName: route.group.name, required: configured.required, reportType: configured.reportType, + source: 'channel', }); } merged.set(key, current); @@ -653,6 +705,9 @@ export class SmsConfigService { where: { applicationId: { in: applicationIds }, status: 'active' }, include: { group: { include: { items: { include: { channel: { include: { reportFields: true } } } } } } }, }) : []; + const hasCommonDrainageFields = await this.prisma.commonReportField.count({ + where: { status: 'active', reportType: 'drainage', drainageField: { status: 'active' } }, + }).then((count) => count > 0); return signatures.map((signature) => { const legacyPayload = isRecord(signature.drainageInfo) ? signature.drainageInfo : {}; const drainageLinks = signature.drainageItems.map((item) => ({ @@ -681,7 +736,7 @@ export class SmsConfigService { const channels = routes .filter((route) => route.applicationId === signature.applicationId && route.group) .flatMap((route) => route.group!.items.map((item) => item.channel)) - .filter((channel) => channel.status !== 'deleted' && channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType))); + .filter((channel) => channel.status !== 'deleted' && (hasCommonDrainageFields || channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType)))); const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task])); return [drainageItemId, [...new Map(channels.map((channel) => [channel.id, channel])).values()].flatMap((channel) => { const task = taskByChannel.get(channel.id); @@ -693,7 +748,7 @@ export class SmsConfigService { const channels = routes .filter((route) => route.applicationId === signature.applicationId && route.group) .flatMap((route) => route.group!.items.map((item) => item.channel)) - .filter((channel) => channel.status !== 'deleted' && channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType))); + .filter((channel) => channel.status !== 'deleted' && (hasCommonDrainageFields || channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType)))); const targets = [...new Map(channels.map((channel) => [channel.id, channel])).values()]; const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task])); return [drainageItemId, Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => { @@ -882,7 +937,7 @@ export class SmsConfigService { } private async withReportRequirementSnapshot(applicationId?: string, drainageInfo?: Record) { - if (!drainageInfo || !applicationId) return drainageInfo; + if (!drainageInfo) return drainageInfo; const fields = await this.getApplicationReportFields(applicationId); return { ...drainageInfo, @@ -896,6 +951,7 @@ export class SmsConfigService { fieldType: field.fieldType, required: field.required, reportTypes: field.reportTypes, + commonReportTypes: field.commonReportTypes, channels: field.channels, })), }, @@ -903,7 +959,7 @@ export class SmsConfigService { } private async syncSignatureReportValues(signatureId: string, applicationId?: string, drainageInfo?: Record) { - if (!applicationId || !drainageInfo) return; + if (!drainageInfo) return; const fields = await this.getApplicationReportFields(applicationId); const signatureValues = isRecord(drainageInfo.signatureReportValues) ? drainageInfo.signatureReportValues : {}; for (const field of fields.filter((item) => item.reportTypes.some((type) => type === 'signature' || type === 'both'))) { @@ -919,9 +975,8 @@ export class SmsConfigService { } private async validateSignatureReportValues(applicationId?: string, drainageInfo?: Record) { - if (!applicationId || !drainageInfo) return; - const fields = await this.getApplicationReportFields(applicationId); - const signatureValues = isRecord(drainageInfo.signatureReportValues) ? drainageInfo.signatureReportValues : {}; + const fields = await this.getApplicationReportFields(applicationId, 'signature'); + const signatureValues = isRecord(drainageInfo?.signatureReportValues) ? drainageInfo.signatureReportValues : {}; const missingSignature = fields .filter((field) => field.required && field.reportTypes.some((type) => type === 'signature' || type === 'both')) .filter((field) => !hasReportValue(signatureValues[field.code])); @@ -931,7 +986,6 @@ export class SmsConfigService { } private async validateDrainageReportValues(applicationId?: string, reportValues: Record = {}) { - if (!applicationId) return; const fields = await this.getApplicationReportFields(applicationId, 'drainage'); const missing = fields.filter((field) => field.required && !hasReportValue(reportValues[field.code])); if (missing.length > 0) { diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 10c9231..6d0d059 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -194,10 +194,10 @@ ### 4.7 通道签名报备 -1. 运营端先在“报备字段库”维护字段编码、名称、类型、是否必填等标准定义;字段代码只允许阿拉伯数字和英文大小写字母,字段类型只允许字符串、图片、文件三种。通道报备详情只能从字段库选择字段,并指定用途为签名报备、引流信息报备或两者共用,不得在通道内另建同名孤立字段。 -2. 通道组配置通道,企业应用通过路由规则选择通道组。企业签名和引流信息编辑时,系统必须沿“企业应用 -> 生效路由规则 -> 通道组 -> 组内通道 -> 通道报备字段”实时解析字段合集。 +1. 运营端先在“报备字段库”维护字段编码、名称、类型、是否必填等标准定义;字段代码只允许阿拉伯数字和英文大小写字母,字段类型只允许字符串、图片、文件三种。字段库支持将任意字段分别配置为“通用签名报备资料”或“通用引流信息报备资料”,通用配置可真实新增和删除。通道报备详情仍可选择包括通用字段在内的任意字段库字段,并指定用途为签名报备、引流信息报备或两者共用,不得在通道内另建同名孤立字段。 +2. 通道组配置通道,企业应用通过路由规则选择通道组。企业签名和引流信息添加/编辑时,系统必须合并“全局通用字段”和“企业应用 -> 生效路由规则 -> 通道组 -> 组内通道 -> 通道报备字段”;即使签名暂未绑定应用,也必须展示并校验对应类型的通用字段。 3. 同一字段被多个通道引用时按字段库记录去重;任一通道将该字段配置为必填,则企业资料中按必填处理,并保留该字段来源的全部通道用于后续分别报备。 -4. 企业签名弹窗只展示签名报备/两者共用字段;每条引流信息只展示引流信息报备/两者共用字段。文件字段走真实对象存储上传,其他字段保存真实值,必填校验同时在前端和 NestJS API 执行。 +4. 企业签名弹窗不再维护固定的签名依据、资质凭证、企业信息或责任人信息分组,所有签名报备资料均由通用字段和通道字段动态生成;每条引流信息同样只使用动态引流报备字段。文件字段走真实对象存储上传,其他字段保存真实值,必填校验同时在运营端、客户端和 NestJS API 执行。 5. 签名动态资料保留在签名记录;每条引流信息必须保存为独立 `SmsDrainageInfo` PostgreSQL 实体,包含所属企业、签名、应用、站点、地址、动态字段、审核状态和驳回原因,不得再以签名 JSON 数组作为引流审核事实来源。 6. 客户端上传签名资料后进入签名审核;签名审核通过后方可新增引流信息。客户端新建或修改引流信息均自动进入 `pending`,运营端必须在独立“引流信息审核”页面查看资料后通过或带原因驳回。运营端在企业签名管理中新增或修改引流信息视为运营操作,自动审核通过并写审核记录。 7. 引流信息审核通过前不得创建新的通道报备任务、写入可导出的引流报备材料或人工修改通道报备状态;已报备引流信息再次修改时,原通道任务冻结为 `waiting_review` 且旧材料停止使用。审核通过后系统按应用当前真实路由通道生成/重置 `reportType=drainage` 任务与材料,并写报备记录。 @@ -527,6 +527,22 @@ - “今日返还金额”按当天实际恢复到企业可用余额的消息级流水汇总:包含已扣费后的 `refunded`,以及提交前失败后按 `sms_message_record` 释放的 `released`;任务冻结转扣费过程中按 `sms_batch_task` 产生的内部释放不得计入返还。 - 计费口径可配置为按提交成功计费或按回执成功计费。 +#### 5.19.1 报表对账 + +- 在“数据详单”之后增加“报表对账”一级菜单,包含“对账单”和“利润报表”两个二级菜单;页面必须读取真实 NestJS API 与 PostgreSQL 报表表,不得在前端按明细临时拼接或使用静态数据。 +- 对账单按发送日期、企业、企业应用汇总日发送条数和成功条数。发送条数、成功条数均按短信计费条数 `billingUnits` 统计,成功以最终 `delivered` 状态为准。 +- 利润报表按发送日期汇总日发送条数、成功条数、消费金额、成本金额、利润和利润率,支持在“企业应用”和“通道”两个统计维度间切换。 +- 企业应用维度的消费金额只统计仍为 `charged` 的客户账单,最终失败并退款的短信不再形成收入;成本金额统计该应用短信所有上游 `accepted` 提交的通道成本,包括补发产生的真实额外成本。 +- 通道维度按实际上游 `accepted` 提交统计发送量和成本,按同一 Gateway 消息回执统计成功量;客户收入只归属最终有效提交,避免补发时重复计算收入。通道成本单价和成本金额必须在提交记录创建时快照,后续修改通道单价不得改写历史成本。 +- 利润等于消费金额减成本金额;利润率等于利润除以消费金额,消费金额为 0 时利润率按 0 展示。所有金额继续使用整数分持久化并按三位小数展示。 +- 报表按北京时间 T+1 生成,不生成当天未完整数据;每日刷新时必须在同一事务内重新生成 T-4 至 T-1 四个完整自然日,使 72 小时内到达或变化的回执能够修正发送成功和利润结果。 +- API 启动后自动补生成最近四个完整自然日,并按日执行滚动刷新;报表查询支持服务端日期、企业、应用、通道和维度过滤及分页。 +- “报表对账”增加“发送质量报表”,包含企业应用、通道、签名、引流信息四个 Tab。每个 Tab 按发送日期和对应维度展示发送条数、成功条数、成功率、平均到达时长,并默认按发送条数从大到小排序。 +- 发送质量的发送和成功条数均按 `billingUnits` 统计,成功以 delivered 为准;企业应用、签名和引流信息按短信最终状态统计,通道按真实 accepted 提交及同一 Gateway 消息的 delivered 回执统计。 +- 平均到达时长只使用成功且时间有效的短信,按 `deliveredAt - submittedAt` 计算;每个日期、每个维度组先计算 P95,剔除大于 P95 的最慢 5% 样本后再求平均。无成功或无有效时间样本时展示为空,不以 0 冒充。 +- `SmsMessageRecord` 必须固化实际使用的 `drainageInfoId`。新短信在同签名已审核通过的引流信息中按正文精确包含 URL 匹配,优先最长 URL;最长 URL 出现多个同长度候选时视为歧义并不关联。历史短信使用同一规则回填,未命中或歧义统一归入“未关联引流信息”,不得把一条短信复制到签名下所有引流信息造成重复统计。 +- 发送质量报表与对账、利润报表共用 T+1 及 T-4 至 T-1 滚动重算任务,每次重算在同一日期事务内重建四个质量维度。 + ### 5.20 数据保存与清理 - 发送记录保存 12 个月。 diff --git a/docs/production-deployment.md b/docs/production-deployment.md index f56ec9a..016cf2d 100644 --- a/docs/production-deployment.md +++ b/docs/production-deployment.md @@ -45,6 +45,8 @@ OPERATION_LOG_ARCHIVE_INTERVAL_MS=86400000 SMS_RECEIPT_TIMEOUT_SCAN_ENABLED=true SMS_RECEIPT_TIMEOUT_HOURS=72 SMS_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS=300000 +REPORT_DAILY_REFRESH_ENABLED=true +REPORT_REFRESH_INTERVAL_MS=3600000 CMPP_DOWNSTREAM_ACK_TIMEOUT_SECONDS=30 GATEWAY_CMPP_ADDR=0.0.0.0:17890 OBJECT_STORAGE_DRIVER=minio @@ -62,6 +64,8 @@ PROD_ADMIN_PASSWORD='change-me' `API_ENABLE_SEND_WORKER=true` 是生产发送链路必填项。后续发布脚本会在构建和迁移前校验该开关以及正整数 `API_SEND_WORKER_CONCURRENCY`;缺失时直接终止发布,防止 API/Gateway 健康但 BullMQ 短信队列无人消费。 +日报任务默认启用,并由 `REPORT_REFRESH_INTERVAL_MS` 每小时检查一次北京时间业务日是否变化;每个业务日只执行一次 T-4 至 T-1 重算。服务重启后也会自动补跑最近四个完整自然日,确保 72 小时回执更新反映到对账和利润报表。 + 如生产验证服务器临时无法稳定下载 MinIO,可显式传入 `OBJECT_STORAGE_DRIVER=local`,文件会通过真实 API 保存到服务器本地目录 `OBJECT_STORAGE_LOCAL_ROOT`,`cmpp-minio` 服务会跳过安装和启动。该模式只建议用于验证环境;正式生产建议恢复 `OBJECT_STORAGE_DRIVER=minio`。 ## 后续发布 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 01aa82e..8d16e88 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -328,12 +328,14 @@ - 前置条件:存在两个 active 通道、一个包含这两个通道的通道组,以及绑定该通道组的企业应用。 - 步骤: 1. 在报备字段库创建图片字段“营业执照”和字符串字段“网站主体”,并直接调用 API 尝试创建整数、网址、电话、日期等其他类型。 - 2. 在通道一将营业执照配置为签名报备必填,在通道二将同一字段配置为两者共用非必填,并将网站主体配置为引流信息报备必填。 - 3. 打开该企业应用下的企业签名编辑弹窗和引流信息编辑弹窗。 + 2. 将营业执照配置为通用签名报备必填,将网站主体配置为通用引流信息报备必填;再在通道一选择同一个营业执照字段配置为签名报备,在通道二配置其他通道专用字段。 + 3. 分别打开绑定应用和不绑定应用的企业签名添加/编辑弹窗,以及对应引流信息添加/编辑弹窗;同时在客户端执行新增签名和新增/修改引流信息。 4. 分别尝试缺少必填值保存,再补齐文件和值后保存。 5. 查询 PostgreSQL 中签名、签名报备材料和引流报备材料记录;删除该引流项后再次查询。 - 预期结果: - - 营业执照按字段库 ID 合并为一个字段,且因任一目标通道必填而整体必填;签名弹窗展示营业执照,引流弹窗展示网站主体及两者共用字段。 + - 通用字段和通道字段按字段库 ID 合并去重;营业执照在所有签名添加/编辑表单中出现,网站主体在所有引流信息添加/编辑表单中出现,不绑定应用也不能绕过通用必填校验。 + - 企业签名弹窗不再展示固定签名依据、资质凭证、企业信息和责任人信息;资料全部来自动态配置。 + - 通用字段仍可在通道报备字段选择器中选中;删除通用配置不删除字段定义和历史资料,字段仍被通道或通用配置引用时禁止删除字段定义。 ### TC-ADMIN-005B 引流信息按通道报备及三入口同步 @@ -3259,6 +3261,20 @@ npm run verify:phase8 | TC-BILLING-013 | 准备已提交扣费但 72 小时完全无回执的 `submitted` 短信,以及有 `UNKNOWN` 回执且超过 72 小时的短信;启动 API 定时扫描并模拟重复扫描。 | 两类短信都转为 timeout 并退款;任务进度刷新;同一短信只退款一次;定时扫描默认启用且每 5 分钟执行。 | | TC-SEC-006 | 安装 API 生产依赖并执行 `npm audit`;使用缺文件、多文件、超大文件、超量字段和正常单文件调用认证后的 multipart 上传接口。 | NestJS/Multer/Hono 已升级或锁定到修复版本,生产依赖 audit 为 0;接口只接受一个不超过 20MB 的文件,并限制字段、part、字段名、字段值和 header pair 数量;异常请求返回受控 4xx,正常文件仍写入真实 MinIO 和 `FileObject`。 | +### 17.5.1 报表对账细化 + +| 用例 | 细化执行点 | 必查断言 | +| --- | --- | --- | +| TC-REPORT-001 | 在同一发送日准备多个企业和应用的单条、长短信,覆盖 delivered、failed、unknown;次日执行报表刷新并按日期、企业、应用查询对账单。 | 只生成 T-1 及更早完整日期;发送和成功均按 `billingUnits` 汇总;成功只包含最终 delivered;企业与应用隔离正确;API 使用 PostgreSQL 报表表和服务端分页。 | +| TC-REPORT-002 | 准备已扣费成功、最终失败退款、同通道成功和跨通道补发成功短信,分别按企业应用和通道查看利润报表。 | 企业应用消费只包含 charged;退款不算收入;所有 accepted 尝试均按成本快照计入成本;通道维度收入只归属最终提交且不重复;利润=消费-成本,利润率计算正确,收入为 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 数据。 | +| TC-QUALITY-001 | 为同一日期、同一维度准备 100 条 delivered 短信,构造不同的 `submittedAt/deliveredAt`,其中最慢 5 条显著偏大。 | 发送量和成功量按 billingUnits 汇总;成功率精确;平均到达时长先按组计算 P95,只平均小于等于 P95 的样本,最慢 5% 不进入均值;无有效成功时间时返回空值。 | +| TC-QUALITY-002 | 分别准备多个企业应用、通道、签名和引流信息的短信,并制造 accepted 补发及不同 Gateway 回执。 | 四个 Tab 分组正确;通道只使用对应 submit/receipt;每个 Tab 服务端按 sentUnits 降序,相同数量再按日期和名称稳定排序;T-4~T-1 重算同步更新四类质量行。 | +| TC-QUALITY-003 | 同一签名配置短 URL、包含短 URL 的长 URL、两个同长度 URL;发送正文分别命中长 URL、唯一 URL、同长度歧义和完全未命中,再对历史记录执行 migration。 | 新短信与历史短信都优先关联唯一最长 approved URL;歧义和未命中不写伪造 ID 并归入“未关联引流信息”;每条短信在引流维度只统计一次。 | +| TC-QUALITY-004 | 打开“发送质量报表”,依次切换企业应用、通道、签名、引流信息 Tab,使用日期、企业、应用、通道筛选并翻页。 | 菜单位于“报表对账”下;页面调用真实 `/admin/reports/quality`;展示发送量、成功量、成功率、P95 截尾平均时长和生成时间,不使用前端明细聚合。 | + ### 17.6 系统日志细化 | 用例 | 细化执行点 | 必查断言 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 1f962fa..1496381 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -1,5 +1,28 @@ # 第一版系统化测试进度 +## 2026-07-15 发送质量报表(未提交、未部署) + +- “报表对账”新增“发送质量报表”,包含企业应用、通道、签名、引流信息四个真实 Tab;统一展示日发送条数、成功条数、成功率、平均到达时长、生成时间,并由 API 按发送条数降序分页。 +- 新增 `DailyQualityReport` 和第 46 条 migration;质量日报与现有日报任务共同按北京时间 T+1 生成并每日重算 T-4 至 T-1。企业应用/签名/引流按短信最终状态聚合,通道按 accepted submit 与对应 Gateway delivered 回执聚合。 +- 平均到达时长使用成功短信的 `deliveredAt - submittedAt`,按日期和维度计算 PostgreSQL `PERCENTILE_CONT(0.95)`,只对小于等于 P95 的样本求平均;无有效成功时间时保存 null。 +- `SmsMessageRecord` 新增真实 `drainageInfoId`。新短信在同签名 approved 引流信息中按正文 URL 精确匹配并取唯一最长 URL;migration 对历史短信用相同规则回填,歧义或未命中统一进入“未关联引流信息”,不重复展开。 +- 本地真实 PostgreSQL 已应用 46 条 migration,并实际执行四个质量维度的 T-4~T-1 生成 SQL;现有真实短信成功生成企业应用、未关联签名和未关联引流信息质量行,未关联行按企业应用隔离并可正常筛选。Prisma validate/generate/migrate status、报表与发送链路定向 2 suites/58 项、API 全量 18 suites/183 项、API build、前端 build、Gateway 全量 Go 测试、`npm audit` 0 漏洞和 `git diff --check` 通过;前端仅有既有 Vite chunk size warning。 + +## 2026-07-15 通用报备字段与签名资料动态化(未提交、未部署) + +- 新增真实 PostgreSQL `CommonReportField` 配置表,字段库可将字段分别配置为通用签名报备资料或通用引流信息报备资料,并支持真实新增、删除;删除配置不删除字段定义或历史材料。字段被通道或通用配置引用时,字段库定义均不能直接删除。 +- 应用报备资料字段统一合并“通用字段 + 当前应用生效通道组/通道字段”,按字段库 ID 去重并合并必填规则。未绑定应用的签名和引流信息仍返回、展示并由 NestJS 强制校验通用字段;通用字段仍保留在通道报备字段的字段库选择项中。 +- 运营端企业签名添加/编辑弹窗移除固定签名依据、资质凭证、企业信息和责任人信息,所有资料改由动态字段生成。运营端和客户端的签名、引流信息添加/编辑均加载对应通用字段;客户端新增签名不再走旧固定材料上传,改为把动态值写入真实签名资料 payload,文件继续上传 MinIO/FileObject。 +- migration `20260715170000_add_common_report_fields` 已在本地 PostgreSQL 成功应用,当前 45 条 migration 全部齐全。Prisma format/generate/validate、API 定向 2 suites/39 项、API 全量 18 suites/181 项、API build、前端 build 和 `git diff --check` 通过;前端仅有既有 Vite chunk size warning。尚未提交、push 或部署。 + +## 2026-07-15 报表对账与利润报表(未提交、未部署) + +- 在“数据详单”后新增“报表对账”一级菜单及“对账单”“利润报表”二级菜单;两页调用真实 `/api/admin/reports/reconciliation`、`/api/admin/reports/profit`,提供日期、企业、企业应用、通道、统计维度和服务端分页。 +- 新增 `DailyReconciliationReport`、`DailyProfitReport` 真实 PostgreSQL 表和第 44 条 migration。报表按北京时间 T+1 生成,API 启动后及每日任务重算 T-4 至 T-1;每个日期在独立事务内删除旧聚合并重建,覆盖 72 小时回执更新窗口。 +- 发送量和成功量按 `billingUnits` 统计;成功取最终 delivered。企业应用利润的消费取当前 charged 账单,退款不计收入;成本累计所有 accepted 上游提交,因此补发成本不会漏算。通道利润按实际 accepted 尝试及对应 Gateway delivered 回执汇总,收入只归属最终提交。 +- `SmsSubmitRecord` 新增成本单价和金额快照,创建提交记录时写入;migration 按当时现有通道价回填历史记录。SubmitResult 更新同时收窄为优先按 `submitId` 更新,避免一次补发结果覆盖同一短信的其他尝试并污染成本。 +- 本地真实 PostgreSQL 已成功应用 migration 并实际执行 2026-07-11 至 2026-07-14 的 T-4~T-1 生成 SQL;Prisma schema validate/generate 和 migrate status 通过,44 条 migration 全部齐全。报表与发送链路定向 2 suites/56 项、API 全量 18 suites/176 项、API build、前端 build、Gateway 全量 Go 测试和 `git diff --check` 全部通过;前端仅有既有 Vite chunk size warning。本批未修改依赖,`npm audit` 延续上一批 0 漏洞锁文件;本轮在线复查因 npm registry TLS 建链连续两次失败,未将网络失败误记为 audit 成功。 + ## 2026-07-15 依赖安全与今日返还修复(已提交、已部署) - 生产数据库只读核查确认目标企业当天共有两笔真实返还:提交前路由失败产生消息级 `released=5` 分,最终失败产生 `refunded=5` 分,正确合计为 10 分(页面应显示 `¥0.100`)。原 Dashboard 和企业列表只聚合 `refunded`,因此少算前一笔并显示 5 分。 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index 7f88190..273019c 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -581,7 +581,16 @@ export type ApplicationReportField = { required: boolean; description?: string | null; reportTypes: string[]; - channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: 'signature' | 'drainage' | 'both' }>; + commonReportTypes?: Array<'signature' | 'drainage'>; + channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: 'signature' | 'drainage' | 'both'; source?: 'common' | 'channel' | 'both' }>; +}; + +export type CommonReportField = DictionaryItem & { + drainageFieldId: string; + reportType: 'signature' | 'drainage'; + required: boolean; + sortOrder: number; + drainageField: DictionaryItem & { code?: string; name?: string; fieldType?: string; description?: string | null }; }; export type ReportTask = DictionaryItem & { @@ -696,6 +705,59 @@ export type PagedResponse = { pageSize: number; }; +export type DailyReconciliationReport = { + id: string; + reportDate: string; + tenantId: string; + tenantName: string; + applicationId: string; + applicationName: string; + sentUnits: number; + successUnits: number; + generatedAt: string; + updatedAt: string; +}; + +export type DailyProfitReport = { + id: string; + reportDate: string; + dimensionType: 'application' | 'channel'; + dimensionId: string; + dimensionName: string; + tenantId?: string | null; + tenantName?: string | null; + applicationId?: string | null; + channelId?: string | null; + sentUnits: number; + successUnits: number; + revenueCents: number; + costCents: number; + profitCents: number; + profitRateBps: number; + generatedAt: string; + updatedAt: string; +}; + +export type DailyQualityReport = { + id: string; + reportDate: string; + dimensionType: 'application' | 'channel' | 'signature' | 'drainage'; + dimensionId: string; + dimensionName: string; + tenantId?: string | null; + tenantName?: string | null; + applicationId?: string | null; + channelId?: string | null; + signatureId?: string | null; + drainageInfoId?: string | null; + sentUnits: number; + successUnits: number; + successRateBps: number; + avgArrivalMs?: number | null; + generatedAt: string; + updatedAt: string; +}; + export type CursorPage = { items: T[]; pageSize: number; @@ -979,9 +1041,17 @@ export const adminApi = { request(`/admin/enterprise-applications/${applicationId}/connections`), listApplicationReportFields: (applicationId: string, reportType?: 'signature' | 'drainage') => request(withQuery(`/admin/enterprise-applications/${applicationId}/report-fields`, { reportType })), + listCommonApplicationReportFields: (reportType: 'signature' | 'drainage') => + request(withQuery('/admin/report-fields/common', { reportType })), getApplicationCmppParams: (applicationId: string) => request(`/admin/enterprise-applications/${applicationId}/cmpp-params`), listChannels: () => request('/admin/channels'), + listReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string; page?: number; pageSize?: number } = {}) => + request>(withQuery('/admin/reports/reconciliation', query)), + listProfitReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) => + request & { dimensionType: 'application' | 'channel' }>(withQuery('/admin/reports/profit', query)), + listQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) => + request & { dimensionType: DailyQualityReport['dimensionType'] }>(withQuery('/admin/reports/quality', query)), createChannel: (body: Partial & { passwordCipher?: string; desiredConnections?: number; windowSize?: number }) => request('/admin/channels', { method: 'POST', body: JSON.stringify(body) }), updateChannel: (id: string, body: Partial & { passwordCipher?: string; desiredConnections?: number; windowSize?: number }) => @@ -1150,6 +1220,10 @@ export const adminApi = { createDrainageField: (body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; required?: boolean; status?: string; description?: string }) => request('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }), deleteDrainageField: (id: string) => request(`/admin/dictionaries/drainage-fields/${id}`, { method: 'DELETE' }), + listCommonReportFields: () => request('/admin/dictionaries/common-report-fields'), + createCommonReportField: (body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean; sortOrder?: number }) => + request('/admin/dictionaries/common-report-fields', { method: 'POST', body: JSON.stringify(body) }), + deleteCommonReportField: (id: string) => request(`/admin/dictionaries/common-report-fields/${id}`, { method: 'DELETE' }), uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId?: string) => { const form = new FormData(); form.set('file', file); @@ -1215,8 +1289,10 @@ export const clientApi = { request('/client/applications', { tenantId }), getApplicationCmppParams: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/cmpp-params`, { tenantId }), - listApplicationReportFields: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(`/client/applications/${applicationId}/report-fields`, { tenantId }), + listApplicationReportFields: (applicationId: string, reportType: 'signature' | 'drainage' = 'drainage', tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(withQuery(`/client/applications/${applicationId}/report-fields`, { reportType }), { tenantId }), + listCommonApplicationReportFields: (reportType: 'signature' | 'drainage', tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(withQuery('/client/report-fields/common', { reportType }), { tenantId }), listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/signatures', { tenantId }), createSignature: (body: { tenantId?: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => diff --git a/src/apps/admin/AdminDrainageFieldsPage.tsx b/src/apps/admin/AdminDrainageFieldsPage.tsx index 2a1a70f..86f4b64 100644 --- a/src/apps/admin/AdminDrainageFieldsPage.tsx +++ b/src/apps/admin/AdminDrainageFieldsPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Plus, Search, Trash2 } from 'lucide-react'; import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, Tag, type TableColumn } from '@/components/ui'; -import { adminApi, type DictionaryItem } from '@/api/adminApi'; +import { adminApi, type CommonReportField, type DictionaryItem } from '@/api/adminApi'; type DrainageField = DictionaryItem & { code?: string; @@ -10,6 +10,7 @@ type DrainageField = DictionaryItem & { required?: boolean; description?: string | null; usageCount?: number; + commonUsageCount?: number; }; type ReportFieldType = 'string' | 'image' | 'file'; @@ -25,6 +26,7 @@ const typeLabels: Record = { string: '字符串', image: '图片 export function AdminDrainageFieldsPage() { const [fields, setFields] = useState([]); + const [commonFields, setCommonFields] = useState([]); const [keyword, setKeyword] = useState(''); const [appliedKeyword, setAppliedKeyword] = useState(''); const [type, setType] = useState('all'); @@ -36,12 +38,18 @@ export function AdminDrainageFieldsPage() { const [description, setDescription] = useState(''); const [error, setError] = useState(''); const [deleteTarget, setDeleteTarget] = useState(null); + const [configuringCommon, setConfiguringCommon] = useState(false); + const [commonFieldId, setCommonFieldId] = useState(''); + const [commonReportType, setCommonReportType] = useState<'signature' | 'drainage'>('signature'); + const [commonRequired, setCommonRequired] = useState(false); + const [commonDeleteTarget, setCommonDeleteTarget] = useState(null); const codeError = code && !/^[A-Za-z0-9]+$/.test(code) ? '字段代码只能包含阿拉伯数字和英文大小写字母' : ''; function loadData() { - adminApi.listDrainageFields() - .then((items) => { + Promise.all([adminApi.listDrainageFields(), adminApi.listCommonReportFields()]) + .then(([items, commonItems]) => { setFields(items as DrainageField[]); + setCommonFields(commonItems); setError(''); }) .catch((failure: Error) => setError(failure.message || '报备字段加载失败')); @@ -74,7 +82,7 @@ export function AdminDrainageFieldsPage() { } function deleteField() { - if (!deleteTarget || (deleteTarget.usageCount ?? 0) > 0) return; + if (!deleteTarget || (deleteTarget.usageCount ?? 0) > 0 || (deleteTarget.commonUsageCount ?? 0) > 0) return; adminApi.deleteDrainageField(deleteTarget.id) .then(() => { setDeleteTarget(null); @@ -83,6 +91,29 @@ export function AdminDrainageFieldsPage() { .catch((failure: Error) => setError(failure.message || '报备字段删除失败')); } + function createCommonField() { + if (!commonFieldId) return; + adminApi.createCommonReportField({ drainageFieldId: commonFieldId, reportType: commonReportType, required: commonRequired }) + .then(() => { + setCommonFieldId(''); + setCommonReportType('signature'); + setCommonRequired(false); + setConfiguringCommon(false); + loadData(); + }) + .catch((failure: Error) => setError(failure.message || '通用字段配置失败')); + } + + function deleteCommonField() { + if (!commonDeleteTarget) return; + adminApi.deleteCommonReportField(commonDeleteTarget.id) + .then(() => { + setCommonDeleteTarget(null); + loadData(); + }) + .catch((failure: Error) => setError(failure.message || '通用字段删除失败')); + } + const columns = useMemo>>(() => [ { key: 'code', title: '字段代码', width: '160px', render: (record) => {record.code} }, { key: 'name', title: '字段名称', width: '190px', render: (record) => record.name ?? '-' }, @@ -90,7 +121,17 @@ export function AdminDrainageFieldsPage() { { key: 'description', title: '描述', render: (record) => record.description ?? '-' }, { key: 'required', title: '是否必填', width: '120px', render: (record) => {record.required ? '必填' : '选填'} }, { key: 'usageCount', title: '使用通道数', width: '130px', render: (record) => 0 ? 'warning' : 'neutral'}>{record.usageCount ?? 0} }, - { key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => }, + { key: 'commonUsageCount', title: '通用配置数', width: '130px', render: (record) => 0 ? 'info' : 'neutral'}>{record.commonUsageCount ?? 0} }, + { key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => }, + ], []); + + const commonColumns = useMemo>>(() => [ + { key: 'name', title: '字段名称', render: (record) => {String(record.drainageField.name ?? record.drainageField.code ?? '-')} }, + { key: 'code', title: '字段代码', width: '170px', render: (record) => String(record.drainageField.code ?? '-') }, + { key: 'reportType', title: '资料用途', width: '180px', render: (record) => {record.reportType === 'signature' ? '签名报备资料' : '引流信息报备资料'} }, + { key: 'fieldType', title: '字段类型', width: '130px', render: (record) => typeLabels[String(record.drainageField.fieldType ?? '')] ?? String(record.drainageField.fieldType ?? '-') }, + { key: 'required', title: '是否必填', width: '120px', render: (record) => {record.required ? '必填' : '选填'} }, + { key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => }, ], []); return ( @@ -114,9 +155,34 @@ export function AdminDrainageFieldsPage() {
+
+
+

通用字段配置

+

通用字段会与企业应用目标通道配置的字段合并,分别用于签名报备资料和引流信息报备资料。

+
+ +
+ + + +
+

字段库

字段定义被通道或通用配置引用后,需要先删除对应配置才能删除字段。

+ } + onClose={() => setConfiguringCommon(false)} + open={configuringCommon} + title="配置通用字段" + > +
+ setCommonReportType(event.target.value as 'signature' | 'drainage')} options={[{ label: '签名报备资料', value: 'signature' }, { label: '引流信息报备资料', value: 'drainage' }]} value={commonReportType} /> + updateProfile('basis', event.target.value)} - options={[ - { label: '请选择签名依据', value: '' }, - { label: '企事业单位证明', value: 'company' }, - { label: '商标注册证', value: 'trademark' }, - { label: '授权委托书', value: 'authorization' }, - ]} - value={form.profile.basis} - /> update('name', event.target.value)} placeholder="请输入短信签名,如【XXXX公司】" required value={form.name} />
- updateProfile('credentialFile', file)} - /> -
-

公司信息

-
- updateProfile('companyName', event.target.value)} placeholder="请输入公司名称" value={form.profile.companyName} /> - updateProfile('creditCode', event.target.value)} placeholder="请输入统一社会信用代码" value={form.profile.creditCode} /> - updateProfile('legalPersonName', event.target.value)} placeholder="请输入法人姓名" value={form.profile.legalPersonName} /> - updateProfile('legalPersonIdCard', event.target.value)} placeholder="请输入法人身份证号" value={form.profile.legalPersonIdCard} /> - updateProfile('legalFrontFile', file)} /> - updateProfile('legalBackFile', file)} /> -
-
- -
-

责任人信息

-
- updateProfile('responsibleName', event.target.value)} placeholder="请输入责任人姓名" value={form.profile.responsibleName} /> - updateProfile('responsiblePhone', event.target.value)} placeholder="请输入责任人手机号" value={form.profile.responsiblePhone} /> - updateProfile('responsibleIdCard', event.target.value)} placeholder="请输入责任人身份证号" value={form.profile.responsibleIdCard} /> - updateProfile('responsibleFrontFile', file)} /> - updateProfile('responsibleBackFile', file)} /> -
-
- - +
@@ -494,8 +405,10 @@ function DrainageFormModal({ applicationId, item, onClose, onSubmit }: { applica }); useEffect(() => { - if (!applicationId) return; - adminApi.listApplicationReportFields(applicationId, 'drainage').then(setReportFields).catch(() => setReportFields([])); + const request = applicationId + ? adminApi.listApplicationReportFields(applicationId, 'drainage') + : adminApi.listCommonApplicationReportFields('drainage'); + request.then(setReportFields).catch(() => setReportFields([])); }, [applicationId]); function update(key: Key, value: DrainageInfo[Key]) { @@ -541,7 +454,7 @@ function DrainageFormModal({ applicationId, item, onClose, onSubmit }: { applica update('siteName', event.target.value)} placeholder="请输入引流信息" value={form.siteName} />