feat: inherit channel report requirements

This commit is contained in:
hectorzhao
2026-07-12 16:14:54 +08:00
parent 053bcca990
commit 87ae4a2063
14 changed files with 612 additions and 65 deletions
@@ -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;
+26
View File
@@ -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
+22
View File
@@ -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();
+26 -9
View File
@@ -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 '连接请求';
@@ -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);
@@ -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);
+193 -5
View File
@@ -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<string, MergedReportField>();
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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
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<string, unknown> {
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;
}