feat: unify signature channel reporting status
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
UPDATE "DrainageField"
|
||||
SET "fieldType" = 'string'
|
||||
WHERE "fieldType" NOT IN ('string', 'image', 'file');
|
||||
|
||||
UPDATE "ChannelReportField"
|
||||
SET "fieldType" = CASE
|
||||
WHEN "fieldType" IN ('string', 'image', 'file') THEN "fieldType"
|
||||
ELSE 'string'
|
||||
END;
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CreateReportFieldDto,
|
||||
CreateReportMaterialDto,
|
||||
CreateReportTaskDto,
|
||||
ChangeReportTaskStatusesDto,
|
||||
CreateRouteRuleDto,
|
||||
TestChannelDto,
|
||||
UpsertConnectionStateDto,
|
||||
@@ -145,8 +146,8 @@ export class ChannelsController {
|
||||
}
|
||||
|
||||
@Get('report-tasks')
|
||||
listReportTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string) {
|
||||
return this.channels.listReportTasks(tenantId, status);
|
||||
listReportTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('channelId') channelId?: string) {
|
||||
return this.channels.listReportTasks(tenantId, status, channelId);
|
||||
}
|
||||
|
||||
@Post('report-tasks/generate')
|
||||
@@ -154,6 +155,11 @@ export class ChannelsController {
|
||||
return this.channels.createReportTask(body);
|
||||
}
|
||||
|
||||
@Post('report-tasks/status-change')
|
||||
changeReportTaskStatuses(@Body() body: ChangeReportTaskStatusesDto) {
|
||||
return this.channels.changeReportTaskStatuses(body);
|
||||
}
|
||||
|
||||
@Post('report-tasks/:id/export')
|
||||
createReportExport(@Param('id') taskId: string, @Body() body: CreateReportExportDto) {
|
||||
return this.channels.createReportExport(taskId, body);
|
||||
|
||||
@@ -86,7 +86,7 @@ function createPrismaMock() {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-item-1', ...data })),
|
||||
},
|
||||
channelRouteRule: {
|
||||
findMany: jest.fn(),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'route-1', ...data })),
|
||||
},
|
||||
@@ -103,7 +103,7 @@ function createPrismaMock() {
|
||||
upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve({ id: 'material-1', ...create })),
|
||||
},
|
||||
channelSignatureReportTask: {
|
||||
findMany: jest.fn(),
|
||||
findMany: jest.fn().mockResolvedValue([{ ...reportTask, status: 'partial', channel }]),
|
||||
create: jest.fn().mockResolvedValue(reportTask),
|
||||
findUnique: jest.fn().mockResolvedValue(reportTask),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...reportTask, ...data })),
|
||||
@@ -119,6 +119,7 @@ function createPrismaMock() {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'import-1', ...data })),
|
||||
},
|
||||
smsSignature: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: null }),
|
||||
update: jest.fn(),
|
||||
},
|
||||
smsApplication: {
|
||||
@@ -174,6 +175,47 @@ describe('ChannelsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('lists report tasks for one real channel', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.channelSignatureReportTask.findMany.mockResolvedValue([]);
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await service.listReportTasks(undefined, undefined, 'channel-1');
|
||||
|
||||
expect(prisma.channelSignatureReportTask.findMany).toHaveBeenCalledWith({
|
||||
where: { tenantId: undefined, status: undefined, channelId: 'channel-1' },
|
||||
include: { signature: true, channel: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
});
|
||||
|
||||
it('changes channel report status and recomputes the signature summary atomically', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const tx = {
|
||||
smsSignature: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
|
||||
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
|
||||
},
|
||||
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', status: 'active' }) },
|
||||
channelSignatureReportTask: {
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'reporting' }),
|
||||
update: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }),
|
||||
create: jest.fn(),
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'task-1', channelId: 'channel-1', status: 'approved', channel: { id: 'channel-1', carrier: 'mobile', status: 'active' } }]),
|
||||
},
|
||||
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'record-1' }) },
|
||||
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: [{ channel: { id: 'channel-1', carrier: 'mobile', status: 'active' } }] } }]) },
|
||||
};
|
||||
prisma.$transaction.mockImplementation((callback) => callback(tx));
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await expect(service.changeReportTaskStatuses({ items: [{ signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }], reason: '运营商确认' })).resolves.toEqual([
|
||||
expect.objectContaining({ signatureId: 'sig-1', reportStatus: 'approved' }),
|
||||
]);
|
||||
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved' }) });
|
||||
expect(tx.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'approved' } });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockQueueAdd.mockClear();
|
||||
mockQueueClose.mockClear();
|
||||
@@ -548,7 +590,7 @@ describe('ChannelsService', () => {
|
||||
});
|
||||
expect(prisma.smsSignature.update).toHaveBeenCalledWith({
|
||||
where: { id: 'sig-1' },
|
||||
data: { reportStatus: 'partial' },
|
||||
data: { reportStatus: 'reporting' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -579,7 +621,7 @@ describe('ChannelsService', () => {
|
||||
});
|
||||
expect(prisma.smsSignature.update).toHaveBeenCalledWith({
|
||||
where: { id: 'sig-1' },
|
||||
data: { reportStatus: 'partial' },
|
||||
data: { reportStatus: 'reporting' },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -101,6 +101,12 @@ export interface CreateReportTaskDto {
|
||||
createdById?: string;
|
||||
}
|
||||
|
||||
export interface ChangeReportTaskStatusesDto {
|
||||
items: Array<{ signatureId: string; channelId: string; status: string }>;
|
||||
reason?: string;
|
||||
operatorId?: string;
|
||||
}
|
||||
|
||||
export interface CreateReportExportDto {
|
||||
fileObjectId?: string;
|
||||
fileName: string;
|
||||
@@ -958,9 +964,9 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
listReportTasks(tenantId?: string, status?: string) {
|
||||
listReportTasks(tenantId?: string, status?: string, channelId?: string) {
|
||||
return this.prisma.channelSignatureReportTask.findMany({
|
||||
where: { tenantId, status },
|
||||
where: { tenantId, status, channelId },
|
||||
include: { signature: true, channel: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
@@ -980,6 +986,53 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
return task;
|
||||
}
|
||||
|
||||
async changeReportTaskStatuses(data: ChangeReportTaskStatusesDto) {
|
||||
if (!data.items.length) throw new BadRequestException('items is required');
|
||||
const allowed = new Set(['pending', 'waiting_material', 'reporting', 'approved', 'failed', 'rejected', 'abandoned']);
|
||||
for (const item of data.items) {
|
||||
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))];
|
||||
for (const item of data.items) {
|
||||
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 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.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 } });
|
||||
}
|
||||
const summaries = [];
|
||||
for (const signatureId of signatureIds) summaries.push(await this.recomputeSignatureReportSummary(tx, signatureId));
|
||||
return summaries;
|
||||
});
|
||||
}
|
||||
|
||||
private async recomputeSignatureReportSummary(tx: Prisma.TransactionClient, signatureId: string) {
|
||||
const signature = await tx.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!signature) throw new NotFoundException('Signature not found');
|
||||
const routes = signature.applicationId ? await tx.channelRouteRule.findMany({
|
||||
where: { applicationId: signature.applicationId, status: 'active' },
|
||||
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 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]));
|
||||
const carrierReportSummary = Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
|
||||
const targets = uniqueChannels.filter((channel) => channel.carrier === carrier || channel.carrier === 'all');
|
||||
const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
|
||||
return [carrier, summarizeReportStatuses(statuses)];
|
||||
}));
|
||||
const allStatuses = uniqueChannels.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
|
||||
const reportStatus = summarizeReportStatuses(allStatuses).status;
|
||||
await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } });
|
||||
return { signatureId, reportStatus, carrierReportSummary };
|
||||
}
|
||||
|
||||
async createReportExport(taskId: string, data: CreateReportExportDto) {
|
||||
const task = await this.getReportTaskOrThrow(taskId);
|
||||
const exported = await this.prisma.reportExportFile.create({
|
||||
@@ -1014,10 +1067,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
},
|
||||
});
|
||||
await this.updateReportTaskStatus(taskId, task.channelId, task.status, statusAfter, 'receipt_import', data.reason);
|
||||
await this.prisma.smsSignature.update({
|
||||
where: { id: task.signatureId },
|
||||
data: { reportStatus: statusAfter },
|
||||
});
|
||||
await this.recomputeSignatureReportSummary(this.prisma as unknown as Prisma.TransactionClient, task.signatureId);
|
||||
return imported;
|
||||
}
|
||||
|
||||
@@ -1644,6 +1694,17 @@ function normalizeReportType(value?: string) {
|
||||
throw new BadRequestException('reportType must be signature, drainage or both');
|
||||
}
|
||||
|
||||
function summarizeReportStatuses(statuses: string[]) {
|
||||
if (!statuses.length) return { status: 'not_applicable', approved: 0, total: 0 };
|
||||
const approved = statuses.filter((status) => status === 'approved').length;
|
||||
let status = 'pending';
|
||||
if (approved === statuses.length) status = 'approved';
|
||||
else if (statuses.some((item) => ['failed', 'rejected'].includes(item))) status = 'failed';
|
||||
else if (statuses.some((item) => ['reporting', 'exporting', 'partial', 'partial_success'].includes(item)) || approved > 0) status = 'reporting';
|
||||
else if (statuses.some((item) => item === 'waiting_material')) status = 'waiting_material';
|
||||
return { status, approved, total: statuses.length };
|
||||
}
|
||||
|
||||
function normalizeLinkEvent(action: string) {
|
||||
if (action.includes('connect_requested')) {
|
||||
return '连接请求';
|
||||
|
||||
@@ -25,6 +25,9 @@ function createPrismaMock() {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'enterprise-1', ...data })),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'enterprise-1', ...data })),
|
||||
},
|
||||
drainageField: {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
|
||||
},
|
||||
smsApplication: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
|
||||
},
|
||||
@@ -112,4 +115,17 @@ describe('DictionariesService', () => {
|
||||
});
|
||||
expect(prisma.enterpriseBlacklist.update).toHaveBeenCalledWith({ where: { id: 'enterprise-1' }, data: { status: 'deleted' } });
|
||||
});
|
||||
|
||||
it('only accepts string, image and file report field types', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new DictionariesService(prisma as never);
|
||||
|
||||
await expect(service.createDrainageField({ code: 'license', name: '营业执照', fieldType: 'image' })).resolves.toEqual(
|
||||
expect.objectContaining({ fieldType: 'image' }),
|
||||
);
|
||||
expect(() => service.createDrainageField({ code: 'amount', name: '数量', fieldType: 'number' as never })).toThrow(
|
||||
'fieldType must be string, image or file',
|
||||
);
|
||||
expect(prisma.drainageField.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface CreateBlacklistDto {
|
||||
export interface CreateDrainageFieldDto {
|
||||
code: string;
|
||||
name: string;
|
||||
fieldType: string;
|
||||
fieldType: 'string' | 'image' | 'file';
|
||||
required?: boolean;
|
||||
status?: string;
|
||||
description?: string;
|
||||
@@ -255,6 +255,9 @@ export class DictionariesService {
|
||||
}
|
||||
|
||||
createDrainageField(data: CreateDrainageFieldDto) {
|
||||
if (!['string', 'image', 'file'].includes(data.fieldType)) {
|
||||
throw new BadRequestException('fieldType must be string, image or file');
|
||||
}
|
||||
return this.prisma.drainageField.create({
|
||||
data: {
|
||||
code: data.code,
|
||||
|
||||
@@ -430,7 +430,7 @@ describe('SmsConfigService', () => {
|
||||
auditStatus: { not: 'deleted' },
|
||||
OR: expect.any(Array),
|
||||
}),
|
||||
include: { materials: true, tenant: true, application: true },
|
||||
include: { materials: true, tenant: true, application: true, reportTasks: { include: { channel: true } } },
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -570,9 +570,9 @@ export class SmsConfigService {
|
||||
});
|
||||
}
|
||||
|
||||
listSignatures(queryOrTenantId?: string | { tenantId?: string; keyword?: string; status?: string }) {
|
||||
async listSignatures(queryOrTenantId?: string | { tenantId?: string; keyword?: string; status?: string }) {
|
||||
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
|
||||
return this.prisma.smsSignature.findMany({
|
||||
const signatures = await this.prisma.smsSignature.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||
@@ -583,9 +583,31 @@ export class SmsConfigService {
|
||||
{ application: { name: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
},
|
||||
include: { materials: true, tenant: true, application: true },
|
||||
include: { materials: true, tenant: true, application: true, reportTasks: { include: { channel: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
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 } } } } },
|
||||
}) : [];
|
||||
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]));
|
||||
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 }));
|
||||
})(),
|
||||
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 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';
|
||||
return [carrier, { status, approved, total: targets.length }];
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
async createSignature(data: CreateSmsSignatureDto) {
|
||||
|
||||
Reference in New Issue
Block a user