feat: inherit channel report requirements
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user