feat: add reconciliation and quality reporting
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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<string, MergedReportField>();
|
||||
const routeChannels = new Map<string, { id: string; code: string; name: string; groupId: string; groupName: string }>();
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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<string, unknown> = {}) {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user