From 87ae4a20637152ceb63b8b3a881536ebcaff36eb Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Sun, 12 Jul 2026 16:14:54 +0800 Subject: [PATCH] feat: inherit channel report requirements --- .../migration.sql | 33 +++ api/prisma/schema.prisma | 26 +++ api/src/channels/channels.service.spec.ts | 22 ++ api/src/channels/channels.service.ts | 35 +++- .../sms-config/admin-sms-config.controller.ts | 5 + api/src/sms-config/sms-config.service.spec.ts | 77 +++++++ api/src/sms-config/sms-config.service.ts | 198 +++++++++++++++++- .../first-version-development-requirements.md | 19 +- docs/system-functional-test-cases.md | 16 ++ docs/testing-progress.md | 10 + src/api/adminApi.ts | 16 ++ src/apps/admin/AdminChannelReportPage.tsx | 45 ++-- .../admin/AdminEnterpriseSignaturesPage.tsx | 133 ++++++++++-- src/styles/global.css | 42 ++++ 14 files changed, 612 insertions(+), 65 deletions(-) create mode 100644 api/prisma/migrations/20260712150000_link_report_field_library/migration.sql diff --git a/api/prisma/migrations/20260712150000_link_report_field_library/migration.sql b/api/prisma/migrations/20260712150000_link_report_field_library/migration.sql new file mode 100644 index 0000000..f627f23 --- /dev/null +++ b/api/prisma/migrations/20260712150000_link_report_field_library/migration.sql @@ -0,0 +1,33 @@ +ALTER TABLE "ChannelReportField" +ADD COLUMN "drainageFieldId" TEXT, +ADD COLUMN "reportType" TEXT NOT NULL DEFAULT 'both'; + +CREATE INDEX "ChannelReportField_drainageFieldId_reportType_idx" +ON "ChannelReportField"("drainageFieldId", "reportType"); + +ALTER TABLE "ChannelReportField" +ADD CONSTRAINT "ChannelReportField_drainageFieldId_fkey" +FOREIGN KEY ("drainageFieldId") REFERENCES "DrainageField"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +CREATE TABLE "DrainageReportMaterial" ( + "id" TEXT NOT NULL, + "signatureId" TEXT NOT NULL, + "drainageItemId" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + "fieldCode" TEXT NOT NULL, + "fieldValue" TEXT, + "fileObjectId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "DrainageReportMaterial_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "DrainageReportMaterial_signatureId_drainageItemId_channelId_fieldCode_key" +ON "DrainageReportMaterial"("signatureId", "drainageItemId", "channelId", "fieldCode"); +CREATE INDEX "DrainageReportMaterial_channelId_fieldCode_idx" +ON "DrainageReportMaterial"("channelId", "fieldCode"); + +ALTER TABLE "DrainageReportMaterial" ADD CONSTRAINT "DrainageReportMaterial_signatureId_fkey" +FOREIGN KEY ("signatureId") REFERENCES "SmsSignature"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "DrainageReportMaterial" ADD CONSTRAINT "DrainageReportMaterial_channelId_fkey" +FOREIGN KEY ("channelId") REFERENCES "SmsChannel"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 416bbb1..908c714 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -235,6 +235,8 @@ model DrainageField { description String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + channelReportFields ChannelReportField[] } model BillingPlan { @@ -410,6 +412,7 @@ model SmsSignature { materials SignatureMaterial[] templates SmsTemplate[] reportMaterials SignatureReportMaterial[] + drainageReportMaterials DrainageReportMaterial[] reportTasks ChannelSignatureReportTask[] messageRecords SmsMessageRecord[] @@ -512,6 +515,7 @@ model SmsChannel { routeRules ChannelRouteRule[] healthMetrics ChannelHealthMetric[] reportFields ChannelReportField[] + drainageReportMaterials DrainageReportMaterial[] reportTasks ChannelSignatureReportTask[] reportRecords ChannelSignatureReportRecord[] messageRecords SmsMessageRecord[] @@ -658,6 +662,8 @@ model ChannelHealthMetric { model ChannelReportField { id String @id @default(cuid()) channelId String + drainageFieldId String? + reportType String @default("both") code String name String fieldType String @@ -669,8 +675,10 @@ model ChannelReportField { updatedAt DateTime @updatedAt channel SmsChannel @relation(fields: [channelId], references: [id], onDelete: Cascade) + drainageField DrainageField? @relation(fields: [drainageFieldId], references: [id]) @@unique([channelId, code]) + @@index([drainageFieldId, reportType]) } model SignatureReportMaterial { @@ -689,6 +697,24 @@ model SignatureReportMaterial { @@index([channelId]) } +model DrainageReportMaterial { + id String @id @default(cuid()) + signatureId String + drainageItemId String + channelId String + fieldCode String + fieldValue String? + fileObjectId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + signature SmsSignature @relation(fields: [signatureId], references: [id], onDelete: Cascade) + channel SmsChannel @relation(fields: [channelId], references: [id], onDelete: Cascade) + + @@unique([signatureId, drainageItemId, channelId, fieldCode]) + @@index([channelId, fieldCode]) +} + model ChannelSignatureReportTask { id String @id @default(cuid()) tenantId String diff --git a/api/src/channels/channels.service.spec.ts b/api/src/channels/channels.service.spec.ts index f02ab74..ddbfc0a 100644 --- a/api/src/channels/channels.service.spec.ts +++ b/api/src/channels/channels.service.spec.ts @@ -94,6 +94,9 @@ function createPrismaMock() { findMany: jest.fn(), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })), }, + drainageField: { + findUnique: jest.fn().mockResolvedValue({ id: 'library-1', code: 'license', name: '营业执照', fieldType: 'file', required: true, status: 'active', description: '执照文件' }), + }, signatureReportMaterial: { findMany: jest.fn().mockResolvedValue([{ signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }]), createMany: jest.fn(), @@ -152,6 +155,25 @@ function createPrismaMock() { } describe('ChannelsService', () => { + it('creates channel report requirements only from the report field library', async () => { + const prisma = createPrismaMock(); + const service = new ChannelsService(prisma as never); + + await service.createReportField({ channelId: 'channel-1', drainageFieldId: 'library-1', reportType: 'signature', code: 'ignored', name: 'ignored', fieldType: 'string' }); + + expect(prisma.channelReportField.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + channelId: 'channel-1', + drainageFieldId: 'library-1', + reportType: 'signature', + code: 'license', + name: '营业执照', + fieldType: 'file', + required: true, + }), + }); + }); + beforeEach(() => { mockQueueAdd.mockClear(); mockQueueClose.mockClear(); diff --git a/api/src/channels/channels.service.ts b/api/src/channels/channels.service.ts index ac838de..cd797c4 100644 --- a/api/src/channels/channels.service.ts +++ b/api/src/channels/channels.service.ts @@ -75,9 +75,11 @@ export interface CreateRouteRuleDto { export interface CreateReportFieldDto { channelId: string; - code: string; - name: string; - fieldType: string; + drainageFieldId: string; + reportType: 'signature' | 'drainage' | 'both'; + code?: string; + name?: string; + fieldType?: string; required?: boolean; description?: string; sortOrder?: number; @@ -363,6 +365,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { config: source.config as Prisma.InputJsonValue | undefined, reportFields: { create: source.reportFields.map((field) => ({ + drainageFieldId: field.drainageFieldId, + reportType: field.reportType, code: field.code, name: field.name, fieldType: field.fieldType, @@ -894,19 +898,27 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { listReportFields(channelId?: string) { return this.prisma.channelReportField.findMany({ where: channelId ? { channelId } : undefined, + include: { drainageField: true }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], }); } - createReportField(data: CreateReportFieldDto) { + async createReportField(data: CreateReportFieldDto) { + const field = await this.prisma.drainageField.findUnique({ where: { id: data.drainageFieldId } }); + if (!field || field.status !== 'active') { + throw new BadRequestException('报备字段库字段不存在或已停用'); + } + const reportType = normalizeReportType(data.reportType); return this.prisma.channelReportField.create({ data: { channelId: data.channelId, - code: data.code, - name: data.name, - fieldType: data.fieldType, - required: data.required ?? false, - description: data.description, + drainageFieldId: field.id, + reportType, + code: field.code, + name: field.name, + fieldType: field.fieldType, + required: data.required ?? field.required, + description: data.description ?? field.description, sortOrder: data.sortOrder ?? 100, status: data.status ?? 'active', }, @@ -1627,6 +1639,11 @@ function validateGroupItems( } } +function normalizeReportType(value?: string) { + if (value === 'signature' || value === 'drainage' || value === 'both') return value; + throw new BadRequestException('reportType must be signature, drainage or both'); +} + function normalizeLinkEvent(action: string) { if (action.includes('connect_requested')) { return '连接请求'; diff --git a/api/src/sms-config/admin-sms-config.controller.ts b/api/src/sms-config/admin-sms-config.controller.ts index 79eaf3a..bc5e234 100644 --- a/api/src/sms-config/admin-sms-config.controller.ts +++ b/api/src/sms-config/admin-sms-config.controller.ts @@ -17,6 +17,11 @@ export class AdminSmsConfigController { return this.smsConfig.getApplication(applicationId); } + @Get('enterprise-applications/:id/report-fields') + getApplicationReportFields(@Param('id') applicationId: string, @Query('reportType') reportType?: 'signature' | 'drainage') { + return this.smsConfig.getApplicationReportFields(applicationId, reportType); + } + @Post('enterprise-applications') createApplication(@Body() body: CreateSmsApplicationDto) { return this.smsConfig.createApplication(body); diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index ffc2556..18d6ebc 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -68,6 +68,13 @@ function createPrismaMock() { findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', auditStatus: 'pending' }), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', ...data })), }, + signatureReportMaterial: { + upsert: jest.fn().mockResolvedValue({ id: 'signature-report-value-1' }), + }, + drainageReportMaterial: { + upsert: jest.fn().mockResolvedValue({ id: 'drainage-report-value-1' }), + deleteMany: jest.fn().mockResolvedValue({ count: 0 }), + }, smsTemplate: { findMany: jest.fn().mockResolvedValue([{ id: 'tpl-1', @@ -427,6 +434,76 @@ describe('SmsConfigService', () => { })); }); + it('merges report fields from every channel in the application channel groups', async () => { + const prisma = createPrismaMock(); + prisma.channelRouteRule.findMany.mockResolvedValue([ + { + id: 'route-1', priority: 10, + group: { + id: 'group-1', name: '默认通道组', + items: [ + { channel: { id: 'channel-1', code: 'CH-1', name: '通道一', reportFields: [{ status: 'active', required: false, reportType: 'signature', drainageField: { id: 'field-1', code: 'license', name: '营业执照', fieldType: 'file', description: null, status: 'active' } }] } }, + { channel: { id: 'channel-2', code: 'CH-2', name: '通道二', reportFields: [{ status: 'active', required: true, reportType: 'both', drainageField: { id: 'field-1', code: 'license', name: '营业执照', fieldType: 'file', description: null, status: 'active' } }] } }, + ], + }, + }, + ] as never); + const service = new SmsConfigService(prisma as never); + + await expect(service.getApplicationReportFields('app-1', 'signature')).resolves.toEqual([ + expect.objectContaining({ + id: 'field-1', + code: 'license', + required: true, + reportTypes: ['signature', 'both'], + channels: [ + expect.objectContaining({ id: 'channel-1', groupId: 'group-1' }), + expect.objectContaining({ id: 'channel-2', groupId: 'group-1' }), + ], + }), + ]); + }); + + it('validates and persists dynamic signature and drainage report values by channel', async () => { + const prisma = createPrismaMock(); + prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'draft' }); + prisma.smsSignature.update.mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', ...data })); + prisma.channelRouteRule.findMany.mockResolvedValue([{ + id: 'route-1', priority: 10, + group: { + id: 'group-1', name: '默认通道组', + items: [{ + channel: { + id: 'channel-1', code: 'CH-1', name: '通道一', + reportFields: [ + { status: 'active', required: true, reportType: 'signature', drainageField: { id: 'field-1', code: 'license', name: '营业执照', fieldType: 'file', description: null, status: 'active' } }, + { status: 'active', required: true, reportType: 'drainage', drainageField: { id: 'field-2', code: 'site_owner', name: '网站主体', fieldType: 'text', description: null, status: 'active' } }, + ], + }, + }], + }, + }] as never); + const service = new SmsConfigService(prisma as never); + + await service.updateSignature('sig-1', { + applicationId: 'app-1', + drainageInfo: { + signatureReportValues: { license: { fileObjectId: 'file-1', fileName: 'license.pdf' } }, + links: [{ id: 'drain-1', reportValues: { site_owner: '企业A' } }], + }, + }); + + expect(prisma.signatureReportMaterial.upsert).toHaveBeenCalledWith(expect.objectContaining({ + create: expect.objectContaining({ signatureId: 'sig-1', channelId: 'channel-1', fieldCode: 'license', fileObjectId: 'file-1' }), + })); + expect(prisma.drainageReportMaterial.upsert).toHaveBeenCalledWith(expect.objectContaining({ + create: expect.objectContaining({ signatureId: 'sig-1', drainageItemId: 'drain-1', channelId: 'channel-1', fieldCode: 'site_owner', fieldValue: '企业A' }), + })); + expect(prisma.drainageReportMaterial.deleteMany).toHaveBeenCalledWith({ + where: { signatureId: 'sig-1', drainageItemId: { notIn: ['drain-1'] } }, + }); + }); + it('updates enterprise signature drainage info through the admin API path', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index 0a560c3..72ea346 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -181,6 +181,75 @@ 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' } }, + }, + }, + }, + }, + }, + }, + }, + orderBy: { priority: 'asc' }, + }); + type MergedReportField = { + id: string; + code: string; + name: string; + fieldType: string; + required: boolean; + description?: string | null; + reportTypes: string[]; + channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: string }>; + }; + const merged = new Map(); + for (const route of routes) { + if (!route.group) continue; + for (const item of route.group.items) { + for (const configured of item.channel.reportFields) { + if (configured.status !== 'active' || !configured.drainageField || configured.drainageField.status !== 'active') continue; + if (reportType && configured.reportType !== 'both' && configured.reportType !== reportType) continue; + const key = configured.drainageField.id; + const current: MergedReportField = merged.get(key) ?? { + id: configured.drainageField.id, + code: configured.drainageField.code, + name: configured.drainageField.name, + fieldType: configured.drainageField.fieldType, + required: false, + description: configured.drainageField.description, + reportTypes: [], + 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)) { + current.channels.push({ + id: item.channel.id, + code: item.channel.code, + name: item.channel.name, + groupId: route.group.id, + groupName: route.group.name, + required: configured.required, + reportType: configured.reportType, + }); + } + merged.set(key, current); + } + } + } + return Array.from(merged.values()); + } + async createApplication(data: CreateSmsApplicationDto) { const secret = normalizeApplicationPassword(data.passwordCipher); const queuePriority = normalizeApplicationQueuePriority(data.queuePriority); @@ -519,16 +588,20 @@ export class SmsConfigService { }); } - createSignature(data: CreateSmsSignatureDto) { - return this.prisma.smsSignature.create({ + async createSignature(data: CreateSmsSignatureDto) { + await this.validateSignatureReportValues(data.applicationId, data.drainageInfo); + const drainageInfo = await this.withReportRequirementSnapshot(data.applicationId, data.drainageInfo); + const signature = await this.prisma.smsSignature.create({ data: { tenantId: data.tenantId, applicationId: data.applicationId, name: data.name, purpose: data.purpose, - drainageInfo: data.drainageInfo as Prisma.InputJsonValue | undefined, + drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, }, }); + await this.syncSignatureReportValues(signature.id, data.applicationId, drainageInfo); + return signature; } async updateSignature(signatureId: string, data: UpdateSmsSignatureDto) { @@ -536,17 +609,114 @@ export class SmsConfigService { if (!signature) { throw new NotFoundException('Signature not found'); } - return this.prisma.smsSignature.update({ + await this.validateSignatureReportValues(data.applicationId ?? signature.applicationId ?? undefined, data.drainageInfo); + const applicationId = data.applicationId ?? signature.applicationId ?? undefined; + const drainageInfo = data.drainageInfo + ? await this.withReportRequirementSnapshot(applicationId, data.drainageInfo) + : undefined; + const updated = await this.prisma.smsSignature.update({ where: { id: signatureId }, data: { applicationId: data.applicationId, name: data.name, purpose: data.purpose, auditStatus: data.auditStatus, - drainageInfo: data.drainageInfo as Prisma.InputJsonValue | undefined, + drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, }, include: { materials: true, tenant: true, application: true }, }); + await this.syncSignatureReportValues(signatureId, updated.applicationId ?? undefined, drainageInfo); + return updated; + } + + private async withReportRequirementSnapshot(applicationId?: string, drainageInfo?: Record) { + if (!drainageInfo || !applicationId) return drainageInfo; + const fields = await this.getApplicationReportFields(applicationId); + return { + ...drainageInfo, + reportRequirementSnapshot: { + capturedAt: new Date().toISOString(), + applicationId, + fields: fields.map((field) => ({ + id: field.id, + code: field.code, + name: field.name, + fieldType: field.fieldType, + required: field.required, + reportTypes: field.reportTypes, + channels: field.channels, + })), + }, + }; + } + + private async syncSignatureReportValues(signatureId: string, applicationId?: string, drainageInfo?: Record) { + if (!applicationId || !drainageInfo) return; + const fields = await this.getApplicationReportFields(applicationId); + const signatureValues = isRecord(drainageInfo.signatureReportValues) ? drainageInfo.signatureReportValues : {}; + const links = Array.isArray(drainageInfo.links) ? drainageInfo.links.filter(isRecord) : []; + const drainageItemIds = links.map((link) => String(link.id ?? '')).filter(Boolean); + await this.prisma.drainageReportMaterial.deleteMany({ + where: { + signatureId, + ...(drainageItemIds.length > 0 ? { drainageItemId: { notIn: drainageItemIds } } : {}), + }, + }); + for (const field of fields.filter((item) => item.reportTypes.some((type) => type === 'signature' || type === 'both'))) { + const value = reportValueParts(signatureValues[field.code]); + for (const channel of field.channels) { + await this.prisma.signatureReportMaterial.upsert({ + where: { signatureId_channelId_fieldCode: { signatureId, channelId: channel.id, fieldCode: field.code } }, + update: value, + create: { signatureId, channelId: channel.id, fieldCode: field.code, ...value }, + }); + } + } + for (const link of links) { + const drainageItemId = String(link.id ?? ''); + const values = isRecord(link.reportValues) ? link.reportValues : {}; + if (!drainageItemId) continue; + for (const field of fields.filter((item) => item.reportTypes.some((type) => type === 'drainage' || type === 'both'))) { + const value = reportValueParts(values[field.code]); + for (const channel of field.channels) { + await this.prisma.drainageReportMaterial.upsert({ + where: { + signatureId_drainageItemId_channelId_fieldCode: { + signatureId, + drainageItemId, + channelId: channel.id, + fieldCode: field.code, + }, + }, + update: value, + create: { signatureId, drainageItemId, channelId: channel.id, fieldCode: field.code, ...value }, + }); + } + } + } + } + + 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 missingSignature = fields + .filter((field) => field.required && field.reportTypes.some((type) => type === 'signature' || type === 'both')) + .filter((field) => !hasReportValue(signatureValues[field.code])); + if (missingSignature.length > 0) { + throw new BadRequestException(`缺少必填签名报备资料:${missingSignature.map((field) => field.name).join('、')}`); + } + const drainageFields = fields.filter( + (field) => field.required && field.reportTypes.some((type) => type === 'drainage' || type === 'both'), + ); + const links = Array.isArray(drainageInfo.links) ? drainageInfo.links.filter(isRecord) : []; + for (const link of links) { + const values = isRecord(link.reportValues) ? link.reportValues : {}; + const missing = drainageFields.filter((field) => !hasReportValue(values[field.code])); + if (missing.length > 0) { + throw new BadRequestException(`引流信息缺少必填报备资料:${missing.map((field) => field.name).join('、')}`); + } + } } createSignatureMaterial(data: CreateSignatureMaterialDto) { @@ -934,3 +1104,21 @@ function parseGatewayDate(value?: string) { const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? undefined : parsed; } + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function reportValueParts(value: unknown) { + if (isRecord(value) && typeof value.fileObjectId === 'string') { + return { fieldValue: typeof value.fileName === 'string' ? value.fileName : undefined, fileObjectId: value.fileObjectId }; + } + return { fieldValue: value === undefined || value === null ? undefined : String(value), fileObjectId: undefined }; +} + +function hasReportValue(value: unknown) { + if (isRecord(value)) { + return Boolean(value.fileObjectId || value.fieldValue || value.value); + } + return value !== undefined && value !== null && String(value).trim().length > 0; +} diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 903b7c4..481923d 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -180,15 +180,16 @@ ### 4.7 通道签名报备 -1. 运营端在通道配置中维护签名报备字段。 -2. 客户端上传签名资料。 -3. 运营端审核企业签名资料。 -4. 运营端在通道资料更新后生成通道签名报备任务。 -5. 运营端在报备任务中导出通道报备资料。 -6. 运营端在报备任务或通道报备详情页导入通道回执。 -7. 系统根据回执同步签名在各通道的报备状态。 -8. 报备记录保留每次导出、导入、状态变更和操作人。 -9. 发送前必须校验最终选中通道上的签名报备任务为 approved;补发切换到新通道时必须重新按新通道校验报备状态,未通过则该次发送失败。 +1. 运营端先在“报备字段库”维护字段编码、名称、类型、是否必填等标准定义;通道报备详情只能从字段库选择字段,并指定用途为签名报备、引流信息报备或两者共用,不得在通道内另建同名孤立字段。 +2. 通道组配置通道,企业应用通过路由规则选择通道组。企业签名和引流信息编辑时,系统必须沿“企业应用 -> 生效路由规则 -> 通道组 -> 组内通道 -> 通道报备字段”实时解析字段合集。 +3. 同一字段被多个通道引用时按字段库记录去重;任一通道将该字段配置为必填,则企业资料中按必填处理,并保留该字段来源的全部通道用于后续分别报备。 +4. 企业签名弹窗只展示签名报备/两者共用字段;每条引流信息只展示引流信息报备/两者共用字段。文件字段走真实对象存储上传,其他字段保存真实值,必填校验同时在前端和 NestJS API 执行。 +5. 企业资料保存后,原始动态值随签名 JSON 保存,同时按实际目标通道分别写入签名报备材料和引流报备材料表,供通道报备任务导出使用;删除引流项时同步清理其规范化材料记录。 +6. 客户端上传签名资料,运营端审核企业签名资料。 +7. 运营端在通道资料更新后生成通道签名报备任务,并在报备任务中导出通道报备资料。 +8. 运营端在报备任务或通道报备详情页导入通道回执,系统根据回执同步签名在各通道的报备状态。 +9. 报备记录保留每次导出、导入、状态变更和操作人。 +10. 发送前必须校验最终选中通道上的签名报备任务为 approved;补发切换到新通道时必须重新按新通道校验报备状态,未通过则该次发送失败。 ### 4.8 CMPP Gateway 与外部接入 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index d4f61a4..eb5b18d 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -296,6 +296,22 @@ - 生成导出文件记录。 - 报备记录包含 create/export 两个动作。 +### TC-ADMIN-005A 报备字段库到企业资料动态继承 + +- 优先级:P0 +- 前置条件:存在两个 active 通道、一个包含这两个通道的通道组,以及绑定该通道组的企业应用。 +- 步骤: + 1. 在报备字段库创建文件字段“营业执照”和文本字段“网站主体”。 + 2. 在通道一将营业执照配置为签名报备必填,在通道二将同一字段配置为两者共用非必填,并将网站主体配置为引流信息报备必填。 + 3. 打开该企业应用下的企业签名编辑弹窗和引流信息编辑弹窗。 + 4. 分别尝试缺少必填值保存,再补齐文件和值后保存。 + 5. 查询 PostgreSQL 中签名、签名报备材料和引流报备材料记录;删除该引流项后再次查询。 +- 预期结果: + - 营业执照按字段库 ID 合并为一个字段,且因任一目标通道必填而整体必填;签名弹窗展示营业执照,引流弹窗展示网站主体及两者共用字段。 + - 缺少必填资料时前端禁止提交;直接调用 API 也返回 400,不能绕过页面保存不完整资料。 + - 文件通过真实对象存储上传;动态值随签名 JSON 保存,并按来源通道分别写入规范化报备材料表。 + - 删除引流项后,对应引流报备材料记录被同步删除,不保留可被后续导出误用的孤立资料。 + ### TC-ADMIN-006 报备回执导入通过 - 优先级:P0 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 0e19d5d..473ddf3 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -1618,3 +1618,13 @@ git diff --check - 聚合窗口关闭前,任务不返回到待审列表且审核接口拒绝提前操作,避免窗口内后到短信加入已完成任务。 - Prisma migration:`20260712113000_add_cmpp_review_aggregation`。已执行 API 全量测试(13 suites、129 项通过)、API build、前端 build、Prisma validate 和 `git diff --check`。 - 已将 `8b6ec92f` 部署生产,部署前完成 PostgreSQL 和发布源码备份,migration 已成功应用。`SmsSendTask` 5 个聚合字段、`SmsMessageRecord.reviewTaskId/signatureId` 均已存在;生产应用中 `manual_review` 3 个、`reject` 201 个。审核 API 返回 200,当前无待审样本,未为验收人工注入短信或修改业务数据。`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均为 active,`12026/17890/8090/3000` 监听、API/Gateway health 和外部 `12026` HTTP 均通过。 + +## 2026-07-12 报备字段库到企业签名/引流资料完整链路 + +- `ChannelReportField` 通过 `drainageFieldId` 真实关联报备字段库,并增加 `reportType=signature/drainage/both`;通道报备配置页改为选择字段库字段和报备用途,不再在通道内手工复制字段编码、名称和类型。 +- 新增企业应用报备字段解析 API,按生效的应用路由规则遍历通道组及组内通道,以字段库 ID 求合集;同字段任一通道必填即整体必填,并返回全部来源通道。 +- 企业签名与引流信息弹窗根据所选企业应用动态加载字段合集。签名只展示签名/共用字段,引流项只展示引流/共用字段;文件字段继续走真实 MinIO/对象存储上传,文本值和文件对象 ID 均提交 NestJS API。 +- API 在写签名前执行必填校验,防止绕过前端;保存后同步写入各目标通道的 `SignatureReportMaterial` 和新增的 `DrainageReportMaterial`,删除引流项时同步清理旧材料。 +- 为避免运营人员面对动态资料时无法理解来源,签名和引流编辑页增加“通道组数/通道数/字段数/必填数”摘要、字段级来源说明和“为什么需要这些资料”解释弹窗;弹窗按企业应用、通道组、通道逐级展示字段用途及必填口径。每次保存同时在签名 JSON 中固化 `reportRequirementSnapshot`,记录当时的字段与来源通道,供配置变化后的历史追溯。 +- Prisma migration:`20260712150000_link_report_field_library`。已执行 Prisma generate/validate、`channels.service.spec.ts + sms-config.service.spec.ts`(2 suites、44 项通过)、API build 和前端 build;前端仅有既有 chunk size warning。 +- 本轮按用户要求仅完成本地代码与验证,尚未提交、push 或部署生产。 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index 4b84beb..f17851f 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -509,12 +509,26 @@ export type ChannelGroupItem = DictionaryItem & { export type ChannelReportField = DictionaryItem & { channelId: string; + drainageFieldId?: string | null; + reportType?: 'signature' | 'drainage' | 'both'; code: string; name: string; fieldType: string; required: boolean; description?: string | null; sortOrder?: number; + drainageField?: DictionaryItem | null; +}; + +export type ApplicationReportField = { + id: string; + code: string; + name: string; + fieldType: string; + 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' }>; }; export type ReportTask = DictionaryItem & { @@ -876,6 +890,8 @@ export const adminApi = { }), listApplicationConnections: (applicationId: string) => request(`/admin/enterprise-applications/${applicationId}/connections`), + listApplicationReportFields: (applicationId: string, reportType?: 'signature' | 'drainage') => + request(withQuery(`/admin/enterprise-applications/${applicationId}/report-fields`, { reportType })), getApplicationCmppParams: (applicationId: string) => request(`/admin/enterprise-applications/${applicationId}/cmpp-params`), listChannels: () => request('/admin/channels'), diff --git a/src/apps/admin/AdminChannelReportPage.tsx b/src/apps/admin/AdminChannelReportPage.tsx index 398e462..08a8ef9 100644 --- a/src/apps/admin/AdminChannelReportPage.tsx +++ b/src/apps/admin/AdminChannelReportPage.tsx @@ -1,27 +1,29 @@ import { useEffect, useMemo, useState } from 'react'; import { ArrowLeft, Plus, Search } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; -import { adminApi, type AdminChannel, type ChannelReportField } from '@/api/adminApi'; +import { adminApi, type AdminChannel, type ChannelReportField, type DictionaryItem } from '@/api/adminApi'; import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, Tag, type TableColumn } from '@/components/ui'; export function AdminChannelReportPage() { const navigate = useNavigate(); const [channels, setChannels] = useState([]); const [fields, setFields] = useState([]); + const [libraryFields, setLibraryFields] = useState([]); const [channelId, setChannelId] = useState(''); const [keyword, setKeyword] = useState(''); const [modalOpen, setModalOpen] = useState(false); - const [code, setCode] = useState(''); - const [name, setName] = useState(''); - const [fieldType, setFieldType] = useState('string'); + const [drainageFieldId, setDrainageFieldId] = useState(''); + const [reportType, setReportType] = useState<'signature' | 'drainage' | 'both'>('signature'); + const [required, setRequired] = useState(false); const [description, setDescription] = useState(''); const [error, setError] = useState(''); function loadData(nextChannelId = channelId) { - Promise.all([adminApi.listChannels(), adminApi.listChannelReportFields(nextChannelId || undefined)]) - .then(([channelItems, fieldItems]) => { + Promise.all([adminApi.listChannels(), adminApi.listChannelReportFields(nextChannelId || undefined), adminApi.listDrainageFields()]) + .then(([channelItems, fieldItems, libraryItems]) => { setChannels(channelItems.filter((item) => item.status !== 'deleted')); setFields(fieldItems); + setLibraryFields(libraryItems.filter((item) => item.status === 'active')); setError(''); }) .catch((failure: Error) => setError(failure.message || '通道报备配置加载失败')); @@ -34,12 +36,14 @@ export function AdminChannelReportPage() { const filteredFields = useMemo(() => fields.filter((field) => !keyword || [field.code, field.name, field.fieldType, field.description].join(' ').includes(keyword)), [fields, keyword]); function createField() { - adminApi.createChannelReportField({ channelId, code, name, fieldType, description, status: 'active' }) + const selected = libraryFields.find((item) => item.id === drainageFieldId); + if (!selected) return; + adminApi.createChannelReportField({ channelId, drainageFieldId, reportType, required, description, status: 'active' }) .then(() => { setModalOpen(false); - setCode(''); - setName(''); - setFieldType('string'); + setDrainageFieldId(''); + setReportType('signature'); + setRequired(false); setDescription(''); loadData(); }) @@ -51,6 +55,7 @@ export function AdminChannelReportPage() { { key: 'code', title: '字段代码', width: '160px', render: (record) => {record.code} }, { key: 'name', title: '字段名称', width: '160px', render: (record) => record.name }, { key: 'type', title: '字段类型', width: '120px', render: (record) => record.fieldType }, + { key: 'reportType', title: '报备用途', width: '140px', render: (record) => record.reportType === 'signature' ? '签名报备' : record.reportType === 'drainage' ? '引流信息报备' : '签名+引流' }, { key: 'required', title: '必填', width: '90px', render: (record) => {record.required ? '是' : '否'} }, { key: 'description', title: '说明', render: (record) => record.description ?? '-' }, ]; @@ -87,26 +92,20 @@ export function AdminChannelReportPage() { } + footer={<>} onClose={() => setModalOpen(false)} open={modalOpen} title="新增通道报备字段" >
- setCode(event.target.value)} value={code} /> - setName(event.target.value)} value={name} /> setReportType(event.target.value as typeof reportType)} options={[{ label: '签名报备', value: 'signature' }, { label: '引流信息报备', value: 'drainage' }, { label: '签名+引流', value: 'both' }]} value={reportType} /> +