feat: add reconciliation and quality reporting

This commit is contained in:
hectorzhao
2026-07-15 14:22:50 +08:00
parent 16311546af
commit 8c3336600e
32 changed files with 1730 additions and 200 deletions
+2
View File
@@ -13,6 +13,7 @@ import { HealthController } from './health.controller';
import { OperationsModule } from './operations/operations.module';
import { PrismaModule } from './prisma/prisma.module';
import { RiskReviewModule } from './risk-review/risk-review.module';
import { ReportsModule } from './reports/reports.module';
import { SendChainModule } from './send-chain/send-chain.module';
import { SmsConfigModule } from './sms-config/sms-config.module';
import { TenantsModule } from './tenants/tenants.module';
@@ -36,6 +37,7 @@ import { UsersModule } from './users/users.module';
SmsConfigModule,
ChannelsModule,
RiskReviewModule,
ReportsModule,
SendChainModule,
OperationsModule,
],
+2
View File
@@ -484,6 +484,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
sessionId: session.id,
submitId,
submitStatus: 'queued',
costUnitPrice: channel.unitPrice,
costAmountCents: channel.unitPrice * messageRecord.billingUnits,
},
});
const command = buildChannelTestSubmitCommand({
@@ -1,6 +1,7 @@
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import {
CreateCommonReportFieldDto,
CreateBlacklistDto,
CreateDrainageFieldDto,
CreatePhoneCarrierRuleDto,
@@ -118,4 +119,19 @@ export class DictionariesController {
deleteDrainageField(@Param('id') id: string) {
return this.dictionaries.deleteDrainageField(id);
}
@Get('common-report-fields')
listCommonReportFields() {
return this.dictionaries.listCommonReportFields();
}
@Post('common-report-fields')
createCommonReportField(@Body() body: CreateCommonReportFieldDto) {
return this.dictionaries.createCommonReportField(body);
}
@Delete('common-report-fields/:id')
deleteCommonReportField(@Param('id') id: string) {
return this.dictionaries.deleteCommonReportField(id);
}
}
@@ -28,12 +28,20 @@ function createPrismaMock() {
},
drainageField: {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
delete: jest.fn().mockResolvedValue({ id: 'field-1' }),
},
channelReportField: {
count: jest.fn().mockResolvedValue(0),
},
commonReportField: {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn().mockResolvedValue(null),
count: jest.fn().mockResolvedValue(0),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'common-1', ...data })),
delete: jest.fn().mockResolvedValue({ id: 'common-1' }),
},
smsApplication: {
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
},
@@ -55,14 +63,38 @@ describe('DictionariesService', () => {
it('returns drainage field usage counts and blocks deleting fields used by channels', async () => {
const prisma = createPrismaMock();
prisma.drainageField.findMany.mockResolvedValue([{ id: 'field-1', code: 'license', _count: { channelReportFields: 2 } }]);
prisma.drainageField.findMany.mockResolvedValue([{ id: 'field-1', code: 'license', _count: { channelReportFields: 2, commonReportFields: 0 } }]);
prisma.channelReportField.count.mockResolvedValue(2);
const service = new DictionariesService(prisma as never);
await expect(service.listDrainageFields()).resolves.toEqual([{ id: 'field-1', code: 'license', usageCount: 2 }]);
await expect(service.listDrainageFields()).resolves.toEqual([{ id: 'field-1', code: 'license', usageCount: 2, commonUsageCount: 0 }]);
await expect(service.deleteDrainageField('field-1')).rejects.toThrow('不能删除');
expect(prisma.drainageField.delete).not.toHaveBeenCalled();
});
it('creates and deletes real common signature and drainage field configurations', async () => {
const prisma = createPrismaMock();
prisma.drainageField.findUnique = jest.fn().mockResolvedValue({ id: 'field-1', code: 'license', required: false, status: 'active' });
const service = new DictionariesService(prisma as never);
await service.createCommonReportField({ drainageFieldId: 'field-1', reportType: 'signature', required: true });
await service.deleteCommonReportField('common-1');
expect(prisma.commonReportField.create).toHaveBeenCalledWith({
data: expect.objectContaining({ drainageFieldId: 'field-1', reportType: 'signature', required: true, status: 'active' }),
include: { drainageField: true },
});
expect(prisma.commonReportField.delete).toHaveBeenCalledWith({ where: { id: 'common-1' } });
});
it('blocks deleting a field referenced by a common configuration', async () => {
const prisma = createPrismaMock();
prisma.commonReportField.count.mockResolvedValue(1);
const service = new DictionariesService(prisma as never);
await expect(service.deleteDrainageField('field-1')).rejects.toThrow('通用配置');
expect(prisma.drainageField.delete).not.toHaveBeenCalled();
});
it('paginates phone segments with a real database count', async () => {
const prisma = createPrismaMock();
prisma.phoneSegment.findMany.mockResolvedValue([
+56 -5
View File
@@ -53,6 +53,13 @@ export interface CreateDrainageFieldDto {
description?: string;
}
export interface CreateCommonReportFieldDto {
drainageFieldId: string;
reportType: 'signature' | 'drainage';
required?: boolean;
sortOrder?: number;
}
export interface DictionaryStatusDto {
status?: string;
operatorId?: string;
@@ -264,10 +271,14 @@ export class DictionariesService {
async listDrainageFields() {
const fields = await this.prisma.drainageField.findMany({
include: { _count: { select: { channelReportFields: true } } },
include: { _count: { select: { channelReportFields: true, commonReportFields: true } } },
orderBy: { createdAt: 'desc' },
});
return fields.map(({ _count, ...field }) => ({ ...field, usageCount: _count.channelReportFields }));
return fields.map(({ _count, ...field }) => ({
...field,
usageCount: _count.channelReportFields,
commonUsageCount: _count.commonReportFields,
}));
}
createDrainageField(data: CreateDrainageFieldDto) {
@@ -291,13 +302,53 @@ export class DictionariesService {
}
async deleteDrainageField(id: string) {
const usageCount = await this.prisma.channelReportField.count({ where: { drainageFieldId: id } });
if (usageCount > 0) {
throw new BadRequestException(`该字段已被 ${usageCount} 个通道使用,不能删除`);
const [usageCount, commonUsageCount] = await Promise.all([
this.prisma.channelReportField.count({ where: { drainageFieldId: id } }),
this.prisma.commonReportField.count({ where: { drainageFieldId: id } }),
]);
if (usageCount > 0 || commonUsageCount > 0) {
throw new BadRequestException(`该字段已被 ${usageCount} 个通道和 ${commonUsageCount} 个通用配置使用,不能删除`);
}
return this.prisma.drainageField.delete({ where: { id } });
}
listCommonReportFields() {
return this.prisma.commonReportField.findMany({
include: { drainageField: true },
orderBy: [{ reportType: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
});
}
async createCommonReportField(data: CreateCommonReportFieldDto) {
if (data.reportType !== 'signature' && data.reportType !== 'drainage') {
throw new BadRequestException('reportType must be signature or drainage');
}
const field = await this.prisma.drainageField.findUnique({ where: { id: data.drainageFieldId } });
if (!field || field.status !== 'active') {
throw new BadRequestException('报备字段库字段不存在或已停用');
}
const existing = await this.prisma.commonReportField.findUnique({
where: { drainageFieldId_reportType: { drainageFieldId: field.id, reportType: data.reportType } },
});
if (existing) {
throw new BadRequestException('该字段已配置为对应类型的通用字段');
}
return this.prisma.commonReportField.create({
data: {
drainageFieldId: field.id,
reportType: data.reportType,
required: data.required ?? field.required,
sortOrder: data.sortOrder ?? 100,
status: 'active',
},
include: { drainageField: true },
});
}
deleteCommonReportField(id: string) {
return this.prisma.commonReportField.delete({ where: { id } });
}
private writeOperationLog(userId: string | undefined, action: string, resource: string, resourceId: string, detail: Record<string, unknown>) {
return this.prisma.operationLog.create({
data: {
+49
View File
@@ -0,0 +1,49 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ReportsService } from './reports.service';
@ApiTags('reports')
@Controller('admin/reports')
export class ReportsController {
constructor(private readonly reports: ReportsService) {}
@Get('reconciliation')
reconciliation(
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
@Query('tenantId') tenantId?: string,
@Query('applicationId') applicationId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.reports.listReconciliation({ dateFrom, dateTo, tenantId, applicationId, page: Number(page), pageSize: Number(pageSize) });
}
@Get('profit')
profit(
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
@Query('dimensionType') dimensionType?: string,
@Query('tenantId') tenantId?: string,
@Query('applicationId') applicationId?: string,
@Query('channelId') channelId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.reports.listProfit({ dateFrom, dateTo, dimensionType, tenantId, applicationId, channelId, page: Number(page), pageSize: Number(pageSize) });
}
@Get('quality')
quality(
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
@Query('dimensionType') dimensionType?: string,
@Query('tenantId') tenantId?: string,
@Query('applicationId') applicationId?: string,
@Query('channelId') channelId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.reports.listQuality({ dateFrom, dateTo, dimensionType, tenantId, applicationId, channelId, page: Number(page), pageSize: Number(pageSize) });
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { ReportsController } from './reports.controller';
import { ReportsService } from './reports.service';
@Module({
imports: [PrismaModule],
controllers: [ReportsController],
providers: [ReportsService],
})
export class ReportsModule {}
+81
View File
@@ -0,0 +1,81 @@
import { ReportsService } from './reports.service';
describe('ReportsService', () => {
const tx = {
dailyReconciliationReport: { deleteMany: jest.fn() },
dailyProfitReport: { deleteMany: jest.fn() },
dailyQualityReport: { deleteMany: jest.fn() },
$executeRaw: jest.fn(),
};
const prisma = {
dailyReconciliationReport: { findMany: jest.fn(), count: jest.fn() },
dailyProfitReport: { findMany: jest.fn(), count: jest.fn() },
dailyQualityReport: { findMany: jest.fn(), count: jest.fn() },
$transaction: jest.fn((callback: (client: typeof tx) => unknown) => callback(tx)),
};
let service: ReportsService;
beforeEach(() => {
jest.clearAllMocks();
tx.dailyReconciliationReport.deleteMany.mockResolvedValue({ count: 0 });
tx.dailyProfitReport.deleteMany.mockResolvedValue({ count: 0 });
tx.dailyQualityReport.deleteMany.mockResolvedValue({ count: 0 });
tx.$executeRaw.mockResolvedValue(0);
prisma.dailyReconciliationReport.findMany.mockResolvedValue([{ id: 'recon-1' }]);
prisma.dailyReconciliationReport.count.mockResolvedValue(1);
prisma.dailyProfitReport.findMany.mockResolvedValue([{ id: 'profit-1' }]);
prisma.dailyProfitReport.count.mockResolvedValue(1);
prisma.dailyQualityReport.findMany.mockResolvedValue([{ id: 'quality-1' }]);
prisma.dailyQualityReport.count.mockResolvedValue(1);
service = new ReportsService(prisma as never);
});
it('rebuilds exactly T-4 through T-1 in independent transactions', async () => {
await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).resolves.toEqual({
refreshedDates: ['2026-07-11', '2026-07-12', '2026-07-13', '2026-07-14'],
});
expect(prisma.$transaction).toHaveBeenCalledTimes(4);
expect(tx.dailyReconciliationReport.deleteMany).toHaveBeenCalledTimes(4);
expect(tx.dailyProfitReport.deleteMany).toHaveBeenCalledTimes(4);
expect(tx.dailyQualityReport.deleteMany).toHaveBeenCalledTimes(4);
expect(tx.$executeRaw).toHaveBeenCalledTimes(28);
});
it('queries reconciliation reports with server-side filters and bounded pagination', async () => {
await expect(service.listReconciliation({
dateFrom: '2026-07-01',
dateTo: '2026-07-14',
tenantId: 'tenant-1',
applicationId: 'app-1',
page: 2,
pageSize: 500,
})).resolves.toEqual({ items: [{ id: 'recon-1' }], total: 1, page: 2, pageSize: 100 });
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1' }),
skip: 100,
take: 100,
}));
});
it('keeps application and channel profit filters separate', async () => {
await service.listProfit({ dimensionType: 'channel', tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1' });
expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
dimensionType: 'channel',
tenantId: undefined,
applicationId: undefined,
channelId: 'channel-1',
}),
}));
});
it('sorts quality reports by send volume and keeps the selected dimension', async () => {
await expect(service.listQuality({ dimensionType: 'drainage', tenantId: 'tenant-1', page: 1, pageSize: 20 })).resolves.toEqual({
items: [{ id: 'quality-1' }], total: 1, page: 1, pageSize: 20, dimensionType: 'drainage',
});
expect(prisma.dailyQualityReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ dimensionType: 'drainage', tenantId: 'tenant-1' }),
orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }],
}));
});
});
+409
View File
@@ -0,0 +1,409 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
const DEFAULT_REFRESH_INTERVAL_MS = 60 * 60 * 1000;
export type ReportListQuery = {
dateFrom?: string;
dateTo?: string;
tenantId?: string;
applicationId?: string;
channelId?: string;
dimensionType?: string;
page?: number;
pageSize?: number;
};
@Injectable()
export class ReportsService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(ReportsService.name);
private refreshTimer?: ReturnType<typeof setInterval>;
private refreshRunning = false;
private lastRefreshBusinessDate?: string;
constructor(private readonly prisma: PrismaService) {}
onModuleInit() {
if (process.env.REPORT_DAILY_REFRESH_ENABLED === 'false') return;
const startupTimer = setTimeout(() => void this.runScheduledRefresh(), 15_000);
startupTimer.unref?.();
this.refreshTimer = setInterval(
() => void this.runScheduledRefresh(),
positiveInteger(process.env.REPORT_REFRESH_INTERVAL_MS, DEFAULT_REFRESH_INTERVAL_MS),
);
this.refreshTimer.unref?.();
}
onModuleDestroy() {
if (this.refreshTimer) clearInterval(this.refreshTimer);
}
async listReconciliation(query: ReportListQuery) {
const { page, pageSize, skip } = pagination(query);
const where: Prisma.DailyReconciliationReportWhereInput = {
reportDate: dateFilter(query.dateFrom, query.dateTo),
tenantId: query.tenantId || undefined,
applicationId: query.applicationId || undefined,
};
const [items, total] = await Promise.all([
this.prisma.dailyReconciliationReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }], skip, take: pageSize }),
this.prisma.dailyReconciliationReport.count({ where }),
]);
return { items, total, page, pageSize };
}
async listProfit(query: ReportListQuery) {
const { page, pageSize, skip } = pagination(query);
const dimensionType = query.dimensionType === 'channel' ? 'channel' : 'application';
const where: Prisma.DailyProfitReportWhereInput = {
dimensionType,
reportDate: dateFilter(query.dateFrom, query.dateTo),
tenantId: dimensionType === 'application' ? query.tenantId || undefined : undefined,
applicationId: dimensionType === 'application' ? query.applicationId || undefined : undefined,
channelId: dimensionType === 'channel' ? query.channelId || undefined : undefined,
};
const [items, total] = await Promise.all([
this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }),
this.prisma.dailyProfitReport.count({ where }),
]);
return { items, total, page, pageSize, dimensionType };
}
async listQuality(query: ReportListQuery) {
const { page, pageSize, skip } = pagination(query);
const allowedDimensions = new Set(['application', 'channel', 'signature', 'drainage']);
const dimensionType = allowedDimensions.has(String(query.dimensionType)) ? String(query.dimensionType) : 'application';
const where: Prisma.DailyQualityReportWhereInput = {
dimensionType,
reportDate: dateFilter(query.dateFrom, query.dateTo),
tenantId: query.tenantId || undefined,
applicationId: query.applicationId || undefined,
channelId: query.channelId || undefined,
};
const [items, total] = await Promise.all([
this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }),
this.prisma.dailyQualityReport.count({ where }),
]);
return { items, total, page, pageSize, dimensionType };
}
async refreshRollingWindow(now = new Date()) {
const days = completedBusinessDays(now, 4);
for (const day of days) await this.refreshBusinessDay(day);
return { refreshedDates: days.map((day) => day.key) };
}
private async runScheduledRefresh() {
const businessDate = shanghaiDateKey(new Date());
if (this.refreshRunning || this.lastRefreshBusinessDate === businessDate) return;
this.refreshRunning = true;
try {
const result = await this.refreshRollingWindow();
this.lastRefreshBusinessDate = businessDate;
this.logger.log(`Daily reports refreshed for ${result.refreshedDates.join(', ')}`);
} catch (error) {
this.logger.error('Daily report refresh failed', error instanceof Error ? error.stack : String(error));
} finally {
this.refreshRunning = false;
}
}
private async refreshBusinessDay(day: BusinessDay) {
await this.prisma.$transaction(async (tx) => {
await tx.dailyReconciliationReport.deleteMany({ where: { reportDate: day.reportDate } });
await tx.dailyProfitReport.deleteMany({ where: { reportDate: day.reportDate } });
await tx.dailyQualityReport.deleteMany({ where: { reportDate: day.reportDate } });
await tx.$executeRaw(Prisma.sql`
INSERT INTO "DailyReconciliationReport" (
"id", "reportDate", "tenantId", "tenantName", "applicationId", "applicationName",
"sentUnits", "successUnits", "generatedAt", "updatedAt"
)
SELECT
CONCAT('recon-', MD5(${day.key} || ':' || tenant.id || ':' || application.id)),
${day.reportDate}::date,
tenant.id,
tenant.name,
application.id,
application.name,
COALESCE(SUM(message."billingUnits"), 0)::integer,
COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END), 0)::integer,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "SmsMessageRecord" message
JOIN "Tenant" tenant ON tenant.id = message."tenantId"
JOIN "SmsApplication" application ON application.id = message."applicationId"
WHERE message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt}
GROUP BY tenant.id, tenant.name, application.id, application.name
`);
await tx.$executeRaw(Prisma.sql`
WITH billing AS (
SELECT "messageId", SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::integer AS revenue
FROM "SmsBillingRecord"
GROUP BY "messageId"
), costs AS (
SELECT submit."messageRecordId", SUM(submit."costAmountCents")::integer AS cost
FROM "SmsSubmitRecord" submit
WHERE submit."submitStatus" = 'accepted'
GROUP BY submit."messageRecordId"
)
INSERT INTO "DailyProfitReport" (
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
"tenantId", "tenantName", "applicationId", "channelId",
"sentUnits", "successUnits", "revenueCents", "costCents", "profitCents", "profitRateBps",
"generatedAt", "updatedAt"
)
SELECT
CONCAT('profit-app-', MD5(${day.key} || ':' || application.id)),
${day.reportDate}::date,
'application',
application.id,
application.name,
tenant.id,
tenant.name,
application.id,
NULL,
COALESCE(SUM(message."billingUnits"), 0)::integer,
COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END), 0)::integer,
COALESCE(SUM(billing.revenue), 0)::integer,
COALESCE(SUM(costs.cost), 0)::integer,
(COALESCE(SUM(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0))::integer,
CASE WHEN COALESCE(SUM(billing.revenue), 0) = 0 THEN 0
ELSE ROUND((COALESCE(SUM(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0)) * 10000.0 / SUM(billing.revenue))::integer END,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "SmsMessageRecord" message
JOIN "Tenant" tenant ON tenant.id = message."tenantId"
JOIN "SmsApplication" application ON application.id = message."applicationId"
LEFT JOIN billing ON billing."messageId" = message."messageId"
LEFT JOIN costs ON costs."messageRecordId" = message.id
WHERE message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt}
GROUP BY tenant.id, tenant.name, application.id, application.name
`);
await tx.$executeRaw(Prisma.sql`
WITH billing AS (
SELECT "messageId", SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::integer AS revenue
FROM "SmsBillingRecord"
GROUP BY "messageId"
)
INSERT INTO "DailyProfitReport" (
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
"tenantId", "tenantName", "applicationId", "channelId",
"sentUnits", "successUnits", "revenueCents", "costCents", "profitCents", "profitRateBps",
"generatedAt", "updatedAt"
)
SELECT
CONCAT('profit-channel-', MD5(${day.key} || ':' || channel.id)),
${day.reportDate}::date,
'channel',
channel.id,
channel.name,
NULL,
NULL,
NULL,
channel.id,
COALESCE(SUM(message."billingUnits"), 0)::integer,
COALESCE(SUM(CASE WHEN EXISTS (
SELECT 1 FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'delivered'
) THEN message."billingUnits" ELSE 0 END), 0)::integer,
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0)::integer,
COALESCE(SUM(submit."costAmountCents"), 0)::integer,
(COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costAmountCents"), 0))::integer,
CASE WHEN COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) = 0 THEN 0
ELSE ROUND((COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costAmountCents"), 0)) * 10000.0 /
SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END))::integer END,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "SmsSubmitRecord" submit
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
LEFT JOIN billing ON billing."messageId" = message."messageId"
WHERE submit."submitStatus" = 'accepted'
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt}
AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt}
GROUP BY channel.id, channel.name
`);
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'application'));
await tx.$executeRaw(qualityByChannelSql(day));
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'signature'));
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'drainage'));
});
}
}
function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'application' | 'signature' | 'drainage') {
const dimensionTypeSql = Prisma.raw(`'${dimensionType}'`);
const dimensionId = dimensionType === 'application'
? Prisma.sql`application.id`
: dimensionType === 'signature'
? Prisma.sql`COALESCE(signature.id, 'unmatched:' || COALESCE(application.id, tenant.id))`
: Prisma.sql`COALESCE(drainage.id, 'unmatched:' || COALESCE(application.id, tenant.id))`;
const dimensionName = dimensionType === 'application'
? Prisma.sql`application.name`
: dimensionType === 'signature'
? Prisma.sql`COALESCE(signature.name, '未关联签名')`
: Prisma.sql`COALESCE(drainage."siteName", '未关联引流信息')`;
const applicationJoin = dimensionType === 'application'
? Prisma.sql`JOIN "SmsApplication" application ON application.id = message."applicationId"`
: Prisma.sql`LEFT JOIN "SmsApplication" application ON application.id = message."applicationId"`;
return Prisma.sql`
WITH base AS (
SELECT
${dimensionId} AS dimension_id,
${dimensionName} AS dimension_name,
tenant.id AS tenant_id,
tenant.name AS tenant_name,
application.id AS application_id,
message."billingUnits" AS billing_units,
CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END AS success_units,
CASE WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
AND message."submittedAt" IS NOT NULL AND message."deliveredAt" >= message."submittedAt"
THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000 END AS arrival_ms
FROM "SmsMessageRecord" message
JOIN "Tenant" tenant ON tenant.id = message."tenantId"
${applicationJoin}
LEFT JOIN "SmsSignature" signature ON signature.id = message."signatureId"
LEFT JOIN "SmsDrainageInfo" drainage ON drainage.id = message."drainageInfoId"
WHERE message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt}
), thresholds AS (
SELECT dimension_id, PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY arrival_ms) AS p95_ms
FROM base WHERE arrival_ms IS NOT NULL GROUP BY dimension_id
)
INSERT INTO "DailyQualityReport" (
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
"tenantId", "tenantName", "applicationId", "channelId", "signatureId", "drainageInfoId",
"sentUnits", "successUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt"
)
SELECT
CONCAT('quality-', ${dimensionTypeSql}, '-', MD5(${day.key} || ':' || base.dimension_id)),
${day.reportDate}::date,
${dimensionTypeSql},
base.dimension_id,
MAX(base.dimension_name),
MAX(base.tenant_id),
MAX(base.tenant_name),
CASE WHEN ${dimensionTypeSql} = 'application' THEN base.dimension_id ELSE MAX(base.application_id) END,
NULL,
CASE WHEN ${dimensionTypeSql} = 'signature' AND base.dimension_id NOT LIKE 'unmatched:%' THEN base.dimension_id ELSE NULL END,
CASE WHEN ${dimensionTypeSql} = 'drainage' AND base.dimension_id NOT LIKE 'unmatched:%' THEN base.dimension_id ELSE NULL END,
SUM(base.billing_units)::integer,
SUM(base.success_units)::integer,
CASE WHEN SUM(base.billing_units) = 0 THEN 0 ELSE ROUND(SUM(base.success_units) * 10000.0 / SUM(base.billing_units))::integer END,
ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL AND base.arrival_ms <= thresholds.p95_ms))::integer,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM base
LEFT JOIN thresholds ON thresholds.dimension_id = base.dimension_id
GROUP BY base.dimension_id
`;
}
function qualityByChannelSql(day: BusinessDay) {
return Prisma.sql`
WITH base AS (
SELECT
channel.id AS dimension_id,
channel.name AS dimension_name,
message."billingUnits" AS billing_units,
CASE WHEN receipt."deliveredAt" IS NOT NULL THEN message."billingUnits" ELSE 0 END AS success_units,
CASE WHEN receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt")
THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 END AS arrival_ms
FROM "SmsSubmitRecord" submit
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
LEFT JOIN LATERAL (
SELECT MIN(receipt."deliveredAt") AS "deliveredAt"
FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'delivered'
) receipt ON TRUE
WHERE submit."submitStatus" = 'accepted'
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt}
AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt}
), thresholds AS (
SELECT dimension_id, PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY arrival_ms) AS p95_ms
FROM base WHERE arrival_ms IS NOT NULL GROUP BY dimension_id
)
INSERT INTO "DailyQualityReport" (
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
"tenantId", "tenantName", "applicationId", "channelId", "signatureId", "drainageInfoId",
"sentUnits", "successUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt"
)
SELECT
CONCAT('quality-channel-', MD5(${day.key} || ':' || base.dimension_id)),
${day.reportDate}::date,
'channel',
base.dimension_id,
MAX(base.dimension_name),
NULL, NULL, NULL, base.dimension_id, NULL, NULL,
SUM(base.billing_units)::integer,
SUM(base.success_units)::integer,
CASE WHEN SUM(base.billing_units) = 0 THEN 0 ELSE ROUND(SUM(base.success_units) * 10000.0 / SUM(base.billing_units))::integer END,
ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL AND base.arrival_ms <= thresholds.p95_ms))::integer,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM base
LEFT JOIN thresholds ON thresholds.dimension_id = base.dimension_id
GROUP BY base.dimension_id
`;
}
type BusinessDay = { key: string; reportDate: Date; startAt: Date; endAt: Date };
function completedBusinessDays(now: Date, count: number): BusinessDay[] {
const shifted = new Date(now.getTime() + SHANGHAI_OFFSET_MS);
const today = Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate());
return Array.from({ length: count }, (_, index) => businessDay(today - (count - index) * DAY_MS));
}
function businessDay(localDateUtc: number): BusinessDay {
const reportDate = new Date(localDateUtc);
return {
key: reportDate.toISOString().slice(0, 10),
reportDate,
startAt: new Date(localDateUtc - SHANGHAI_OFFSET_MS),
endAt: new Date(localDateUtc - SHANGHAI_OFFSET_MS + DAY_MS),
};
}
function shanghaiDateKey(now: Date) {
return new Date(now.getTime() + SHANGHAI_OFFSET_MS).toISOString().slice(0, 10);
}
function dateFilter(from?: string, to?: string): Prisma.DateTimeFilter | undefined {
const gte = parseDate(from);
const lte = parseDate(to);
if (!gte && !lte) return undefined;
return { gte, lte };
}
function parseDate(value?: string) {
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined;
const date = new Date(`${value}T00:00:00.000Z`);
return Number.isNaN(date.getTime()) ? undefined : date;
}
function pagination(query: ReportListQuery) {
const page = Math.max(1, Math.floor(Number(query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 20)));
return { page, pageSize, skip: (page - 1) * pageSize };
}
function positiveInteger(value: string | undefined, fallback: number) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
+26 -1
View File
@@ -99,6 +99,9 @@ function createPrismaMock() {
smsSignature: {
findFirst: jest.fn().mockResolvedValue({ id: 'sig-1', name: '签名', auditStatus: 'approved', reportStatus: 'reporting' }),
},
smsDrainageInfo: {
findMany: jest.fn().mockResolvedValue([]),
},
smsSendTask: {
findUnique: jest.fn().mockResolvedValue(null),
},
@@ -348,6 +351,28 @@ describe('SendChainService', () => {
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
});
it('persists the unique longest approved drainage URL match on new message records', async () => {
const { service, prisma } = createService();
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
prisma.smsTemplate.findUnique.mockResolvedValue({
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', auditStatus: 'approved',
signature: { id: 'sig-1', auditStatus: 'approved', reportStatus: 'reporting' },
});
prisma.smsDrainageInfo.findMany.mockResolvedValue([
{ id: 'drain-short', url: 'https://a.example', updatedAt: new Date('2026-07-01') },
{ id: 'drain-long', url: 'https://a.example/landing', updatedAt: new Date('2026-07-02') },
]);
await service.createBatchTask({
tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1',
content: '详情请访问 https://a.example/landing', phones: ['13800000001'],
});
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({ signatureId: 'sig-1', drainageInfoId: 'drain-long' })],
});
});
it('creates scheduled tasks without immediate enqueue and dispatches due tasks later', async () => {
const { service, prisma, billing } = createService();
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
@@ -934,7 +959,7 @@ describe('SendChainService', () => {
});
expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith({
where: { OR: [{ submitId: 'SUB-1' }, { messageRecordId: 'record-1' }] },
where: { submitId: 'SUB-1' },
data: expect.objectContaining({ sequenceId: 7, gatewayMessageId: 'GW-1', submitStatus: 'accepted' }),
});
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
+39 -6
View File
@@ -282,6 +282,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const phones = [...new Set(data.phones ?? [])];
const schedule = parseSchedule(data);
await this.validateSendResources(data.tenantId, data.applicationId, data.templateId);
const messageClassification = await this.resolveTemplateMessageClassification(data.templateId, data.content);
const unitPrice = await this.resolveUnitPrice(data.tenantId, data.applicationId);
const queuePriority = await this.resolveQueuePriority(data.tenantId, data.applicationId);
const risk = await this.riskReview.evaluateTask({
@@ -366,6 +367,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
batchTaskId: task.id,
applicationId: data.applicationId,
templateId: data.templateId,
signatureId: messageClassification.signatureId,
drainageInfoId: messageClassification.drainageInfoId,
messageId: `MSG-${randomUUID()}`,
phoneNumber: phone,
content: data.content,
@@ -750,7 +753,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
: null;
const submittedAt = data.submittedAt ? new Date(data.submittedAt) : new Date();
await this.prisma.smsSubmitRecord.updateMany({
where: { OR: [{ submitId: data.submitId }, { messageRecordId: message.id }] },
where: data.submitId ? { submitId: data.submitId } : { messageRecordId: message.id },
data: {
sequenceId: data.sequenceId,
gatewayMessageId: data.gatewayMessageId,
@@ -1744,6 +1747,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
await this.recordCmppFailureReceipt(message, code, reason);
};
const queueAfterRiskChecks = async (options: { templateId?: string; signatureId?: string }) => {
const drainageInfoId = await this.resolveDrainageInfoId(options.signatureId, data.content);
const risk = await this.riskReview.evaluateTask({
tenantId: application.tenantId,
applicationId: application.id,
@@ -1759,7 +1763,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (risk.status === 'pending_review') {
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { status: 'pending_review', signatureId: options.signatureId },
data: { status: 'pending_review', signatureId: options.signatureId, drainageInfoId },
});
await this.prisma.smsBatchTask.update({
where: { id: task.id },
@@ -1786,7 +1790,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { status: 'queued', signatureId: options.signatureId },
data: { status: 'queued', signatureId: options.signatureId, drainageInfoId },
});
await this.prisma.smsBatchTask.update({
where: { id: task.id },
@@ -1831,7 +1835,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
});
}
const reviewTask = risk.status === 'pending_review' && risk.task
? await this.attachMessageToReviewTask(risk.task.id, message.id, signature.id)
? await this.attachMessageToReviewTask(risk.task.id, message.id, signature.id, await this.resolveDrainageInfoId(signature.id, data.content))
: await this.riskReview.aggregateTemplateMismatch({
tenantId: application.tenantId,
applicationId: application.id,
@@ -1986,6 +1990,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
sessionId: session.id,
submitId,
submitStatus: 'queued',
costUnitPrice: channel.unitPrice ?? 0,
costAmountCents: (channel.unitPrice ?? 0) * Math.max(1, message.billingUnits ?? 1),
},
});
await this.prisma.smsMessageRecord.update({
@@ -2276,10 +2282,37 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
});
}
private async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string) {
private async resolveTemplateMessageClassification(templateId: string | undefined, content: string) {
if (!templateId) return { signatureId: undefined, drainageInfoId: undefined };
const template = await this.prisma.smsTemplate.findUnique({
where: { id: templateId },
select: { signatureId: true },
});
const signatureId = template?.signatureId ?? undefined;
return { signatureId, drainageInfoId: await this.resolveDrainageInfoId(signatureId, content) };
}
private async resolveDrainageInfoId(signatureId: string | undefined, content: string) {
if (!signatureId) return undefined;
const candidates = await this.prisma.smsDrainageInfo.findMany({
where: { signatureId, auditStatus: 'approved' },
select: { id: true, url: true, updatedAt: true },
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
});
const matches = candidates
.map((item) => ({ ...item, normalizedUrl: item.url.trim() }))
.filter((item) => item.normalizedUrl.length > 0 && content.includes(item.normalizedUrl))
.sort((left, right) => right.normalizedUrl.length - left.normalizedUrl.length || right.updatedAt.getTime() - left.updatedAt.getTime());
if (matches.length === 0) return undefined;
const longestLength = matches[0].normalizedUrl.length;
const longestMatches = matches.filter((item) => item.normalizedUrl.length === longestLength);
return longestMatches.length === 1 ? longestMatches[0].id : undefined;
}
private async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) {
await this.prisma.smsMessageRecord.update({
where: { id: messageRecordId },
data: { reviewTaskId, signatureId, status: 'pending_review' },
data: { reviewTaskId, signatureId, drainageInfoId, status: 'pending_review' },
});
return this.prisma.smsSendTask.findUnique({ where: { id: reviewTaskId } });
}
@@ -23,6 +23,11 @@ export class AdminSmsConfigController {
return this.smsConfig.getApplicationReportFields(applicationId, reportType);
}
@Get('report-fields/common')
getCommonReportFields(@Query('reportType') reportType?: 'signature' | 'drainage') {
return this.smsConfig.getApplicationReportFields(undefined, reportType);
}
@Post('enterprise-applications')
@RequireRecentAuthentication()
createApplication(@Body() body: CreateSmsApplicationDto) {
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
@@ -35,8 +35,13 @@ export class ClientSmsConfigController {
}
@Get('applications/:id/report-fields')
getApplicationReportFields(@Param('id') applicationId: string, @TenantId() tenantId?: string) {
return this.smsConfig.getApplication(applicationId, tenantId).then(() => this.smsConfig.getApplicationReportFields(applicationId, 'drainage'));
getApplicationReportFields(@Param('id') applicationId: string, @Query('reportType') reportType: 'signature' | 'drainage' = 'drainage', @TenantId() tenantId?: string) {
return this.smsConfig.getApplication(applicationId, tenantId).then(() => this.smsConfig.getApplicationReportFields(applicationId, reportType));
}
@Get('report-fields/common')
getCommonReportFields(@Query('reportType') reportType: 'signature' | 'drainage' = 'drainage') {
return this.smsConfig.getApplicationReportFields(undefined, reportType);
}
@Post('applications/:id/secret/reset')
@@ -52,6 +52,10 @@ function createPrismaMock() {
{ id: 'rule-2', applicationId: 'app-1', groupId: 'group-unicom', carrier: 'unicom', priority: 20, status: 'active' },
]),
},
commonReportField: {
findMany: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
},
smsSignature: {
findMany: jest.fn().mockResolvedValue([{
id: 'sig-1',
@@ -544,6 +548,59 @@ describe('SmsConfigService', () => {
]);
});
it('merges common report fields into every target channel requirement', async () => {
const prisma = createPrismaMock();
prisma.commonReportField.findMany.mockResolvedValue([{
id: 'common-1', reportType: 'signature', required: true, status: 'active',
drainageField: { id: 'field-common', code: 'creditCode', name: '统一社会信用代码', fieldType: 'string', description: null, status: 'active' },
}]);
prisma.channelRouteRule.findMany.mockResolvedValue([{
id: 'route-1', priority: 10,
group: {
id: 'group-1', name: '默认通道组',
items: [{ channel: { id: 'channel-1', code: 'CH-1', name: '通道一', reportFields: [] } }],
},
}] as never);
const service = new SmsConfigService(prisma as never);
await expect(service.getApplicationReportFields('app-1', 'signature')).resolves.toEqual([
expect.objectContaining({
id: 'field-common',
required: true,
reportTypes: ['signature'],
commonReportTypes: ['signature'],
channels: [expect.objectContaining({ id: 'channel-1', source: 'common', required: true })],
}),
]);
});
it('requires common signature fields even when a signature is not bound to an application', async () => {
const prisma = createPrismaMock();
prisma.commonReportField.findMany.mockResolvedValue([{
id: 'common-1', reportType: 'signature', required: true, status: 'active',
drainageField: { id: 'field-common', code: 'creditCode', name: '统一社会信用代码', fieldType: 'string', description: null, status: 'active' },
}]);
const service = new SmsConfigService(prisma as never);
await expect(service.createSignature({ tenantId: 'tenant-1', name: '无应用签名' }))
.rejects.toThrow('缺少必填签名报备资料:统一社会信用代码');
expect(prisma.smsSignature.create).not.toHaveBeenCalled();
});
it('requires common drainage fields when adding drainage info without an application', async () => {
const prisma = createPrismaMock();
prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: null, auditStatus: 'approved' });
prisma.commonReportField.findMany.mockResolvedValue([{
id: 'common-2', reportType: 'drainage', required: true, status: 'active',
drainageField: { id: 'field-site', code: 'siteOwner', name: '网站主体', fieldType: 'string', description: null, status: 'active' },
}]);
const service = new SmsConfigService(prisma as never);
await expect(service.createDrainageInfo('sig-1', { siteName: '官网', url: 'https://example.com', reportValues: {} }, {}, 'tenant-1'))
.rejects.toThrow('引流信息缺少必填报备资料:网站主体');
expect(prisma.smsDrainageInfo.create).not.toHaveBeenCalled();
});
it('validates and persists dynamic signature report values by channel without bypassing drainage audit', async () => {
const prisma = createPrismaMock();
prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'draft' });
+79 -25
View File
@@ -223,27 +223,38 @@ 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' } },
async getApplicationReportFields(applicationId?: string, reportType?: 'signature' | 'drainage') {
if (applicationId) await this.getApplication(applicationId);
const [commonFields, routes] = await Promise.all([
this.prisma.commonReportField.findMany({
where: {
status: 'active',
reportType,
drainageField: { status: 'active' },
},
include: { drainageField: true },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
}),
applicationId ? 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' },
});
orderBy: { priority: 'asc' },
}) : Promise.resolve([]),
]);
type MergedReportField = {
id: string;
code: string;
@@ -252,9 +263,43 @@ export class SmsConfigService {
required: boolean;
description?: string | null;
reportTypes: string[];
channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: string }>;
commonReportTypes: string[];
channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: string; source: 'common' | 'channel' | 'both' }>;
};
const merged = new Map<string, MergedReportField>();
const routeChannels = new Map<string, { id: string; code: string; name: string; groupId: string; groupName: string }>();
for (const route of routes) {
if (!route.group) continue;
for (const item of route.group.items) {
if (!routeChannels.has(item.channel.id)) {
routeChannels.set(item.channel.id, {
id: item.channel.id,
code: item.channel.code,
name: item.channel.name,
groupId: route.group.id,
groupName: route.group.name,
});
}
}
}
for (const configured of commonFields) {
merged.set(configured.drainageField.id, {
id: configured.drainageField.id,
code: configured.drainageField.code,
name: configured.drainageField.name,
fieldType: configured.drainageField.fieldType,
required: configured.required,
description: configured.drainageField.description,
reportTypes: [configured.reportType],
commonReportTypes: [configured.reportType],
channels: Array.from(routeChannels.values()).map((channel) => ({
...channel,
required: configured.required,
reportType: configured.reportType,
source: 'common' as const,
})),
});
}
for (const route of routes) {
if (!route.group) continue;
for (const item of route.group.items) {
@@ -270,11 +315,17 @@ export class SmsConfigService {
required: false,
description: configured.drainageField.description,
reportTypes: [],
commonReportTypes: [],
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)) {
const existingChannel = current.channels.find((channel) => channel.id === item.channel.id);
if (existingChannel) {
existingChannel.required = existingChannel.required || configured.required;
existingChannel.reportType = configured.reportType;
existingChannel.source = existingChannel.source === 'common' ? 'both' : existingChannel.source;
} else {
current.channels.push({
id: item.channel.id,
code: item.channel.code,
@@ -283,6 +334,7 @@ export class SmsConfigService {
groupName: route.group.name,
required: configured.required,
reportType: configured.reportType,
source: 'channel',
});
}
merged.set(key, current);
@@ -653,6 +705,9 @@ export class SmsConfigService {
where: { applicationId: { in: applicationIds }, status: 'active' },
include: { group: { include: { items: { include: { channel: { include: { reportFields: true } } } } } } },
}) : [];
const hasCommonDrainageFields = await this.prisma.commonReportField.count({
where: { status: 'active', reportType: 'drainage', drainageField: { status: 'active' } },
}).then((count) => count > 0);
return signatures.map((signature) => {
const legacyPayload = isRecord(signature.drainageInfo) ? signature.drainageInfo : {};
const drainageLinks = signature.drainageItems.map((item) => ({
@@ -681,7 +736,7 @@ export class SmsConfigService {
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)));
.filter((channel) => channel.status !== 'deleted' && (hasCommonDrainageFields || 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()].flatMap((channel) => {
const task = taskByChannel.get(channel.id);
@@ -693,7 +748,7 @@ export class SmsConfigService {
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)));
.filter((channel) => channel.status !== 'deleted' && (hasCommonDrainageFields || 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) => {
@@ -882,7 +937,7 @@ export class SmsConfigService {
}
private async withReportRequirementSnapshot(applicationId?: string, drainageInfo?: Record<string, unknown>) {
if (!drainageInfo || !applicationId) return drainageInfo;
if (!drainageInfo) return drainageInfo;
const fields = await this.getApplicationReportFields(applicationId);
return {
...drainageInfo,
@@ -896,6 +951,7 @@ export class SmsConfigService {
fieldType: field.fieldType,
required: field.required,
reportTypes: field.reportTypes,
commonReportTypes: field.commonReportTypes,
channels: field.channels,
})),
},
@@ -903,7 +959,7 @@ export class SmsConfigService {
}
private async syncSignatureReportValues(signatureId: string, applicationId?: string, drainageInfo?: Record<string, unknown>) {
if (!applicationId || !drainageInfo) return;
if (!drainageInfo) return;
const fields = await this.getApplicationReportFields(applicationId);
const signatureValues = isRecord(drainageInfo.signatureReportValues) ? drainageInfo.signatureReportValues : {};
for (const field of fields.filter((item) => item.reportTypes.some((type) => type === 'signature' || type === 'both'))) {
@@ -919,9 +975,8 @@ export class SmsConfigService {
}
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 fields = await this.getApplicationReportFields(applicationId, 'signature');
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]));
@@ -931,7 +986,6 @@ export class SmsConfigService {
}
private async validateDrainageReportValues(applicationId?: string, reportValues: Record<string, unknown> = {}) {
if (!applicationId) return;
const fields = await this.getApplicationReportFields(applicationId, 'drainage');
const missing = fields.filter((field) => field.required && !hasReportValue(reportValues[field.code]));
if (missing.length > 0) {