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 { 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 { SmsReportValidationService } from './report-validation.service'; import { SmsAuditService } from './audit.service'; import { shanghaiDateRange } from '../common/shanghai-date-range'; import { summarizeReportStatuses } from '../common/report-status'; import { normalizeChannelCarriers } from '../channels/channels.helpers'; /** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ export class SmsSignatureService { constructor( private readonly prisma: PrismaService, private readonly reportValidation: SmsReportValidationService, private readonly audit: SmsAuditService, ) {} async listSignatures(queryOrTenantId?: string | SignatureListQuery, summaryOnly = false) { const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : (queryOrTenantId ?? {}); const signatures = await this.prisma.smsSignature.findMany({ where: { id: query.signatureId, tenantId: query.tenantId, auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined, updatedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo), drainageItems: query.drainageKeyword ? { some: { auditStatus: { not: 'deleted' }, OR: [ { siteName: { contains: query.drainageKeyword } }, { url: { contains: query.drainageKeyword } }, { remark: { contains: query.drainageKeyword } }, ], }, } : undefined, OR: query.keyword ? [ { name: { contains: query.keyword } }, { purpose: { contains: query.keyword } }, { tenant: { name: { contains: query.keyword } } }, { application: { name: { contains: query.keyword } } }, ] : undefined, }, select: { id: true, tenantId: true, applicationId: true, name: true, purpose: true, drainageInfo: true, auditStatus: true, reportStatus: true, rejectReason: true, materialVersion: true, pendingReport: true, reportChangedAt: true, createdAt: true, updatedAt: true, materials: !summaryOnly, tenant: { select: { id: true, name: true, code: true, status: true } }, application: { select: { id: true, tenantId: true, name: true, status: true } }, drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' }, select: { id: true, siteName: true, url: true, remark: true, reportValues: !summaryOnly, auditStatus: true, rejectReason: true, submittedAt: true, reviewedAt: true, createdAt: true, updatedAt: true, }, }, reportTasks: { select: { id: true, signatureId: true, channelId: true, carrier: true, status: true, approvedAt: true, approvalScope: true, reportType: true, drainageItemId: true, }, }, reportBatchItems: { where: { batch: { status: { in: ['completed', 'partial_failed'] } } }, select: { reportType: true, materialVersion: true, snapshot: true }, }, }, orderBy: { createdAt: 'desc' }, ...(query.page && query.pageSize ? { skip: (query.page - 1) * query.pageSize, take: query.pageSize, } : {}), }); const applicationIds = signatures .map((signature) => signature.applicationId) .filter((id): id is string => Boolean(id)); const routes = applicationIds.length ? await this.prisma.channelRouteRule.findMany({ where: { applicationId: { in: applicationIds }, status: 'active' }, select: { applicationId: true, group: { select: { status: true, items: { select: { channel: { select: { id: true, name: true, carrier: true, carriers: true, status: true, reportFields: { select: { status: true, reportType: 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 { reportBatchItems: _reportBatchItems, ...signatureView } = signature; const legacyPayload = isRecord(signature.drainageInfo) ? signature.drainageInfo : {}; const applicationChannels = [ ...new Map( routes .filter((route) => route.applicationId === signature.applicationId && route.group?.status === 'active') .flatMap((route) => route.group!.items.map((item) => item.channel)) .filter((channel) => channel.status === 'active') .map((channel) => [channel.id, channel]), ).values(), ]; const signatureTasks = (signature.reportTasks ?? []).filter((task) => task.reportType === 'signature'); const generatedTargets = new Set(); for (const item of (signature.reportBatchItems ?? []).filter( (entry) => entry.reportType === 'signature' && entry.materialVersion === signature.materialVersion, )) { const businessKeys = isRecord(item.snapshot) ? item.snapshot.businessKeys : undefined; if (!Array.isArray(businessKeys)) continue; for (const value of businessKeys) { const match = typeof value === 'string' ? value.match(/:channel:([^:]+):carrier:([^:]+)$/) : null; if (!match) continue; for (const carrier of match[2] .split(',') .map((entry) => entry.trim()) .filter(Boolean)) generatedTargets.add(`${match[1]}:${carrier}`); } } const pendingReportTargets = applicationChannels .flatMap((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => ({ channel, carrier })), ) .filter(({ channel, carrier }) => { const task = signatureTasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier) ?? signatureTasks.find( (candidate) => candidate.channelId === channel.id && candidate.carrier === null && candidate.approvalScope === 'legacy_channel', ); if (task?.status === 'abandoned') return false; return !generatedTargets.has(`${channel.id}:${carrier}`) && !generatedTargets.has(`${channel.id}:legacy`); }); const pendingReportBlockedReason = signature.auditStatus !== 'approved' ? '审核通过后计算' : !signature.applicationId ? '未绑定短信应用' : signature.application?.status !== 'active' ? '短信应用未启用' : applicationChannels.length === 0 ? '暂无有效报备通道' : !signature.pendingReport || pendingReportTargets.length === 0 ? '当前资料版本无需生成批次' : null; const drainageLinks = signature.drainageItems.map((item) => ({ id: item.id, siteName: item.siteName, url: item.url, remark: item.remark ?? '', reportValues: isRecord(item.reportValues) ? item.reportValues : {}, auditStatus: item.auditStatus, rejectReason: item.rejectReason, submittedAt: item.submittedAt.toISOString(), reviewedAt: item.reviewedAt?.toISOString(), createdAt: item.createdAt.toISOString(), updatedAt: item.updatedAt.toISOString(), })); const view = { ...signatureView, name: normalizeSmsSignature(signature.name), drainageInfo: { ...legacyPayload, links: drainageLinks }, reportTargets: (() => { const tasks = signatureTasks; return applicationChannels.flatMap((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => { const task = tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier) ?? tasks.find( (candidate) => candidate.channelId === channel.id && candidate.carrier === null && candidate.approvalScope === 'legacy_channel', ); return { channel, channelId: channel.id, carrier, status: task?.status ?? 'pending', taskId: task?.id, approvedAt: task?.approvedAt, approvalScope: task?.approvalScope ?? 'carrier_specific', }; }), ); })(), pendingReportDetailCount: signature.auditStatus === 'approved' && signature.pendingReport ? pendingReportTargets.length : 0, pendingReportMaterialVersion: signature.auditStatus === 'approved' && signature.pendingReport ? signature.materialVersion : null, 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)) .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); return task ? [{ channel, channelId: channel.id, status: task.status, taskId: task.id }] : []; }), ]; }), ), 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)) .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)]; }), ), ]; }), ), carrierReportSummary: Object.fromEntries( ['mobile', 'unicom', 'telecom'].map((carrier) => { const configured = routes .filter((route) => route.applicationId === signature.applicationId && route.group) .flatMap((route) => route.group!.items.map((item) => item.channel)) .filter( (channel) => channel.status !== 'deleted' && normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier), ); const targets = [...new Map(configured.map((channel) => [channel.id, channel])).values()]; const signatureTasks = (signature.reportTasks ?? []).filter((task) => task.reportType === 'signature'); const statuses = targets.map( (channel) => signatureTasks.find((task) => task.channelId === channel.id && task.carrier === carrier)?.status ?? signatureTasks.find( (task) => task.channelId === channel.id && task.carrier === null && task.approvalScope === 'legacy_channel', )?.status ?? 'pending', ); return [carrier, summarizeReportStatuses(statuses)]; }), ), }; if (!summaryOnly) return view; const { materials: _materials, reportTasks: _reportTasks, reportTargets: _reportTargets, drainageReportTargets: _drainageReportTargets, ...summary } = view; return { ...summary, drainageInfo: { links: drainageLinks.map(({ reportValues: _reportValues, ...link }) => link), }, }; }); } async listSignaturesPage(query: SignatureListQuery) { const page = Math.max(1, Math.floor(Number(query.page) || 1)); const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); const where: Prisma.SmsSignatureWhereInput = { tenantId: query.tenantId, auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined, updatedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo), drainageItems: query.drainageKeyword ? { some: { auditStatus: { not: 'deleted' }, OR: [ { siteName: { contains: query.drainageKeyword } }, { url: { contains: query.drainageKeyword } }, { remark: { contains: query.drainageKeyword } }, ], }, } : undefined, OR: query.keyword ? [ { name: { contains: query.keyword } }, { purpose: { contains: query.keyword } }, { tenant: { name: { contains: query.keyword } } }, { application: { name: { contains: query.keyword } } }, ] : undefined, }; const [items, total, pendingReportDetailTotal] = await Promise.all([ this.listSignatures({ ...query, page, pageSize }, true), this.prisma.smsSignature.count({ where }), this.countPendingReportDetails(where), ]); return { items, total, page, pageSize, pendingReportDetailTotal }; } private async countPendingReportDetails(where: Prisma.SmsSignatureWhereInput) { const signatures = await this.prisma.smsSignature.findMany({ where: { AND: [where, { auditStatus: 'approved', pendingReport: true }] }, select: { id: true, applicationId: true, materialVersion: true, application: { select: { status: true } }, reportTasks: { where: { reportType: 'signature' }, select: { channelId: true, carrier: true, status: true, approvalScope: true }, }, reportBatchItems: { where: { reportType: 'signature', batch: { status: { in: ['completed', 'partial_failed'] } } }, select: { materialVersion: true, snapshot: true }, }, }, }); const applicationIds = [ ...new Set(signatures.map((signature) => signature.applicationId).filter((id): id is string => Boolean(id))), ]; const routes = applicationIds.length ? await this.prisma.channelRouteRule.findMany({ where: { applicationId: { in: applicationIds }, status: 'active' }, select: { applicationId: true, group: { select: { status: true, items: { select: { channel: { select: { id: true, carrier: true, carriers: true, status: true } }, }, }, }, }, }, }) : []; let total = 0; for (const signature of signatures) { if (!signature.applicationId || signature.application?.status !== 'active') continue; const generatedTargets = new Set(); for (const item of signature.reportBatchItems.filter( (entry) => entry.materialVersion === signature.materialVersion, )) { const businessKeys = isRecord(item.snapshot) ? item.snapshot.businessKeys : undefined; if (!Array.isArray(businessKeys)) continue; for (const value of businessKeys) { const match = typeof value === 'string' ? value.match(/:channel:([^:]+):carrier:([^:]+)$/) : null; if (!match) continue; for (const carrier of match[2].split(',').map((entry) => entry.trim()).filter(Boolean)) generatedTargets.add(`${match[1]}:${carrier}`); } } const channels = [ ...new Map( routes .filter((route) => route.applicationId === signature.applicationId && route.group?.status === 'active') .flatMap((route) => route.group!.items.map((item) => item.channel)) .filter((channel) => channel.status === 'active') .map((channel) => [channel.id, channel]), ).values(), ]; for (const channel of channels) { for (const carrier of normalizeChannelCarriers(channel.carriers, channel.carrier)) { const task = signature.reportTasks.find( (candidate) => candidate.channelId === channel.id && candidate.carrier === carrier, ) ?? signature.reportTasks.find( (candidate) => candidate.channelId === channel.id && candidate.carrier === null && candidate.approvalScope === 'legacy_channel', ); if (task?.status === 'abandoned') continue; if (generatedTargets.has(`${channel.id}:${carrier}`) || generatedTargets.has(`${channel.id}:legacy`)) continue; total += 1; } } } return total; } async getSignature(id: string) { const [item] = await this.listSignatures({ signatureId: id }); if (!item || item.auditStatus === 'deleted') throw new NotFoundException('Signature not found'); return item; } async getSignatureReportTargets(id: string) { const item = await this.getSignature(id); return 'reportTargets' in item ? item.reportTargets ?? [] : []; } async getDrainageReportTargets(id: string) { const drainage = await this.prisma.smsDrainageInfo.findUnique({ where: { id }, select: { id: true, signatureId: true, auditStatus: true }, }); 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] ?? [] : []; } listSignatureOptions(tenantId?: string) { return this.prisma.smsSignature.findMany({ where: { tenantId, auditStatus: { not: 'deleted' } }, select: { id: true, tenantId: true, applicationId: true, name: true, auditStatus: true }, orderBy: [{ name: 'asc' }, { id: 'asc' }], }); } async listClientSignatures( tenantId?: string, signatureId?: string, query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}, ) { const signatures = await this.prisma.smsSignature.findMany({ where: { id: signatureId, tenantId, applicationId: query.applicationId, auditStatus: { notIn: ['deleted', 'disabled'], ...(query.status && query.status !== 'all' ? { equals: query.status } : {}), }, OR: query.keyword?.trim() ? [ { name: { contains: query.keyword.trim() } }, { purpose: { contains: query.keyword.trim() } }, { application: { name: { contains: query.keyword.trim() } } }, ] : undefined, }, select: { id: true, tenantId: true, applicationId: true, name: true, purpose: true, auditStatus: true, reportStatus: true, pendingReport: true, reportChangedAt: true, rejectReason: true, drainageInfo: true, createdAt: true, updatedAt: true, application: { select: { id: true, name: true, status: true } }, materials: { select: { id: true, fileObjectId: true, materialType: true, title: true, description: true, createdAt: true }, }, drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' }, select: { id: true, siteName: true, url: true, remark: true, reportValues: true, auditStatus: true, rejectReason: true, submittedAt: true, reviewedAt: true, createdAt: true, updatedAt: true, }, }, reportTasks: { select: { channelId: true, carrier: true, status: true, approvalScope: true, reportType: true, drainageItemId: true, }, }, _count: { select: { reportMaterials: true } }, }, orderBy: { updatedAt: 'desc' }, skip: query.page && query.pageSize ? (query.page - 1) * query.pageSize : undefined, take: query.pageSize, }); const applicationIds = signatures .map((signature) => signature.applicationId) .filter((id): id is string => Boolean(id)); const routes = applicationIds.length ? await this.prisma.channelRouteRule.findMany({ where: { applicationId: { in: applicationIds }, status: 'active' }, include: { group: { include: { items: { include: { channel: { 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 stored = isRecord(signature.drainageInfo) ? signature.drainageInfo : {}; const applicationChannels = [ ...new Map( routes .filter((route) => route.applicationId === signature.applicationId && route.group) .flatMap((route) => route.group!.items.map((item) => item.channel)) .filter((channel) => channel.status !== 'deleted') .map((channel) => [channel.id, channel]), ).values(), ]; const signatureTasks = signature.reportTasks.filter((task) => task.reportType === 'signature'); const carrierReportSummary = Object.fromEntries( ['mobile', 'unicom', 'telecom'].map((carrier) => { const targets = applicationChannels.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier), ); const statuses = targets.map( (channel) => signatureTasks.find((task) => task.channelId === channel.id && task.carrier === carrier)?.status ?? signatureTasks.find( (task) => task.channelId === channel.id && task.carrier === null && task.approvalScope === 'legacy_channel', )?.status ?? 'pending', ); return [carrier, summarizeReportStatuses(statuses)]; }), ); const drainageCarrierReportSummary = Object.fromEntries( signature.drainageItems.map((item) => { const targets = applicationChannels.filter( (channel) => hasCommonDrainageFields || channel.reportFields.some( (field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType), ), ); const tasks = signature.reportTasks.filter( (task) => task.reportType === 'drainage' && task.drainageItemId === item.id, ); return [ item.id, Object.fromEntries( ['mobile', 'unicom', 'telecom'].map((carrier) => { const carrierTargets = targets.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier), ); const statuses = carrierTargets.map( (channel) => tasks.find((task) => task.channelId === channel.id)?.status ?? 'pending', ); return [carrier, summarizeReportStatuses(statuses)]; }), ), ]; }), ); return { id: signature.id, tenantId: signature.tenantId, applicationId: signature.applicationId, name: normalizeSmsSignature(signature.name), purpose: signature.purpose, auditStatus: signature.auditStatus, reportStatus: signature.reportStatus, pendingReport: signature.pendingReport, reportChangedAt: signature.reportChangedAt, rejectReason: signature.rejectReason, createdAt: signature.createdAt, updatedAt: signature.updatedAt, application: signature.application, materials: signature.materials, submittedMaterialCount: signature.materials.length + signature._count.reportMaterials, carrierReportSummary, drainageCarrierReportSummary, reportValues: isRecord(stored.signatureReportValues) ? stored.signatureReportValues : {}, drainageInfo: { links: signature.drainageItems.map((item) => ({ ...item, reportValues: isRecord(item.reportValues) ? item.reportValues : {}, })), }, }; }); } async getClientSignatureView(signatureId: string, tenantId?: string) { const [signature] = await this.listClientSignatures(tenantId, signatureId); if (!signature) throw new NotFoundException('Signature not found'); return signature; } async getClientSignatureWorkspace( tenantId?: string, query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}, ) { const page = Math.max(1, Math.floor(Number(query.page) || 1)); const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); const filteredWhere: Prisma.SmsSignatureWhereInput = { tenantId, applicationId: query.applicationId, auditStatus: { notIn: ['deleted', 'disabled'], ...(query.status && query.status !== 'all' ? { equals: query.status } : {}), }, OR: query.keyword?.trim() ? [ { name: { contains: query.keyword.trim() } }, { purpose: { contains: query.keyword.trim() } }, { application: { name: { contains: query.keyword.trim() } } }, ] : undefined, }; const [items, total, statusCounts] = await Promise.all([ this.listClientSignatures(tenantId, undefined, { ...query, page, pageSize }), this.prisma.smsSignature.count({ where: filteredWhere }), this.prisma.smsSignature.groupBy({ by: ['auditStatus'], where: { tenantId, auditStatus: { notIn: ['deleted', 'disabled'] } }, _count: { _all: true }, }), ]); const summary = { total: 0, pending: 0, approved: 0, rejected: 0, draft: 0 }; for (const item of statusCounts) { const count = item._count._all; summary.total += count; if (item.auditStatus in summary && item.auditStatus !== 'total') { summary[item.auditStatus as keyof Omit] = count; } } return { items, summary, total, page, pageSize }; } async createSignature(data: CreateSmsSignatureDto, options: CreateSmsSignatureOptions = {}) { await this.reportValidation.validateSignatureReportValues(data.applicationId, data.drainageInfo); const drainageInfo = await this.reportValidation.withReportRequirementSnapshot( data.applicationId, data.drainageInfo, ); const name = validateCompleteSmsSignature(data.name); const signature = await this.prisma.smsSignature.create({ data: { tenantId: data.tenantId, applicationId: data.applicationId, name, purpose: data.purpose, auditStatus: options.initialAuditStatus, drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, }, }); await this.reportValidation.syncSignatureReportValues(signature.id, data.applicationId, drainageInfo); if (options.initialAuditStatus) { await this.audit.createAuditRecord({ tenantId: signature.tenantId, targetType: 'sms_signature', targetId: signature.id, action: 'admin_create_approved', statusAfter: options.initialAuditStatus, reason: '运营端新建签名自动审核通过', }); } return { ...signature, reportMaterialChanged: true, reportPoolAvailableAfter: signature.auditStatus === 'approved' ? ('immediate' as const) : ('approval' as const), }; } async updateSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) { const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); if (!signature || (tenantId && signature.tenantId !== tenantId)) { throw new NotFoundException('Signature not found'); } await this.reportValidation.validateSignatureReportValues( data.applicationId ?? signature.applicationId ?? undefined, data.drainageInfo, ); const applicationId = data.applicationId ?? signature.applicationId ?? undefined; const drainageInfo = data.drainageInfo ? await this.reportValidation.withReportRequirementSnapshot(applicationId, data.drainageInfo) : undefined; const name = data.name === undefined ? undefined : validateCompleteSmsSignature(data.name); const materialChanged = (data.applicationId !== undefined && data.applicationId !== signature.applicationId) || (name !== undefined && name !== normalizeSmsSignature(signature.name)) || (data.purpose !== undefined && data.purpose !== signature.purpose) || (data.drainageInfo !== undefined && JSON.stringify(drainageInfo ?? null) !== JSON.stringify(signature.drainageInfo ?? null)); const auditStatus = materialChanged && signature.auditStatus === 'approved' ? 'pending' : data.auditStatus; const updated = await this.prisma.smsSignature.update({ where: { id: signatureId }, data: { applicationId: data.applicationId, name, purpose: data.purpose, auditStatus, rejectReason: auditStatus === 'pending' ? null : undefined, drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, materialVersion: materialChanged ? { increment: 1 } : undefined, pendingReport: materialChanged ? true : undefined, reportChangedAt: materialChanged ? new Date() : undefined, }, include: { materials: true, tenant: true, application: true }, }); await this.reportValidation.syncSignatureReportValues( signatureId, updated.applicationId ?? undefined, drainageInfo, ); return { ...updated, reportMaterialChanged: materialChanged, reportPoolAvailableAfter: updated.auditStatus === 'approved' ? ('immediate' as const) : ('approval' as const), }; } async updateClientSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) { const current = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); if (!current || (tenantId && current.tenantId !== tenantId)) throw new NotFoundException('Signature not found'); if (!['draft', 'rejected', 'approved'].includes(current.auditStatus)) { throw new BadRequestException('当前审核状态不允许修改签名'); } const updated = await this.updateSignature(signatureId, { ...data, auditStatus: 'pending' }, tenantId); await this.audit.createAuditRecord({ tenantId: current.tenantId, targetType: 'sms_signature', targetId: signatureId, action: 'client_update_submit', statusBefore: current.auditStatus, statusAfter: 'pending', }); return updated; } createSignatureMaterial(data: CreateSignatureMaterialDto) { return this.prisma.signatureMaterial.create({ data: { signatureId: data.signatureId, fileObjectId: data.fileObjectId, materialType: data.materialType, title: data.title, description: data.description, }, }); } async submitSignature(signatureId: string, tenantId?: string) { const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); if (!signature || signature.auditStatus === 'deleted' || (tenantId && signature.tenantId !== tenantId)) { throw new NotFoundException('Signature not found'); } const updated = await this.prisma.smsSignature.update({ where: { id: signatureId }, data: { auditStatus: 'pending', rejectReason: null }, }); await this.audit.createAuditRecord({ tenantId: signature.tenantId, targetType: 'sms_signature', targetId: signatureId, action: 'submit', statusBefore: signature.auditStatus, statusAfter: 'pending', }); return updated; } }