fix: enforce drainage uniqueness and carrier-specific reporting

This commit is contained in:
hectorzhao
2026-09-14 18:12:38 +08:00
parent ac6449028c
commit 7f9abe3da0
26 changed files with 2637 additions and 965 deletions
@@ -0,0 +1,10 @@
-- Preserve all legacy rows; independent carrier states require distinct business keys.
BEGIN;
CREATE UNIQUE INDEX "ChannelSignatureReportTask_drainage_carrier_key"
ON "ChannelSignatureReportTask" ("signatureId", "drainageItemId", "channelId", "carrier")
WHERE "reportType" = 'drainage' AND "drainageItemId" IS NOT NULL AND "carrier" IS NOT NULL;
CREATE UNIQUE INDEX "ChannelSignatureReportTask_drainage_legacy_key"
ON "ChannelSignatureReportTask" ("signatureId", "drainageItemId", "channelId")
WHERE "reportType" = 'drainage' AND "drainageItemId" IS NOT NULL AND "carrier" IS NULL;
DROP INDEX "ChannelSignatureReportTask_drainage_target_key";
COMMIT;
+41 -15
View File
@@ -1,3 +1,4 @@
import { selectDrainageReportTask } from '../common/drainage-report-task';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
@@ -450,17 +451,25 @@ export class ChannelReportingService {
}),
);
const drainageDetails = signature.drainageItems.flatMap((drainageInfo) =>
channels
.map((channel) => {
const existing = signature.reportTasks.find(
(task) =>
task.reportType === 'drainage' &&
task.channelId === channel.id &&
task.drainageItemId === drainageInfo.id,
channels.flatMap((channel) =>
normalizeChannelCarriers(channel.carriers, channel.carrier).flatMap((carrier) => {
const tasks = signature.reportTasks.filter(
(task) => task.reportType === 'drainage' && task.drainageItemId === drainageInfo.id,
);
return existing ? { ...existing, signature } : undefined;
})
.filter(Boolean),
const existing = selectDrainageReportTask(tasks, channel.id, carrier);
return existing
? [
{
...existing,
id: existing.carrier ? existing.id : `virtual:${drainageInfo.id}:${channel.id}:${carrier}`,
carrier,
virtual: !existing.carrier,
signature,
},
]
: [];
}),
),
);
return [...signatureDetails, ...drainageDetails];
})
@@ -551,6 +560,9 @@ export class ChannelReportingService {
throw new BadRequestException('unsupported report task source entry');
}
return this.prisma.$transaction(async (tx) => {
for (const signatureId of [...new Set(data.items.map((item) => item.signatureId))].sort()) {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${signatureId}, 910))`;
}
const signatureIds = [
...new Set(
data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId),
@@ -561,6 +573,7 @@ export class ChannelReportingService {
reportType: 'drainage';
drainageItemId: string;
channelId: string;
carrier: string | null;
status: string;
}> = [];
for (const item of data.items) {
@@ -577,7 +590,7 @@ export class ChannelReportingService {
if (drainageInfo.auditStatus !== 'approved')
throw new BadRequestException('引流信息审核通过后才能修改通道报备状态');
}
const carrier = reportType === 'signature' && item.carrier ? normalizeBusinessCarrier(item.carrier) : null;
const carrier = item.carrier ? normalizeBusinessCarrier(item.carrier) : null;
if (carrier && !normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)) {
throw new BadRequestException('报备运营商不在通道支持范围内');
}
@@ -587,11 +600,23 @@ export class ChannelReportingService {
channelId: item.channelId,
reportType,
drainageItemId: reportType === 'drainage' ? item.drainageItemId : null,
carrier: reportType === 'signature' ? carrier : null,
carrier,
},
});
if (reportType === 'drainage' && !existing)
throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核');
if (reportType === 'drainage' && !existing) {
const legacy = carrier
? await tx.channelSignatureReportTask.findFirst({
where: {
signatureId: item.signatureId,
channelId: item.channelId,
reportType,
drainageItemId: item.drainageItemId,
carrier: null,
},
})
: null;
if (!legacy) throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核');
}
if (reportType === 'signature' && !carrier && !existing)
throw new BadRequestException('签名报备状态必须指定运营商');
const approvedAt =
@@ -603,7 +628,7 @@ export class ChannelReportingService {
const task = existing
? await tx.channelSignatureReportTask.update({
where: { id: existing.id },
data: { status: item.status, reason: data.reason, ...(reportType === 'signature' ? { approvedAt } : {}) },
data: { status: item.status, reason: data.reason, approvedAt },
})
: await tx.channelSignatureReportTask.create({
data: {
@@ -638,6 +663,7 @@ export class ChannelReportingService {
reportType,
drainageItemId: item.drainageItemId!,
channelId: item.channelId,
carrier,
status: item.status,
});
}
@@ -370,6 +370,7 @@ describe('ChannelsService', () => {
updatedAt: new Date(),
};
const tx = {
$executeRaw: jest.fn().mockResolvedValue(1),
channelReportField: {
findMany: jest
.fn()
@@ -519,6 +520,7 @@ describe('ChannelsService', () => {
async (sourceEntry) => {
const prisma = createPrismaMock();
const tx = {
$executeRaw: jest.fn().mockResolvedValue(1),
smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
@@ -601,6 +603,7 @@ describe('ChannelsService', () => {
it('uses the enterprise-signature save time when creating an approved carrier task', async () => {
const prisma = createPrismaMock();
const tx = {
$executeRaw: jest.fn().mockResolvedValue(1),
smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
@@ -655,6 +658,7 @@ describe('ChannelsService', () => {
it('changes a drainage report task without overwriting the signature report summary', async () => {
const prisma = createPrismaMock();
const tx = {
$executeRaw: jest.fn().mockResolvedValue(1),
smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
update: jest.fn(),
@@ -699,6 +703,7 @@ describe('ChannelsService', () => {
reportType: 'drainage',
drainageItemId: 'drain-1',
channelId: 'channel-1',
carrier: null,
status: 'approved',
},
]);
@@ -1259,6 +1264,7 @@ describe('ChannelsService', () => {
const transactionCallback = prisma.$transaction.mock.calls[0][0];
const tx = {
$executeRaw: jest.fn().mockResolvedValue(1),
smsChannelGroupItem: { deleteMany: jest.fn(), createMany: jest.fn() },
smsChannelGroup: {
update: jest.fn(),
+13
View File
@@ -0,0 +1,13 @@
/** A carrier-specific decision overrides a legacy channel decision, including rejection. */
export function selectDrainageReportTask<
T extends {
channelId: string;
carrier?: string | null;
approvalScope?: string;
},
>(tasks: T[], channelId: string, carrier?: string) {
return (
(carrier ? tasks.find((task) => task.channelId === channelId && task.carrier === carrier) : undefined) ??
tasks.find((task) => task.channelId === channelId && !task.carrier && task.approvalScope !== 'carrier_specific')
);
}
@@ -235,7 +235,10 @@ export class ReportBatchGenerationService {
continue;
if (scope.batchItem.reportType === 'drainage' && task.drainageItemId !== scope.batchItem.drainageItemId)
continue;
if (scope.batchItem.reportType === 'signature' && carriers.size && task.carrier && !carriers.has(task.carrier))
if (
carriers.size &&
(task.carrier ? !carriers.has(task.carrier) : !carriers.has('all') && !carriers.has('legacy'))
)
continue;
const key = `${scope.batchItem.id}:${task.id}`;
if (seen.has(key)) continue;
@@ -664,7 +667,7 @@ export class ReportBatchGenerationService {
where: { channelId: channel.id, status: 'active', reportType: { in: [selected.reportType, 'both'] } },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
});
const targetCarriers = selected.reportType === 'signature' ? [...carriers].sort() : ['all'];
const targetCarriers = [...carriers].sort();
for (const carrier of targetCarriers) {
const businessKey = `${selected.reportType}:${selected.drainageItemId ?? signature.id}:v${materialVersion}:app:${signature.applicationId}:channel:${channel.id}:carrier:${carrier}`;
const targetReasons = [...blockedReasons];
@@ -677,11 +680,13 @@ export class ReportBatchGenerationService {
if (missing.length)
targetReasons.push(`缺少必填字段:${missing.map((field) => field.exportName || field.name).join('、')}`);
}
const existingTask = currentTasks.find(
(task) => task.channelId === channel.id && (selected.reportType === 'drainage' || task.carrier === carrier),
);
const existingTask = currentTasks.find((task) => task.channelId === channel.id && task.carrier === carrier);
if (existingTask?.status === 'abandoned') targetReasons.push('该通道报备明细已放弃报备');
const duplicateBatchId = priorKeys.get(businessKey);
const duplicateBatchId =
priorKeys.get(businessKey) ??
(selected.reportType === 'drainage'
? priorKeys.get(businessKey.replace(/:carrier:[^:]+$/, ':carrier:all'))
: undefined);
if (duplicateBatchId) targetReasons.push(`同一资料版本已在批次 ${duplicateBatchId} 生成`);
targets.push({
id: `${channel.id}:${carrier}`,
@@ -71,47 +71,48 @@ export class ReportChannelExportService {
: missing.length
? `缺少字段:${missing.map((field) => field.exportName || field.name).join('、')}`
: null;
const reportCarriers =
reportType === 'signature'
? item.eligibleTargets
.filter((target) => target.channelId === channelId)
.map((target) => target.carrier as 'mobile' | 'unicom' | 'telecom')
: [null];
const reportCarriers = item.eligibleTargets
.filter((target) => target.channelId === channelId)
.map((target) => target.carrier as 'mobile' | 'unicom' | 'telecom');
const tasks: Array<{ task: { id: string; reason: string | null }; existingTask: { status: string } | null }> =
[];
for (const carrier of reportCarriers) {
const existingTask = await this.prisma.channelSignatureReportTask.findFirst({
where: {
signatureId: item.signature.id,
channelId,
carrier,
reportType,
drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null,
},
const entry = await this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${item.signature.id}, 910))`;
const existingTask = await tx.channelSignatureReportTask.findFirst({
where: {
signatureId: item.signature.id,
channelId,
carrier,
reportType,
drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null,
},
});
const task = existingTask
? await tx.channelSignatureReportTask.update({
where: { id: existingTask.id },
data: {
status: missingReason ? 'waiting_material' : 'exporting',
reason: missingReason,
approvedAt: null,
},
})
: await tx.channelSignatureReportTask.create({
data: {
tenantId: item.signature.tenantId,
signatureId: item.signature.id,
channelId,
carrier,
approvalScope: 'carrier_specific',
reportType,
drainageItemId: item.drainageInfo?.id,
status: missingReason ? 'waiting_material' : 'exporting',
reason: missingReason,
},
});
return { task, existingTask };
});
const task = existingTask
? await this.prisma.channelSignatureReportTask.update({
where: { id: existingTask.id },
data: {
status: missingReason ? 'waiting_material' : 'exporting',
reason: missingReason,
...(reportType === 'signature' ? { approvedAt: null } : {}),
},
})
: await this.prisma.channelSignatureReportTask.create({
data: {
tenantId: item.signature.tenantId,
signatureId: item.signature.id,
channelId,
carrier,
approvalScope: reportType === 'signature' ? 'carrier_specific' : 'legacy_channel',
reportType,
drainageItemId: item.drainageInfo?.id,
status: missingReason ? 'waiting_material' : 'exporting',
reason: missingReason,
},
});
tasks.push({ task, existingTask });
tasks.push(entry);
}
const task = tasks[0].task;
if (missingReason) {
@@ -128,3 +128,17 @@ describe('drainage authorization', () => {
expect(materialMatches(targets[0], 'lisglo.cn')).toBe(false);
});
});
describe('drainage carrier override', () => {
it('uses explicit rejection over legacy approval and keeps other carriers independent', () => {
const row = material('m', 'example.com', []);
row.reportTasks = [
{ id: 'legacy', channelId: 'c', carrier: null, status: 'approved' },
{ id: 'mobile', channelId: 'c', carrier: 'mobile', status: 'failed' },
{ id: 'unicom', channelId: 'c', carrier: 'unicom', status: 'approved' },
];
expect(assessDrainage([target('example.com')], [row], 'mobile').allowedChannelIds).toEqual([]);
expect(assessDrainage([target('example.com')], [row], 'unicom').allowedChannelIds).toEqual(['c']);
expect(assessDrainage([target('example.com')], [row], 'telecom').allowedChannelIds).toEqual(['c']);
});
});
+5 -3
View File
@@ -1,4 +1,5 @@
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
import { selectDrainageReportTask } from '../common/drainage-report-task';
import { isIP } from 'node:net';
import { parse } from 'tldts';
import type { PrismaService } from '../prisma/prisma.service';
@@ -19,7 +20,7 @@ export type DrainageMaterial = {
url: string;
auditStatus: string;
materialVersion: number;
reportTasks: Array<{ id: string; channelId: string; carrier: string | null; status: string }>;
reportTasks: Array<{ id: string; channelId: string; carrier: string | null; approvalScope?: string; status: string }>;
};
export type DrainageAssessment = {
version: string;
@@ -138,8 +139,9 @@ export function assessDrainage(
}
const channels = new Set(
approved.flatMap((item) =>
item.reportTasks
.filter((task) => task.status === 'approved' && (!task.carrier || !carrier || task.carrier === carrier))
[...new Set(item.reportTasks.map((task) => task.channelId))]
.map((channelId) => selectDrainageReportTask(item.reportTasks, channelId, carrier))
.filter((task): task is NonNullable<typeof task> => task?.status === 'approved')
.map((task) => task.channelId),
),
);
+195 -110
View File
@@ -1,12 +1,15 @@
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomInt, randomUUID } from 'node:crypto';
import { isIpAllowed } from '../common/ip-allowlist';
import { assertMoneyUnits } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
import { automaticDeliveryMode } from '../open-api/delivery-mode';
import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts';
import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers';
import type {
CreateSmsDrainageInfoDto,
CreateSmsSignatureOptions,
DrainageInfoListQuery,
ReviewDto,
StatusChangeDto,
UpdateSmsDrainageInfoDto,
} from './sms-config.contracts';
import { isRecord } from './sms-config.helpers';
import { SmsReportValidationService } from './report-validation.service';
import { SmsAuditService } from './audit.service';
import { shanghaiDateRange } from '../common/shanghai-date-range';
@@ -20,67 +23,98 @@ function normalizeDrainageTarget(value?: string) {
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
export class SmsDrainageService {
constructor(private readonly prisma: PrismaService, private readonly reportValidation: SmsReportValidationService, private readonly audit: SmsAuditService) {}
constructor(
private readonly prisma: PrismaService,
private readonly reportValidation: SmsReportValidationService,
private readonly audit: SmsAuditService,
) {}
private async assertUniqueTarget(
tx: Prisma.TransactionClient,
signatureId: string,
target: string,
excludeId?: string,
) {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${signatureId}, 910))`;
const duplicate = await tx.smsDrainageInfo.findFirst({
where: {
signatureId,
url: target,
auditStatus: { not: 'deleted' },
...(excludeId ? { id: { not: excludeId } } : {}),
},
select: { id: true },
});
if (duplicate) throw new BadRequestException('同一签名下已存在相同的引流信息');
}
async listClientDrainageInfos(tenantId?: string, itemId?: string) {
return this.prisma.smsDrainageInfo.findMany({
where: { id: itemId, tenantId, auditStatus: { not: 'deleted' } },
select: {
id: true,
tenantId: true,
signatureId: true,
applicationId: true,
siteName: true,
url: true,
remark: true,
reportValues: true,
auditStatus: true,
rejectReason: true,
submittedAt: true,
reviewedAt: true,
createdAt: true,
updatedAt: true,
signature: { select: { id: true, name: true, auditStatus: true } },
application: { select: { id: true, name: true, status: true } },
},
orderBy: { updatedAt: 'desc' },
});
}
return this.prisma.smsDrainageInfo.findMany({
where: { id: itemId, tenantId, auditStatus: { not: 'deleted' } },
select: {
id: true,
tenantId: true,
signatureId: true,
applicationId: true,
siteName: true,
url: true,
remark: true,
reportValues: true,
auditStatus: true,
rejectReason: true,
submittedAt: true,
reviewedAt: true,
createdAt: true,
updatedAt: true,
signature: { select: { id: true, name: true, auditStatus: true } },
application: { select: { id: true, name: true, status: true } },
},
orderBy: { updatedAt: 'desc' },
});
}
async getClientDrainageInfoView(itemId: string, tenantId?: string) {
const [item] = await this.listClientDrainageInfos(tenantId, itemId);
if (!item) throw new NotFoundException('Drainage info not found');
return item;
}
const [item] = await this.listClientDrainageInfos(tenantId, itemId);
if (!item) throw new NotFoundException('Drainage info not found');
return item;
}
listDrainageInfos(query: DrainageInfoListQuery = {}) {
return this.prisma.smsDrainageInfo.findMany({
where: {
tenantId: query.tenantId,
signatureId: query.signatureId,
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
submittedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo),
OR: query.keyword ? [
{ siteName: { contains: query.keyword } },
{ url: { contains: query.keyword } },
{ signature: { name: { contains: query.keyword } } },
{ tenant: { name: { contains: query.keyword } } },
{ application: { name: { contains: query.keyword } } },
] : undefined,
},
include: { tenant: true, signature: true, application: true, reportTasks: { include: { channel: true } } },
orderBy: { updatedAt: 'desc' },
});
}
return this.prisma.smsDrainageInfo.findMany({
where: {
tenantId: query.tenantId,
signatureId: query.signatureId,
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
submittedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo),
OR: query.keyword
? [
{ siteName: { contains: query.keyword } },
{ url: { contains: query.keyword } },
{ signature: { name: { contains: query.keyword } } },
{ tenant: { name: { contains: query.keyword } } },
{ application: { name: { contains: query.keyword } } },
]
: undefined,
},
include: { tenant: true, signature: true, application: true, reportTasks: { include: { channel: true } } },
orderBy: { updatedAt: 'desc' },
});
}
async createDrainageInfo(signatureId: string, data: CreateSmsDrainageInfoDto, options: CreateSmsSignatureOptions = {}, tenantId?: string) {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
if (!signature) throw new NotFoundException('Signature not found');
if (tenantId && signature.tenantId !== tenantId) throw new NotFoundException('Signature not found');
if (signature.auditStatus !== 'approved') throw new BadRequestException('签名审核通过后才能新增引流信息');
const target = normalizeDrainageTarget(data.url);
await this.reportValidation.validateDrainageReportValues(signature.applicationId ?? undefined, data.reportValues);
const auditStatus = options.initialAuditStatus ?? 'pending';
const item = await this.prisma.smsDrainageInfo.create({
async createDrainageInfo(
signatureId: string,
data: CreateSmsDrainageInfoDto,
options: CreateSmsSignatureOptions = {},
tenantId?: string,
) {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
if (!signature) throw new NotFoundException('Signature not found');
if (tenantId && signature.tenantId !== tenantId) throw new NotFoundException('Signature not found');
if (signature.auditStatus !== 'approved') throw new BadRequestException('签名审核通过后才能新增引流信息');
const target = normalizeDrainageTarget(data.url);
await this.reportValidation.validateDrainageReportValues(signature.applicationId ?? undefined, data.reportValues);
const auditStatus = options.initialAuditStatus ?? 'pending';
const item = await this.prisma.$transaction(async (tx) => {
await this.assertUniqueTarget(tx, signatureId, target);
return tx.smsDrainageInfo.create({
data: {
tenantId: signature.tenantId,
signatureId,
@@ -94,28 +128,65 @@ export class SmsDrainageService {
},
include: { tenant: true, signature: true, application: true },
});
await this.audit.createAuditRecord({
tenantId: item.tenantId,
targetType: 'sms_drainage_info',
targetId: item.id,
action: auditStatus === 'approved' ? 'admin_create_approved' : 'submit',
statusAfter: auditStatus,
reason: auditStatus === 'approved' ? '运营端新建引流信息自动审核通过' : undefined,
});
if (auditStatus === 'approved') await this.reportValidation.activateDrainageReporting(item.id);
return item;
}
});
await this.audit.createAuditRecord({
tenantId: item.tenantId,
targetType: 'sms_drainage_info',
targetId: item.id,
action: auditStatus === 'approved' ? 'admin_create_approved' : 'submit',
statusAfter: auditStatus,
reason: auditStatus === 'approved' ? '运营端新建引流信息自动审核通过' : undefined,
});
if (auditStatus === 'approved') await this.reportValidation.activateDrainageReporting(item.id);
return item;
}
async updateDrainageInfo(itemId: string, data: UpdateSmsDrainageInfoDto, options: CreateSmsSignatureOptions = {}, tenantId?: string) {
const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } });
if (!current) throw new NotFoundException('Drainage info not found');
if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found');
if (current.auditStatus === 'deleted') throw new BadRequestException('已删除的引流信息不能修改');
const target = data.url === undefined ? undefined : normalizeDrainageTarget(data.url);
const applicationId = current.signature.applicationId ?? current.applicationId ?? undefined;
await this.reportValidation.validateDrainageReportValues(applicationId, data.reportValues ?? (isRecord(current.reportValues) ? current.reportValues : {}));
const auditStatus = options.initialAuditStatus ?? 'pending';
const updated = await this.prisma.smsDrainageInfo.update({
async updateDrainageInfo(
itemId: string,
data: UpdateSmsDrainageInfoDto,
options: CreateSmsSignatureOptions = {},
tenantId?: string,
) {
const current = await this.prisma.smsDrainageInfo.findUnique({
where: { id: itemId },
include: { signature: true },
});
if (!current) throw new NotFoundException('Drainage info not found');
if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found');
if (current.auditStatus === 'deleted') throw new BadRequestException('已删除的引流信息不能修改');
const target = data.url === undefined ? undefined : normalizeDrainageTarget(data.url);
const applicationId = current.signature.applicationId ?? current.applicationId ?? undefined;
await this.reportValidation.validateDrainageReportValues(
applicationId,
data.reportValues ?? (isRecord(current.reportValues) ? current.reportValues : {}),
);
const auditStatus = options.initialAuditStatus ?? 'pending';
const updated = await this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${current.signatureId}, 910))`;
const latest = await tx.smsDrainageInfo.findUnique({ where: { id: itemId } });
if (!latest || latest.auditStatus === 'deleted') throw new BadRequestException('已删除的引流信息不能修改');
if (target !== undefined && target !== latest.url)
await this.assertUniqueTarget(tx, current.signatureId, target, itemId);
const priorTasks = await tx.channelSignatureReportTask.findMany({
where: { drainageItemId: itemId, reportType: 'drainage' },
});
for (const task of priorTasks) {
await tx.channelSignatureReportTask.update({
where: { id: task.id },
data: { status: 'waiting_review', approvedAt: null, reason: '引流资料修改,原报备失效' },
});
await tx.channelSignatureReportRecord.create({
data: {
taskId: task.id,
channelId: task.channelId,
action: 'material_changed',
statusBefore: task.status,
statusAfter: 'waiting_review',
reason: '引流资料修改,原报备失效',
},
});
}
return tx.smsDrainageInfo.update({
where: { id: itemId },
data: {
applicationId,
@@ -133,37 +204,51 @@ export class SmsDrainageService {
},
include: { tenant: true, signature: true, application: true },
});
await this.audit.createAuditRecord({
tenantId: current.tenantId,
targetType: 'sms_drainage_info',
targetId: itemId,
action: auditStatus === 'approved' ? 'admin_update_approved' : 'update_submit',
statusBefore: current.auditStatus,
statusAfter: auditStatus,
reason: auditStatus === 'approved' ? '运营端修改引流信息并自动审核通过' : undefined,
});
if (auditStatus === 'approved') await this.reportValidation.activateDrainageReporting(itemId);
else await this.reportValidation.suspendDrainageReporting(itemId, '引流信息修改后等待运营审核');
return updated;
}
});
await this.audit.createAuditRecord({
tenantId: current.tenantId,
targetType: 'sms_drainage_info',
targetId: itemId,
action: auditStatus === 'approved' ? 'admin_update_approved' : 'update_submit',
statusBefore: current.auditStatus,
statusAfter: auditStatus,
reason: auditStatus === 'approved' ? '运营端修改引流信息并自动审核通过' : undefined,
});
if (auditStatus === 'approved') await this.reportValidation.activateDrainageReporting(itemId);
else await this.reportValidation.suspendDrainageReporting(itemId, '引流信息修改后等待运营审核');
return updated;
}
approveDrainageInfo(itemId: string, data: ReviewDto) {
return this.audit.reviewDrainageInfo(itemId, 'approved', 'approve', data);
}
return this.audit.reviewDrainageInfo(itemId, 'approved', 'approve', data);
}
rejectDrainageInfo(itemId: string, data: ReviewDto) {
return this.audit.reviewDrainageInfo(itemId, 'rejected', 'reject', data);
}
return this.audit.reviewDrainageInfo(itemId, 'rejected', 'reject', data);
}
async changeDrainageInfoStatus(itemId: string, data: StatusChangeDto, tenantId?: string) {
const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } });
if (!current) throw new NotFoundException('Drainage info not found');
if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found');
const status = data.status ?? 'deleted';
if (tenantId && status !== 'deleted') throw new BadRequestException('客户端只能删除引流信息,不能直接修改审核状态');
const updated = await this.prisma.smsDrainageInfo.update({ where: { id: itemId }, data: { auditStatus: status } });
if (status === 'deleted') await this.reportValidation.suspendDrainageReporting(itemId, data.reason ?? '引流信息已删除', 'abandoned');
await this.audit.createAuditRecord({ tenantId: current.tenantId, targetType: 'sms_drainage_info', targetId: itemId, action: status, statusBefore: current.auditStatus, statusAfter: status, reason: data.reason });
return updated;
}
const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } });
if (!current) throw new NotFoundException('Drainage info not found');
if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found');
const status = data.status ?? 'deleted';
if (tenantId && status !== 'deleted') throw new BadRequestException('客户端只能删除引流信息,不能直接修改审核状态');
const updated = await this.prisma.$transaction(async (tx) => {
// Restoration must obey the same uniqueness lock as create and edit.
if (status !== 'deleted') await this.assertUniqueTarget(tx, current.signatureId, current.url, itemId);
return tx.smsDrainageInfo.update({ where: { id: itemId }, data: { auditStatus: status } });
});
if (status === 'deleted')
await this.reportValidation.suspendDrainageReporting(itemId, data.reason ?? '引流信息已删除', 'abandoned');
await this.audit.createAuditRecord({
tenantId: current.tenantId,
targetType: 'sms_drainage_info',
targetId: itemId,
action: status,
statusBefore: current.auditStatus,
statusAfter: status,
reason: data.reason,
});
return updated;
}
}
+161 -97
View File
@@ -1,122 +1,186 @@
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomInt, randomUUID } from 'node:crypto';
import { isIpAllowed } from '../common/ip-allowlist';
import { assertMoneyUnits } from '../common/money';
import { normalizeChannelCarriers } from '../channels/channels.helpers';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { automaticDeliveryMode } from '../open-api/delivery-mode';
import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts';
import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers';
import { hasReportValue, isRecord, reportValueParts } from './sms-config.helpers';
import { SmsApplicationConfigService } from './application-config.service';
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
export class SmsReportValidationService {
constructor(private readonly prisma: PrismaService, private readonly applications: SmsApplicationConfigService) {}
constructor(
private readonly prisma: PrismaService,
private readonly applications: SmsApplicationConfigService,
) {}
async withReportRequirementSnapshot(applicationId?: string, drainageInfo?: Record<string, unknown>) {
if (!drainageInfo) return drainageInfo;
const fields = await this.applications.getApplicationReportFields(applicationId);
return {
...drainageInfo,
reportRequirementSnapshot: {
capturedAt: new Date().toISOString(),
applicationId,
fields: fields.map((field) => ({
id: field.id,
code: field.code,
name: field.name,
fieldType: field.fieldType,
required: field.required,
reportTypes: field.reportTypes,
commonReportTypes: field.commonReportTypes,
channels: field.channels,
})),
},
};
}
if (!drainageInfo) return drainageInfo;
const fields = await this.applications.getApplicationReportFields(applicationId);
return {
...drainageInfo,
reportRequirementSnapshot: {
capturedAt: new Date().toISOString(),
applicationId,
fields: fields.map((field) => ({
id: field.id,
code: field.code,
name: field.name,
fieldType: field.fieldType,
required: field.required,
reportTypes: field.reportTypes,
commonReportTypes: field.commonReportTypes,
channels: field.channels,
})),
},
};
}
async syncSignatureReportValues(signatureId: string, applicationId?: string, drainageInfo?: Record<string, unknown>) {
if (!drainageInfo) return;
const fields = await this.applications.getApplicationReportFields(applicationId);
const signatureValues = isRecord(drainageInfo.signatureReportValues) ? drainageInfo.signatureReportValues : {};
for (const field of fields.filter((item) => item.reportTypes.some((type) => type === 'signature' || type === 'both'))) {
const value = reportValueParts(signatureValues[field.code]);
for (const channel of field.channels) {
await this.prisma.signatureReportMaterial.upsert({
where: { signatureId_channelId_fieldCode: { signatureId, channelId: channel.id, fieldCode: field.code } },
update: value,
create: { signatureId, channelId: channel.id, fieldCode: field.code, ...value },
});
}
if (!drainageInfo) return;
const fields = await this.applications.getApplicationReportFields(applicationId);
const signatureValues = isRecord(drainageInfo.signatureReportValues) ? drainageInfo.signatureReportValues : {};
for (const field of fields.filter((item) =>
item.reportTypes.some((type) => type === 'signature' || type === 'both'),
)) {
const value = reportValueParts(signatureValues[field.code]);
for (const channel of field.channels) {
await this.prisma.signatureReportMaterial.upsert({
where: { signatureId_channelId_fieldCode: { signatureId, channelId: channel.id, fieldCode: field.code } },
update: value,
create: { signatureId, channelId: channel.id, fieldCode: field.code, ...value },
});
}
}
}
async validateSignatureReportValues(applicationId?: string, drainageInfo?: Record<string, unknown>) {
const fields = await this.applications.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]));
if (missingSignature.length > 0) {
throw new BadRequestException(`缺少必填签名报备资料:${missingSignature.map((field) => field.name).join('、')}`);
}
const fields = await this.applications.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]));
if (missingSignature.length > 0) {
throw new BadRequestException(`缺少必填签名报备资料:${missingSignature.map((field) => field.name).join('、')}`);
}
}
async validateDrainageReportValues(applicationId?: string, reportValues: Record<string, unknown> = {}) {
const fields = await this.applications.getApplicationReportFields(applicationId, 'drainage');
const missing = fields.filter((field) => field.required && !hasReportValue(reportValues[field.code]));
if (missing.length > 0) {
throw new BadRequestException(`引流信息缺少必填报备资料:${missing.map((field) => field.name).join('、')}`);
}
const fields = await this.applications.getApplicationReportFields(applicationId, 'drainage');
const missing = fields.filter((field) => field.required && !hasReportValue(reportValues[field.code]));
if (missing.length > 0) {
throw new BadRequestException(`引流信息缺少必填报备资料:${missing.map((field) => field.name).join('、')}`);
}
}
async activateDrainageReporting(itemId: string) {
const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } });
if (!item) throw new NotFoundException('Drainage info not found');
if (item.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
const applicationId = item.signature.applicationId ?? item.applicationId ?? undefined;
if (!applicationId) return;
const fields = (await this.applications.getApplicationReportFields(applicationId, 'drainage'))
.filter((field) => field.reportTypes.some((type) => type === 'drainage' || type === 'both'));
const channels = new Map(fields.flatMap((field) => field.channels).map((channel) => [channel.id, channel]));
const values = isRecord(item.reportValues) ? item.reportValues : {};
await this.prisma.$transaction(async (tx) => {
await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } });
for (const field of fields) {
const value = reportValueParts(values[field.code]);
for (const channel of field.channels) {
await tx.drainageReportMaterial.create({
data: { signatureId: item.signatureId, drainageItemId: item.id, channelId: channel.id, fieldCode: field.code, ...value },
});
}
}
const existingTasks = await tx.channelSignatureReportTask.findMany({ where: { drainageItemId: item.id, reportType: 'drainage' } });
const existingByChannel = new Map(existingTasks.map((task) => [task.channelId, task]));
for (const channel of channels.values()) {
const existing = existingByChannel.get(channel.id);
const task = existing
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: 'pending', reason: null } })
: await tx.channelSignatureReportTask.create({ data: { tenantId: item.tenantId, signatureId: item.signatureId, channelId: channel.id, reportType: 'drainage', drainageItemId: item.id, status: 'pending' } });
await tx.channelSignatureReportRecord.create({
data: { taskId: task.id, channelId: channel.id, action: existing ? 'audit_approved_reset' : 'audit_approved_create', statusBefore: existing?.status, statusAfter: 'pending', reason: '引流信息运营审核通过' },
const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } });
if (!item) throw new NotFoundException('Drainage info not found');
if (item.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
const applicationId = item.signature.applicationId ?? item.applicationId ?? undefined;
if (!applicationId) return;
const fields = (await this.applications.getApplicationReportFields(applicationId, 'drainage')).filter((field) =>
field.reportTypes.some((type) => type === 'drainage' || type === 'both'),
);
const channels = new Map(fields.flatMap((field) => field.channels).map((channel) => [channel.id, channel]));
const values = isRecord(item.reportValues) ? item.reportValues : {};
await this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${item.signatureId}, 910))`;
await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } });
for (const field of fields) {
const value = reportValueParts(values[field.code]);
for (const channel of field.channels) {
await tx.drainageReportMaterial.create({
data: {
signatureId: item.signatureId,
drainageItemId: item.id,
channelId: channel.id,
fieldCode: field.code,
...value,
},
});
}
for (const task of existingTasks.filter((current) => !channels.has(current.channelId) && current.status !== 'abandoned')) {
await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: 'abandoned', reason: '应用当前路由已不包含此通道' } });
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'route_removed', statusBefore: task.status, statusAfter: 'abandoned', reason: '应用当前路由已不包含此通道' } });
}
}
const existingTasks = await tx.channelSignatureReportTask.findMany({
where: { drainageItemId: item.id, reportType: 'drainage' },
});
}
const configuredChannels = await tx.smsChannel.findMany({
where: { id: { in: [...channels.keys()] }, status: { not: 'deleted' } },
});
const activeKeys = new Set<string>();
for (const channel of configuredChannels) {
for (const carrier of normalizeChannelCarriers(channel.carriers, channel.carrier)) {
const key = `${channel.id}:${carrier}`;
activeKeys.add(key);
const existing = existingTasks.find((task) => task.channelId === channel.id && task.carrier === carrier);
const task = existing
? await tx.channelSignatureReportTask.update({
where: { id: existing.id },
data: { status: 'pending', reason: null, approvedAt: null },
})
: await tx.channelSignatureReportTask.create({
data: {
tenantId: item.tenantId,
signatureId: item.signatureId,
channelId: channel.id,
carrier,
approvalScope: 'carrier_specific',
reportType: 'drainage',
drainageItemId: item.id,
status: 'pending',
},
});
await tx.channelSignatureReportRecord.create({
data: {
taskId: task.id,
channelId: channel.id,
action: existing ? 'audit_approved_reset' : 'audit_approved_create',
statusBefore: existing?.status,
statusAfter: 'pending',
reason: '引流信息运营审核通过,按运营商重新报备',
},
});
}
}
for (const task of existingTasks.filter(
(current) => !activeKeys.has(`${current.channelId}:${current.carrier}`) && current.status !== 'abandoned',
)) {
const reason = '资料版本更新或应用路由已不包含此通道运营商';
await tx.channelSignatureReportTask.update({
where: { id: task.id },
data: { status: 'abandoned', reason, approvedAt: null },
});
await tx.channelSignatureReportRecord.create({
data: {
taskId: task.id,
channelId: task.channelId,
action: 'route_removed',
statusBefore: task.status,
statusAfter: 'abandoned',
reason,
},
});
}
});
}
async suspendDrainageReporting(itemId: string, reason: string, statusAfter = 'waiting_review') {
await this.prisma.$transaction(async (tx) => {
const item = await tx.smsDrainageInfo.findUnique({ where: { id: itemId } });
if (!item) throw new NotFoundException('Drainage info not found');
await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } });
const tasks = await tx.channelSignatureReportTask.findMany({ where: { drainageItemId: item.id, reportType: 'drainage' } });
for (const task of tasks.filter((current) => current.status !== statusAfter)) {
await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: statusAfter, reason } });
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'audit_suspended', statusBefore: task.status, statusAfter, reason } });
}
await this.prisma.$transaction(async (tx) => {
const item = await tx.smsDrainageInfo.findUnique({ where: { id: itemId } });
if (!item) throw new NotFoundException('Drainage info not found');
await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } });
const tasks = await tx.channelSignatureReportTask.findMany({
where: { drainageItemId: item.id, reportType: 'drainage' },
});
}
for (const task of tasks.filter((current) => current.status !== statusAfter)) {
await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: statusAfter, reason } });
await tx.channelSignatureReportRecord.create({
data: {
taskId: task.id,
channelId: task.channelId,
action: 'audit_suspended',
statusBefore: task.status,
statusAfter,
reason,
},
});
}
});
}
}
+72 -109
View File
@@ -1,64 +1,15 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { selectDrainageReportTask } from '../common/drainage-report-task';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomInt, randomUUID } from 'node:crypto';
import { isIpAllowed } from '../common/ip-allowlist';
import { assertMoneyUnits } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
import { automaticDeliveryMode } from '../open-api/delivery-mode';
import type {
ApplicationListQuery,
CreateSignatureMaterialDto,
CreateSmsApplicationDto,
CreateSmsDrainageInfoDto,
CreateSmsSignatureDto,
CreateSmsSignatureOptions,
CreateSmsTemplateDto,
CreateSmsTemplateOptions,
DrainageInfoListQuery,
GatewayDownstreamConnectionEventDto,
ReplaceApplicationRouteRulesDto,
ReviewDto,
SignatureListQuery,
StatusChangeDto,
TemplateListQuery,
UpdateSmsApplicationDto,
UpdateSmsDrainageInfoDto,
UpdateSmsSignatureDto,
UpdateSmsTemplateDto,
} from './sms-config.contracts';
import {
APPLICATION_DISABLE_GRACE_MS,
DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS,
DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS,
UNRESOLVED_DOWNSTREAM_STATUSES,
type TemplateVariableInput,
estimateBillingUnits,
generateApplicationPassword,
getPositiveInteger,
getPositiveIntegerEnv,
hasReportValue,
inferTemplateVariables,
isRecord,
normalizeApplicationCmppStatus,
normalizeApplicationInterfaceType,
normalizeApplicationPassword,
normalizeApplicationQueuePriority,
normalizeCmppAccessNumberConfig,
normalizeSmsSignature,
parseGatewayDate,
reportValueParts,
startOfToday,
validateAndNormalizeTemplateVariables,
validateCompleteSmsSignature,
} from './sms-config.helpers';
import { isRecord, normalizeSmsSignature, validateCompleteSmsSignature } from './sms-config.helpers';
import { SmsReportValidationService } from './report-validation.service';
import { SmsAuditService } from './audit.service';
import { shanghaiDateRange } from '../common/shanghai-date-range';
@@ -202,6 +153,7 @@ export class SmsSignatureService {
.then((count) => count > 0);
return signatures.map((signature) => {
const { reportBatchItems: _reportBatchItems, ...signatureView } = signature;
void _reportBatchItems;
const legacyPayload = isRecord(signature.drainageInfo) ? signature.drainageInfo : {};
const applicationChannels = [
...new Map(
@@ -305,64 +257,70 @@ export class SmsSignatureService {
pendingReportBlockedReason,
drainageReportTargets: Object.fromEntries(
signature.drainageItems.map((drainageItem) => {
const drainageItemId = drainageItem.id;
const channels = routes
.filter((route) => route.applicationId === signature.applicationId && route.group)
.flatMap((route) => route.group!.items.map((item) => item.channel))
const tasks = (signature.reportTasks ?? []).filter(
(task) => task.reportType === 'drainage' && task.drainageItemId === drainageItem.id,
);
const targets = applicationChannels
.filter(
(channel) =>
channel.status !== 'deleted' &&
(hasCommonDrainageFields ||
channel.reportFields.some(
(field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType),
)),
hasCommonDrainageFields ||
channel.reportFields.some(
(field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType),
),
)
.flatMap((channel) =>
normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => {
const task = selectDrainageReportTask(tasks, channel.id, carrier);
return {
channel,
channelId: channel.id,
carrier,
status: task?.status ?? 'pending',
taskId: task?.id,
approvedAt: task?.approvedAt,
approvalScope: task?.carrier ? 'carrier_specific' : task ? 'legacy_channel' : 'carrier_specific',
};
}),
);
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);
return task ? [{ channel, channelId: channel.id, status: task.status, taskId: task.id }] : [];
}),
];
return [drainageItem.id, targets];
}),
),
drainageCarrierReportSummary: Object.fromEntries(
signature.drainageItems.map((drainageItem) => {
const drainageItemId = drainageItem.id;
const channels = routes
.filter((route) => route.applicationId === signature.applicationId && route.group)
.flatMap((route) => route.group!.items.map((item) => item.channel))
const tasks = (signature.reportTasks ?? []).filter(
(task) => task.reportType === 'drainage' && task.drainageItemId === drainageItem.id,
);
const targets = applicationChannels
.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) => {
const carrierTargets = targets.filter((channel) =>
normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier),
);
const statuses = carrierTargets.flatMap((channel) =>
taskByChannel.get(channel.id)?.status ? [taskByChannel.get(channel.id)!.status] : [],
);
return [carrier, summarizeReportStatuses(statuses)];
hasCommonDrainageFields ||
channel.reportFields.some(
(field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType),
),
)
.flatMap((channel) =>
normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => {
const task = selectDrainageReportTask(tasks, channel.id, carrier);
return {
channel,
channelId: channel.id,
carrier,
status: task?.status ?? 'pending',
taskId: task?.id,
approvedAt: task?.approvedAt,
approvalScope: task?.carrier ? 'carrier_specific' : task ? 'legacy_channel' : 'carrier_specific',
};
}),
);
return [
drainageItem.id,
Object.fromEntries(
['mobile', 'unicom', 'telecom'].map((carrier) => [
carrier,
summarizeReportStatuses(
targets.filter((target) => target.carrier === carrier).map((target) => target.status),
),
]),
),
];
}),
@@ -400,10 +358,14 @@ export class SmsSignatureService {
drainageReportTargets: _drainageReportTargets,
...summary
} = view;
void [_materials, _reportTasks, _reportTargets, _drainageReportTargets];
return {
...summary,
drainageInfo: {
links: drainageLinks.map(({ reportValues: _reportValues, ...link }) => link),
links: drainageLinks.map(({ reportValues: _reportValues, ...link }) => {
void _reportValues;
return link;
}),
},
};
});
@@ -508,7 +470,10 @@ export class SmsSignatureService {
for (const value of businessKeys) {
const match = typeof value === 'string' ? value.match(/:channel:([^:]+):carrier:([^:]+)$/) : null;
if (!match) continue;
for (const carrier of match[2].split(',').map((entry) => entry.trim()).filter(Boolean))
for (const carrier of match[2]
.split(',')
.map((entry) => entry.trim())
.filter(Boolean))
generatedTargets.add(`${match[1]}:${carrier}`);
}
}
@@ -534,7 +499,8 @@ export class SmsSignatureService {
candidate.approvalScope === 'legacy_channel',
);
if (task?.status === 'abandoned') continue;
if (generatedTargets.has(`${channel.id}:${carrier}`) || generatedTargets.has(`${channel.id}:legacy`)) continue;
if (generatedTargets.has(`${channel.id}:${carrier}`) || generatedTargets.has(`${channel.id}:legacy`))
continue;
detailTotal += 1;
hasPendingTarget = true;
}
@@ -552,7 +518,7 @@ export class SmsSignatureService {
async getSignatureReportTargets(id: string) {
const item = await this.getSignature(id);
return 'reportTargets' in item ? item.reportTargets ?? [] : [];
return 'reportTargets' in item ? (item.reportTargets ?? []) : [];
}
async getDrainageReportTargets(id: string) {
@@ -562,7 +528,7 @@ export class SmsSignatureService {
});
if (!drainage || drainage.auditStatus === 'deleted') throw new NotFoundException('Drainage info not found');
const signature = await this.getSignature(drainage.signatureId);
return 'drainageReportTargets' in signature ? signature.drainageReportTargets?.[id] ?? [] : [];
return 'drainageReportTargets' in signature ? (signature.drainageReportTargets?.[id] ?? []) : [];
}
listSignatureOptions(tenantId?: string) {
@@ -876,10 +842,7 @@ export class SmsSignatureService {
updated.applicationId ?? undefined,
drainageInfo,
);
if (
options.initialAuditStatus === 'approved' &&
(materialChanged || signature.auditStatus !== 'approved')
) {
if (options.initialAuditStatus === 'approved' && (materialChanged || signature.auditStatus !== 'approved')) {
await this.audit.createAuditRecord({
tenantId: signature.tenantId,
targetType: 'sms_signature',
File diff suppressed because it is too large Load Diff