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
+27
View File
@@ -216,6 +216,33 @@ describe('ChannelsService', () => {
expect(tx.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'approved' } });
});
it('changes a drainage report task without overwriting the signature report summary', async () => {
const prisma = createPrismaMock();
const tx = {
smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
update: jest.fn(),
},
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', status: 'active' }) },
channelSignatureReportTask: {
findFirst: jest.fn().mockResolvedValue({ id: 'drainage-task-1', signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'reporting' }),
update: jest.fn().mockResolvedValue({ id: 'drainage-task-1', status: 'approved' }),
create: jest.fn(),
},
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'record-1' }) },
};
prisma.$transaction.mockImplementation((callback) => callback(tx));
const service = new ChannelsService(prisma as never);
await expect(service.changeReportTaskStatuses({
items: [{ signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'approved' }],
reason: '引流信息已报备',
})).resolves.toEqual([{ signatureId: 'sig-1', reportType: 'drainage', drainageItemId: 'drain-1', channelId: 'channel-1', status: 'approved' }]);
expect(tx.channelSignatureReportTask.findFirst).toHaveBeenCalledWith({ where: { signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1' } });
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved' }) });
expect(tx.smsSignature.update).not.toHaveBeenCalled();
});
beforeEach(() => {
mockQueueAdd.mockClear();
mockQueueClose.mockClear();
+19 -7
View File
@@ -98,11 +98,13 @@ export interface CreateReportTaskDto {
tenantId: string;
signatureId: string;
channelId: string;
reportType?: 'signature' | 'drainage';
drainageItemId?: string;
createdById?: string;
}
export interface ChangeReportTaskStatusesDto {
items: Array<{ signatureId: string; channelId: string; status: string }>;
items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>;
reason?: string;
operatorId?: string;
}
@@ -973,11 +975,15 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
}
async createReportTask(data: CreateReportTaskDto) {
const reportType = data.reportType ?? 'signature';
if (reportType === 'drainage' && !data.drainageItemId) throw new BadRequestException('drainageItemId is required');
const task = await this.prisma.channelSignatureReportTask.create({
data: {
tenantId: data.tenantId,
signatureId: data.signatureId,
channelId: data.channelId,
reportType,
drainageItemId: reportType === 'drainage' ? data.drainageItemId : undefined,
createdById: data.createdById,
status: 'pending',
},
@@ -993,20 +999,24 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
if (!allowed.has(item.status)) throw new BadRequestException('unsupported report task status');
}
return this.prisma.$transaction(async (tx) => {
const signatureIds = [...new Set(data.items.map((item) => item.signatureId))];
const signatureIds = [...new Set(data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId))];
const drainageResults: Array<{ signatureId: string; reportType: 'drainage'; drainageItemId: string; channelId: string; status: string }> = [];
for (const item of data.items) {
const reportType = item.reportType ?? 'signature';
if (reportType === 'drainage' && !item.drainageItemId) throw new BadRequestException('drainageItemId is required');
const signature = await tx.smsSignature.findUnique({ where: { id: item.signatureId } });
const channel = await tx.smsChannel.findUnique({ where: { id: item.channelId } });
if (!signature || !channel) throw new NotFoundException('Signature or channel not found');
const existing = await tx.channelSignatureReportTask.findFirst({ where: { signatureId: item.signatureId, channelId: item.channelId } });
const existing = await tx.channelSignatureReportTask.findFirst({ where: { signatureId: item.signatureId, channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : null } });
const task = existing
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: item.status, reason: data.reason } })
: await tx.channelSignatureReportTask.create({ data: { tenantId: signature.tenantId, signatureId: item.signatureId, channelId: item.channelId, status: item.status, reason: data.reason, createdById: data.operatorId } });
: await tx.channelSignatureReportTask.create({ data: { tenantId: signature.tenantId, signatureId: item.signatureId, channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : undefined, status: item.status, reason: data.reason, createdById: data.operatorId } });
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: item.channelId, action: 'manual_status_change', statusBefore: existing?.status, statusAfter: item.status, reason: data.reason, operatorId: data.operatorId } });
if (reportType === 'drainage') drainageResults.push({ signatureId: item.signatureId, reportType, drainageItemId: item.drainageItemId!, channelId: item.channelId, status: item.status });
}
const summaries = [];
for (const signatureId of signatureIds) summaries.push(await this.recomputeSignatureReportSummary(tx, signatureId));
return summaries;
return [...summaries, ...drainageResults];
});
}
@@ -1018,7 +1028,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
include: { group: { include: { items: { include: { channel: true } } } } },
}) : [];
const configuredChannels = routes.flatMap((route) => route.group.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted');
const tasks = await tx.channelSignatureReportTask.findMany({ where: { signatureId }, include: { channel: true } });
const tasks = await tx.channelSignatureReportTask.findMany({ where: { signatureId, reportType: 'signature' }, include: { channel: true } });
const channels = configuredChannels.length ? configuredChannels : tasks.map((task) => task.channel);
const uniqueChannels = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
const taskByChannel = new Map(tasks.map((task) => [task.channelId, task]));
@@ -1067,7 +1077,9 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
},
});
await this.updateReportTaskStatus(taskId, task.channelId, task.status, statusAfter, 'receipt_import', data.reason);
await this.recomputeSignatureReportSummary(this.prisma as unknown as Prisma.TransactionClient, task.signatureId);
if ((task.reportType ?? 'signature') === 'signature') {
await this.recomputeSignatureReportSummary(this.prisma as unknown as Prisma.TransactionClient, task.signatureId);
}
return imported;
}
@@ -739,6 +739,7 @@ describe('SendChainService', () => {
service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd });
await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual(expect.objectContaining({ submitted: true, channelId: backup.id }));
expect(prisma.channelSignatureReportTask.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ reportType: 'signature' }) }));
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ channelId: backup.id }) }));
});
+2 -2
View File
@@ -1876,7 +1876,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const signatureId = await this.resolveMessageSignatureId(message);
if (!signatureId) throw new BadRequestException('短信签名未配置,无法选择已报备通道');
const approvedTasks = await this.prisma.channelSignatureReportTask.findMany({
where: { signatureId, status: 'approved', channelId: { in: route.group.items.map((item) => item.channelId) } },
where: { signatureId, reportType: 'signature', status: 'approved', channelId: { in: route.group.items.map((item) => item.channelId) } },
select: { channelId: true },
});
const approvedChannelIds = new Set(approvedTasks.map((task) => task.channelId));
@@ -2247,7 +2247,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
throw new BadRequestException('短信签名未配置,不能提交到通道');
}
const reportTask = await this.prisma.channelSignatureReportTask.findFirst({
where: { signatureId, channelId, status: 'approved' },
where: { signatureId, channelId, reportType: 'signature', status: 'approved' },
select: { id: true },
});
if (!reportTask) {
@@ -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({