919 lines
37 KiB
TypeScript
919 lines
37 KiB
TypeScript
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<string>();
|
|
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<string>();
|
|
for (const item of signature.reportBatchItems.filter(
|
|
(entry) => entry.materialVersion === signature.materialVersion,
|
|
)) {
|
|
const businessKeys = isRecord(item.snapshot) ? item.snapshot.businessKeys : undefined;
|
|
if (!Array.isArray(businessKeys)) continue;
|
|
for (const value of businessKeys) {
|
|
const match = typeof value === 'string' ? value.match(/:channel:([^:]+):carrier:([^:]+)$/) : null;
|
|
if (!match) continue;
|
|
for (const carrier of match[2].split(',').map((entry) => entry.trim()).filter(Boolean))
|
|
generatedTargets.add(`${match[1]}:${carrier}`);
|
|
}
|
|
}
|
|
const channels = [
|
|
...new Map(
|
|
routes
|
|
.filter((route) => route.applicationId === signature.applicationId && route.group?.status === 'active')
|
|
.flatMap((route) => route.group!.items.map((item) => item.channel))
|
|
.filter((channel) => channel.status === 'active')
|
|
.map((channel) => [channel.id, channel]),
|
|
).values(),
|
|
];
|
|
for (const channel of channels) {
|
|
for (const carrier of normalizeChannelCarriers(channel.carriers, channel.carrier)) {
|
|
const task =
|
|
signature.reportTasks.find(
|
|
(candidate) => candidate.channelId === channel.id && candidate.carrier === carrier,
|
|
) ??
|
|
signature.reportTasks.find(
|
|
(candidate) =>
|
|
candidate.channelId === channel.id &&
|
|
candidate.carrier === null &&
|
|
candidate.approvalScope === 'legacy_channel',
|
|
);
|
|
if (task?.status === 'abandoned') continue;
|
|
if (generatedTargets.has(`${channel.id}:${carrier}`) || generatedTargets.has(`${channel.id}:legacy`)) continue;
|
|
total += 1;
|
|
}
|
|
}
|
|
}
|
|
return total;
|
|
}
|
|
|
|
async getSignature(id: string) {
|
|
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<typeof summary, 'total'>] = 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;
|
|
}
|
|
}
|