feat: complete signature and drainage reporting workflows

This commit is contained in:
hectorzhao
2026-07-13 17:40:37 +08:00
parent 567e4da0c9
commit 551b99cbcd
21 changed files with 452 additions and 71 deletions
@@ -54,7 +54,7 @@ export class AdminSmsConfigController {
@Post('enterprise-signatures')
createSignature(@Body() body: CreateSmsSignatureDto) {
return this.smsConfig.createSignature(body);
return this.smsConfig.createSignature(body, { initialAuditStatus: 'approved' });
}
@Put('enterprise-signatures/:id')
@@ -66,6 +66,7 @@ function createPrismaMock() {
materials: [],
}]),
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', auditStatus: 'pending' }),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sig-new', tenantId: 'tenant-1', ...data })),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', ...data })),
},
signatureReportMaterial: {
@@ -75,6 +76,14 @@ function createPrismaMock() {
upsert: jest.fn().mockResolvedValue({ id: 'drainage-report-value-1' }),
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
},
channelSignatureReportTask: {
findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-task-1', ...data })),
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
},
channelSignatureReportRecord: {
create: jest.fn().mockResolvedValue({ id: 'drainage-record-1' }),
},
smsTemplate: {
findMany: jest.fn().mockResolvedValue([{
id: 'tpl-1',
@@ -502,6 +511,24 @@ describe('SmsConfigService', () => {
expect(prisma.drainageReportMaterial.deleteMany).toHaveBeenCalledWith({
where: { signatureId: 'sig-1', drainageItemId: { notIn: ['drain-1'] } },
});
expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledWith({
data: expect.objectContaining({ signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'pending' }),
});
expect(prisma.channelSignatureReportRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({ taskId: 'drainage-task-1', action: 'create', statusAfter: 'pending' }),
});
});
it('creates admin signatures with an approved initial audit status', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await service.createSignature({ tenantId: 'tenant-1', name: '运营新建签名' }, { initialAuditStatus: 'approved' });
expect(prisma.smsSignature.create).toHaveBeenCalledWith({
data: expect.objectContaining({ auditStatus: 'approved', name: '运营新建签名' }),
});
expect(prisma.auditRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'admin_create_approved', statusAfter: 'approved' }) });
});
it('updates enterprise signature drainage info through the admin API path', async () => {
+70 -5
View File
@@ -44,6 +44,10 @@ export interface CreateSmsSignatureDto {
drainageInfo?: Record<string, unknown>;
}
export interface CreateSmsSignatureOptions {
initialAuditStatus?: string;
}
export type UpdateSmsSignatureDto = Partial<Omit<CreateSmsSignatureDto, 'tenantId'>> & {
auditStatus?: string;
};
@@ -589,19 +593,44 @@ export class SmsConfigService {
const applicationIds = signatures.map((signature) => signature.applicationId).filter((id): id is string => Boolean(id));
const routes = applicationIds.length ? await this.prisma.channelRouteRule.findMany({
where: { applicationId: { in: applicationIds }, status: 'active' },
include: { group: { include: { items: { include: { channel: true } } } } },
include: { group: { include: { items: { include: { channel: { include: { reportFields: true } } } } } } },
}) : [];
return signatures.map((signature) => ({
...signature,
reportTargets: (() => {
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');
const taskByChannel = new Map((signature.reportTasks ?? []).map((task) => [task.channelId, task]));
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task]));
return [...new Map(channels.map((channel) => [channel.id, channel])).values()].map((channel) => ({ channel, channelId: channel.id, status: taskByChannel.get(channel.id)?.status ?? 'pending', taskId: taskByChannel.get(channel.id)?.id }));
})(),
drainageReportTargets: Object.fromEntries((Array.isArray((signature.drainageInfo as Record<string, unknown> | null)?.links) ? (signature.drainageInfo as Record<string, unknown>).links as Array<Record<string, unknown>> : []).map((link) => {
const drainageItemId = String(link.id ?? '');
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)));
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()].map((channel) => ({ channel, channelId: channel.id, status: taskByChannel.get(channel.id)?.status ?? 'pending', taskId: taskByChannel.get(channel.id)?.id }))];
})),
drainageCarrierReportSummary: Object.fromEntries((Array.isArray((signature.drainageInfo as Record<string, unknown> | null)?.links) ? (signature.drainageInfo as Record<string, unknown>).links as Array<Record<string, unknown>> : []).map((link) => {
const drainageItemId = String(link.id ?? '');
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)));
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) => {
const carrierTargets = targets.filter((channel) => channel.carrier === carrier || channel.carrier === 'all');
const statuses = carrierTargets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
const approved = statuses.filter((status) => status === 'approved').length;
const status = !statuses.length ? 'not_applicable' : approved === statuses.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending';
return [carrier, { status, approved, total: statuses.length }];
}))];
})),
carrierReportSummary: Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
const configured = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted' && (channel.carrier === carrier || channel.carrier === 'all'));
const targets = [...new Map(configured.map((channel) => [channel.id, channel])).values()];
const taskByChannel = new Map((signature.reportTasks ?? []).map((task) => [task.channelId, task]));
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task]));
const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
const approved = statuses.filter((status) => status === 'approved').length;
const status = !targets.length ? 'not_applicable' : approved === targets.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending';
@@ -610,7 +639,7 @@ export class SmsConfigService {
}));
}
async createSignature(data: CreateSmsSignatureDto) {
async createSignature(data: CreateSmsSignatureDto, options: CreateSmsSignatureOptions = {}) {
await this.validateSignatureReportValues(data.applicationId, data.drainageInfo);
const drainageInfo = await this.withReportRequirementSnapshot(data.applicationId, data.drainageInfo);
const signature = await this.prisma.smsSignature.create({
@@ -619,10 +648,21 @@ export class SmsConfigService {
applicationId: data.applicationId,
name: data.name,
purpose: data.purpose,
auditStatus: options.initialAuditStatus,
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
},
});
await this.syncSignatureReportValues(signature.id, data.applicationId, drainageInfo);
if (options.initialAuditStatus) {
await this.createAuditRecord({
tenantId: signature.tenantId,
targetType: 'sms_signature',
targetId: signature.id,
action: 'admin_create_approved',
statusAfter: options.initialAuditStatus,
reason: '运营端新建签名自动审核通过',
});
}
return signature;
}
@@ -684,6 +724,13 @@ export class SmsConfigService {
...(drainageItemIds.length > 0 ? { drainageItemId: { notIn: drainageItemIds } } : {}),
},
});
await this.prisma.channelSignatureReportTask.deleteMany({
where: {
signatureId,
reportType: 'drainage',
...(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) {
@@ -694,11 +741,29 @@ export class SmsConfigService {
});
}
}
const drainageFields = fields.filter((item) => item.reportTypes.some((type) => type === 'drainage' || type === 'both'));
const drainageChannels = new Map(drainageFields.flatMap((item) => item.channels).map((channel) => [channel.id, channel]));
const signatureOwner = links.length > 0
? await this.prisma.smsSignature.findUnique({ where: { id: signatureId }, select: { tenantId: true } })
: null;
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'))) {
for (const channel of drainageChannels.values()) {
const existingTask = await this.prisma.channelSignatureReportTask.findFirst({
where: { signatureId, channelId: channel.id, reportType: 'drainage', drainageItemId },
});
if (!existingTask && signatureOwner) {
const task = await this.prisma.channelSignatureReportTask.create({
data: { tenantId: signatureOwner.tenantId, signatureId, channelId: channel.id, reportType: 'drainage', drainageItemId, status: 'pending' },
});
await this.prisma.channelSignatureReportRecord.create({
data: { taskId: task.id, channelId: channel.id, action: 'create', statusAfter: 'pending' },
});
}
}
for (const field of drainageFields) {
const value = reportValueParts(values[field.code]);
for (const channel of field.channels) {
await this.prisma.drainageReportMaterial.upsert({