feat: add reconciliation and quality reporting
This commit is contained in:
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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' }],
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user