feat: add channel report batch briefs

This commit is contained in:
hectorzhao
2026-09-02 18:29:05 +08:00
parent 2dff1be750
commit 7cb5dd376e
27 changed files with 1083 additions and 153 deletions
@@ -72,8 +72,8 @@ export class AdminSmsConfigController {
}
@Get('enterprise-signatures')
listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('signatureKeyword') signatureKeyword?: string, @Query('drainageKeyword') drainageKeyword?: string, @Query('signatureSort') signatureSort?: 'asc' | 'desc', @Query('submittedAtFrom') submittedAtFrom?: string, @Query('submittedAtTo') submittedAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
const query = { tenantId, keyword, status, enterpriseKeyword, applicationKeyword, signatureKeyword, drainageKeyword, signatureSort, submittedAtFrom, submittedAtTo, page: Number(page), pageSize: Number(pageSize) };
listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('signatureKeyword') signatureKeyword?: string, @Query('drainageKeyword') drainageKeyword?: string, @Query('submittedAtFrom') submittedAtFrom?: string, @Query('submittedAtTo') submittedAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
const query = { tenantId, keyword, status, enterpriseKeyword, applicationKeyword, signatureKeyword, drainageKeyword, submittedAtFrom, submittedAtTo, page: Number(page), pageSize: Number(pageSize) };
return page || pageSize ? this.smsConfig.listSignaturesPage(query) : this.smsConfig.listSignatures(query);
}
+89 -5
View File
@@ -74,8 +74,6 @@ export class SmsSignatureService {
) {}
async listSignatures(queryOrTenantId?: string | SignatureListQuery, summaryOnly = false) {
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : (queryOrTenantId ?? {});
const signatureSort =
query.signatureSort === 'asc' || query.signatureSort === 'desc' ? query.signatureSort : undefined;
const signatures = await this.prisma.smsSignature.findMany({
where: {
id: query.signatureId,
@@ -159,7 +157,7 @@ export class SmsSignatureService {
select: { reportType: true, materialVersion: true, snapshot: true },
},
},
orderBy: signatureSort ? [{ name: signatureSort }, { id: 'asc' }] : { createdAt: 'desc' },
orderBy: { createdAt: 'desc' },
...(query.page && query.pageSize
? {
skip: (query.page - 1) * query.pageSize,
@@ -442,11 +440,97 @@ export class SmsSignatureService {
]
: undefined,
};
const [items, total] = await Promise.all([
const [items, total, pendingReportDetailTotal] = await Promise.all([
this.listSignatures({ ...query, page, pageSize }, true),
this.prisma.smsSignature.count({ where }),
this.countPendingReportDetails(where),
]);
return { items, total, page, pageSize };
return { items, total, page, pageSize, pendingReportDetailTotal };
}
private async countPendingReportDetails(where: Prisma.SmsSignatureWhereInput) {
const signatures = await this.prisma.smsSignature.findMany({
where: { AND: [where, { auditStatus: 'approved', pendingReport: true }] },
select: {
id: true,
applicationId: true,
materialVersion: true,
application: { select: { status: true } },
reportTasks: {
where: { reportType: 'signature' },
select: { channelId: true, carrier: true, status: true, approvalScope: true },
},
reportBatchItems: {
where: { reportType: 'signature', batch: { status: { in: ['completed', 'partial_failed'] } } },
select: { materialVersion: true, snapshot: true },
},
},
});
const applicationIds = [
...new Set(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' },
select: {
applicationId: true,
group: {
select: {
status: true,
items: {
select: {
channel: { select: { id: true, carrier: true, carriers: true, status: true } },
},
},
},
},
},
})
: [];
let total = 0;
for (const signature of signatures) {
if (!signature.applicationId || signature.application?.status !== 'active') continue;
const generatedTargets = new Set<string>();
for (const item of signature.reportBatchItems.filter(
(entry) => entry.materialVersion === signature.materialVersion,
)) {
const businessKeys = isRecord(item.snapshot) ? item.snapshot.businessKeys : undefined;
if (!Array.isArray(businessKeys)) continue;
for (const value of businessKeys) {
const match = typeof value === 'string' ? value.match(/:channel:([^:]+):carrier:([^:]+)$/) : null;
if (!match) continue;
for (const carrier of match[2].split(',').map((entry) => entry.trim()).filter(Boolean))
generatedTargets.add(`${match[1]}:${carrier}`);
}
}
const channels = [
...new Map(
routes
.filter((route) => route.applicationId === signature.applicationId && route.group?.status === 'active')
.flatMap((route) => route.group!.items.map((item) => item.channel))
.filter((channel) => channel.status === 'active')
.map((channel) => [channel.id, channel]),
).values(),
];
for (const channel of channels) {
for (const carrier of normalizeChannelCarriers(channel.carriers, channel.carrier)) {
const task =
signature.reportTasks.find(
(candidate) => candidate.channelId === channel.id && candidate.carrier === carrier,
) ??
signature.reportTasks.find(
(candidate) =>
candidate.channelId === channel.id &&
candidate.carrier === null &&
candidate.approvalScope === 'legacy_channel',
);
if (task?.status === 'abandoned') continue;
if (generatedTargets.has(`${channel.id}:${carrier}`) || generatedTargets.has(`${channel.id}:legacy`)) continue;
total += 1;
}
}
}
return total;
}
async getSignature(id: string) {
@@ -144,7 +144,6 @@ export interface SignatureListQuery {
applicationKeyword?: string;
signatureKeyword?: string;
drainageKeyword?: string;
signatureSort?: 'asc' | 'desc';
submittedAtFrom?: string;
submittedAtTo?: string;
page?: number;
+53 -2
View File
@@ -781,7 +781,7 @@ describe('SmsConfigService', () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.listSignatures({ enterpriseKeyword: '租户', applicationKeyword: '应用', signatureKeyword: '签名', drainageKeyword: '官网', signatureSort: 'asc' })).resolves.toEqual([
await expect(service.listSignatures({ enterpriseKeyword: '租户', applicationKeyword: '应用', signatureKeyword: '签名', drainageKeyword: '官网' })).resolves.toEqual([
expect.objectContaining({
id: 'sig-1',
tenant: expect.objectContaining({ name: '租户A' }),
@@ -804,7 +804,7 @@ describe('SmsConfigService', () => {
reportTasks: expect.objectContaining({ select: expect.any(Object) }),
reportBatchItems: expect.any(Object),
}),
orderBy: [{ name: 'asc' }, { id: 'asc' }],
orderBy: { createdAt: 'desc' },
}));
});
@@ -1223,6 +1223,7 @@ describe('SmsConfigService', () => {
const result = await service.listSignaturesPage({ page: 1, pageSize: 10 });
expect(result.total).toBe(1);
expect(result.pendingReportDetailTotal).toBe(0);
expect(result.items[0]).toEqual(expect.objectContaining({ id: 'sig-1', name: '【签名A】' }));
expect(result.items[0]).not.toHaveProperty('materials');
expect(result.items[0]).not.toHaveProperty('reportTasks');
@@ -1230,6 +1231,56 @@ describe('SmsConfigService', () => {
expect(result.items[0]).not.toHaveProperty('drainageReportTargets');
});
it('returns the filtered total of signature channel-carrier details still awaiting batch generation', async () => {
const prisma = createPrismaMock();
prisma.smsSignature.findMany.mockImplementation(({ where }) => {
if (where?.AND) {
return Promise.resolve([
{
id: 'sig-pending',
applicationId: 'app-1',
materialVersion: 2,
application: { status: 'active' },
reportTasks: [
{ channelId: 'channel-1', carrier: 'mobile', status: 'abandoned', approvalScope: 'carrier_specific' },
],
reportBatchItems: [],
},
]);
}
return Promise.resolve([]);
});
prisma.channelRouteRule.findMany.mockResolvedValue([
{
applicationId: 'app-1',
group: {
status: 'active',
items: [
{
channel: {
id: 'channel-1',
status: 'active',
carrier: 'mobile',
carriers: ['mobile', 'unicom'],
reportFields: [],
},
},
],
},
},
] as never);
const service = new SmsConfigService(prisma as never);
const result = await service.listSignaturesPage({ signatureKeyword: '测试', page: 1, pageSize: 10 });
expect(result.pendingReportDetailTotal).toBe(1);
expect(prisma.smsSignature.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { AND: [expect.objectContaining({ name: { contains: '测试' } }), { auditStatus: 'approved', pendingReport: true }] },
}),
);
});
it.each([
'【带 空格】',
' 【外部空格】',