feat: complete drainage review and admin search workflows
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
CREATE TABLE "SmsDrainageInfo" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"signatureId" TEXT NOT NULL,
|
||||
"applicationId" TEXT,
|
||||
"siteName" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"remark" TEXT,
|
||||
"reportValues" JSONB,
|
||||
"auditStatus" TEXT NOT NULL DEFAULT 'pending',
|
||||
"rejectReason" TEXT,
|
||||
"submittedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"reviewedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "SmsDrainageInfo_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TEMP TABLE drainage_backfill_map AS
|
||||
SELECT
|
||||
signature."id" AS "signatureId",
|
||||
COALESCE(NULLIF(item.value->>'id', ''), item.ordinality::text) AS "oldId",
|
||||
'drain_' || md5(signature."id" || ':' || COALESCE(NULLIF(item.value->>'id', ''), item.ordinality::text)) AS "newId",
|
||||
signature."tenantId" AS "tenantId",
|
||||
signature."applicationId" AS "applicationId",
|
||||
item.value AS item
|
||||
FROM "SmsSignature" signature
|
||||
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(signature."drainageInfo"->'links', '[]'::jsonb)) WITH ORDINALITY AS item(value, ordinality);
|
||||
|
||||
INSERT INTO "SmsDrainageInfo" (
|
||||
"id", "tenantId", "signatureId", "applicationId", "siteName", "url", "remark", "reportValues",
|
||||
"auditStatus", "submittedAt", "createdAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
"newId",
|
||||
"tenantId",
|
||||
"signatureId",
|
||||
"applicationId",
|
||||
COALESCE(NULLIF(item->>'siteName', ''), '未命名站点'),
|
||||
COALESCE(item->>'url', ''),
|
||||
NULLIF(item->>'remark', ''),
|
||||
COALESCE(item->'reportValues', '{}'::jsonb),
|
||||
CASE WHEN item->>'auditStatus' IN ('pending', 'approved', 'rejected') THEN item->>'auditStatus' ELSE 'pending' END,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
FROM drainage_backfill_map;
|
||||
|
||||
UPDATE "ChannelSignatureReportTask" task
|
||||
SET "drainageItemId" = map."newId"
|
||||
FROM drainage_backfill_map map
|
||||
WHERE task."signatureId" = map."signatureId"
|
||||
AND task."reportType" = 'drainage'
|
||||
AND task."drainageItemId" = map."oldId";
|
||||
|
||||
UPDATE "DrainageReportMaterial" material
|
||||
SET "drainageItemId" = map."newId"
|
||||
FROM drainage_backfill_map map
|
||||
WHERE material."signatureId" = map."signatureId"
|
||||
AND material."drainageItemId" = map."oldId";
|
||||
|
||||
DROP TABLE drainage_backfill_map;
|
||||
|
||||
CREATE INDEX "SmsDrainageInfo_tenantId_auditStatus_updatedAt_idx"
|
||||
ON "SmsDrainageInfo"("tenantId", "auditStatus", "updatedAt");
|
||||
CREATE INDEX "SmsDrainageInfo_signatureId_auditStatus_idx"
|
||||
ON "SmsDrainageInfo"("signatureId", "auditStatus");
|
||||
CREATE INDEX "SmsDrainageInfo_applicationId_idx"
|
||||
ON "SmsDrainageInfo"("applicationId");
|
||||
|
||||
ALTER TABLE "SmsDrainageInfo" ADD CONSTRAINT "SmsDrainageInfo_tenantId_fkey"
|
||||
FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "SmsDrainageInfo" ADD CONSTRAINT "SmsDrainageInfo_signatureId_fkey"
|
||||
FOREIGN KEY ("signatureId") REFERENCES "SmsSignature"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "SmsDrainageInfo" ADD CONSTRAINT "SmsDrainageInfo_applicationId_fkey"
|
||||
FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "ChannelSignatureReportTask" ADD CONSTRAINT "ChannelSignatureReportTask_drainageItemId_fkey"
|
||||
FOREIGN KEY ("drainageItemId") REFERENCES "SmsDrainageInfo"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "ChannelSignatureReportRecord"
|
||||
ADD COLUMN "sourceEntry" TEXT NOT NULL DEFAULT 'system';
|
||||
@@ -26,6 +26,7 @@ model Tenant {
|
||||
smsBillingRecords SmsBillingRecord[]
|
||||
smsApplications SmsApplication[]
|
||||
smsSignatures SmsSignature[]
|
||||
smsDrainageInfos SmsDrainageInfo[]
|
||||
smsTemplates SmsTemplate[]
|
||||
auditRecords AuditRecord[]
|
||||
riskRules RiskRule[]
|
||||
@@ -366,6 +367,7 @@ model SmsApplication {
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
ipAllowlist SmsApplicationIpAllowlist[]
|
||||
signatures SmsSignature[]
|
||||
drainageInfos SmsDrainageInfo[]
|
||||
templates SmsTemplate[]
|
||||
enterpriseBlacklists EnterpriseBlacklist[]
|
||||
sendTasks SmsSendTask[]
|
||||
@@ -413,6 +415,7 @@ model SmsSignature {
|
||||
templates SmsTemplate[]
|
||||
reportMaterials SignatureReportMaterial[]
|
||||
drainageReportMaterials DrainageReportMaterial[]
|
||||
drainageItems SmsDrainageInfo[]
|
||||
reportTasks ChannelSignatureReportTask[]
|
||||
messageRecords SmsMessageRecord[]
|
||||
|
||||
@@ -420,6 +423,32 @@ model SmsSignature {
|
||||
@@index([tenantId, reportStatus])
|
||||
}
|
||||
|
||||
model SmsDrainageInfo {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
signatureId String
|
||||
applicationId String?
|
||||
siteName String
|
||||
url String
|
||||
remark String?
|
||||
reportValues Json?
|
||||
auditStatus String @default("pending")
|
||||
rejectReason String?
|
||||
submittedAt DateTime @default(now())
|
||||
reviewedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
signature SmsSignature @relation(fields: [signatureId], references: [id], onDelete: Cascade)
|
||||
application SmsApplication? @relation(fields: [applicationId], references: [id])
|
||||
reportTasks ChannelSignatureReportTask[]
|
||||
|
||||
@@index([tenantId, auditStatus, updatedAt])
|
||||
@@index([signatureId, auditStatus])
|
||||
@@index([applicationId])
|
||||
}
|
||||
|
||||
model SignatureMaterial {
|
||||
id String @id @default(cuid())
|
||||
signatureId String
|
||||
@@ -730,6 +759,7 @@ model ChannelSignatureReportTask {
|
||||
|
||||
signature SmsSignature @relation(fields: [signatureId], references: [id])
|
||||
channel SmsChannel @relation(fields: [channelId], references: [id])
|
||||
drainageInfo SmsDrainageInfo? @relation(fields: [drainageItemId], references: [id])
|
||||
records ChannelSignatureReportRecord[]
|
||||
exportFiles ReportExportFile[]
|
||||
receiptImports ReportReceiptImport[]
|
||||
@@ -749,6 +779,7 @@ model ChannelSignatureReportRecord {
|
||||
statusAfter String
|
||||
reason String?
|
||||
operatorId String?
|
||||
sourceEntry String @default("system")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
task ChannelSignatureReportTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -146,8 +146,8 @@ export class ChannelsController {
|
||||
}
|
||||
|
||||
@Get('report-tasks')
|
||||
listReportTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('channelId') channelId?: string) {
|
||||
return this.channels.listReportTasks(tenantId, status, channelId);
|
||||
listReportTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('channelId') channelId?: string, @Query('reportType') reportType?: string) {
|
||||
return this.channels.listReportTasks(tenantId, status, channelId, reportType);
|
||||
}
|
||||
|
||||
@Post('report-tasks/generate')
|
||||
|
||||
@@ -108,6 +108,9 @@ function createPrismaMock() {
|
||||
findUnique: jest.fn().mockResolvedValue(reportTask),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...reportTask, ...data })),
|
||||
},
|
||||
smsDrainageInfo: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }),
|
||||
},
|
||||
channelSignatureReportRecord: {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'record-1', ...data })),
|
||||
findMany: jest.fn(),
|
||||
@@ -183,8 +186,8 @@ describe('ChannelsService', () => {
|
||||
await service.listReportTasks(undefined, undefined, 'channel-1');
|
||||
|
||||
expect(prisma.channelSignatureReportTask.findMany).toHaveBeenCalledWith({
|
||||
where: { tenantId: undefined, status: undefined, channelId: 'channel-1' },
|
||||
include: { signature: true, channel: true },
|
||||
where: { tenantId: undefined, status: undefined, channelId: 'channel-1', reportType: undefined },
|
||||
include: { signature: true, channel: true, drainageInfo: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
});
|
||||
@@ -197,6 +200,7 @@ describe('ChannelsService', () => {
|
||||
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
|
||||
},
|
||||
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', status: 'active' }) },
|
||||
smsDrainageInfo: { findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }) },
|
||||
channelSignatureReportTask: {
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'reporting' }),
|
||||
update: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }),
|
||||
@@ -209,10 +213,10 @@ describe('ChannelsService', () => {
|
||||
prisma.$transaction.mockImplementation((callback) => callback(tx));
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await expect(service.changeReportTaskStatuses({ items: [{ signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }], reason: '运营商确认' })).resolves.toEqual([
|
||||
await expect(service.changeReportTaskStatuses({ items: [{ signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }], reason: '运营商确认', sourceEntry: 'enterprise_signature' })).resolves.toEqual([
|
||||
expect.objectContaining({ signatureId: 'sig-1', reportStatus: 'approved' }),
|
||||
]);
|
||||
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved' }) });
|
||||
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved', sourceEntry: 'enterprise_signature' }) });
|
||||
expect(tx.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'approved' } });
|
||||
});
|
||||
|
||||
@@ -224,6 +228,7 @@ describe('ChannelsService', () => {
|
||||
update: jest.fn(),
|
||||
},
|
||||
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', status: 'active' }) },
|
||||
smsDrainageInfo: { findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }) },
|
||||
channelSignatureReportTask: {
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'drainage-task-1', signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'reporting' }),
|
||||
update: jest.fn().mockResolvedValue({ id: 'drainage-task-1', status: 'approved' }),
|
||||
|
||||
@@ -107,6 +107,7 @@ export interface ChangeReportTaskStatusesDto {
|
||||
items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>;
|
||||
reason?: string;
|
||||
operatorId?: string;
|
||||
sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report';
|
||||
}
|
||||
|
||||
export interface CreateReportExportDto {
|
||||
@@ -912,6 +913,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
async createReportField(data: CreateReportFieldDto) {
|
||||
if (!data.drainageFieldId) throw new BadRequestException('drainageFieldId is required');
|
||||
const field = await this.prisma.drainageField.findUnique({ where: { id: data.drainageFieldId } });
|
||||
if (!field || field.status !== 'active') {
|
||||
throw new BadRequestException('报备字段库字段不存在或已停用');
|
||||
@@ -966,10 +968,10 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
listReportTasks(tenantId?: string, status?: string, channelId?: string) {
|
||||
listReportTasks(tenantId?: string, status?: string, channelId?: string, reportType?: string) {
|
||||
return this.prisma.channelSignatureReportTask.findMany({
|
||||
where: { tenantId, status, channelId },
|
||||
include: { signature: true, channel: true },
|
||||
where: { tenantId, status, channelId, reportType },
|
||||
include: { signature: true, channel: true, drainageInfo: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
@@ -977,13 +979,19 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
async createReportTask(data: CreateReportTaskDto) {
|
||||
const reportType = data.reportType ?? 'signature';
|
||||
if (reportType === 'drainage' && !data.drainageItemId) throw new BadRequestException('drainageItemId is required');
|
||||
if (reportType === 'drainage') {
|
||||
const drainageInfo = await this.prisma.smsDrainageInfo.findUnique({ where: { id: data.drainageItemId! } });
|
||||
if (!drainageInfo || drainageInfo.signatureId !== data.signatureId) throw new NotFoundException('Drainage info not found');
|
||||
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
|
||||
throw new BadRequestException('引流信息通道报备任务由运营审核通过后按应用路由自动生成');
|
||||
}
|
||||
const task = await this.prisma.channelSignatureReportTask.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
signatureId: data.signatureId,
|
||||
channelId: data.channelId,
|
||||
reportType,
|
||||
drainageItemId: reportType === 'drainage' ? data.drainageItemId : undefined,
|
||||
drainageItemId: undefined,
|
||||
createdById: data.createdById,
|
||||
status: 'pending',
|
||||
},
|
||||
@@ -998,6 +1006,10 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
for (const item of data.items) {
|
||||
if (!allowed.has(item.status)) throw new BadRequestException('unsupported report task status');
|
||||
}
|
||||
const sourceEntry = data.sourceEntry ?? 'report_task';
|
||||
if (!['enterprise_signature', 'report_task', 'channel_report'].includes(sourceEntry)) {
|
||||
throw new BadRequestException('unsupported report task source entry');
|
||||
}
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const signatureIds = [...new Set(data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId))];
|
||||
const drainageResults: Array<{ signatureId: string; reportType: 'drainage'; drainageItemId: string; channelId: string; status: string }> = [];
|
||||
@@ -1007,11 +1019,17 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
const signature = await tx.smsSignature.findUnique({ where: { id: item.signatureId } });
|
||||
const channel = await tx.smsChannel.findUnique({ where: { id: item.channelId } });
|
||||
if (!signature || !channel) throw new NotFoundException('Signature or channel not found');
|
||||
if (reportType === 'drainage') {
|
||||
const drainageInfo = await tx.smsDrainageInfo.findUnique({ where: { id: item.drainageItemId! } });
|
||||
if (!drainageInfo || drainageInfo.signatureId !== item.signatureId) throw new NotFoundException('Drainage info not found');
|
||||
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能修改通道报备状态');
|
||||
}
|
||||
const existing = await tx.channelSignatureReportTask.findFirst({ where: { signatureId: item.signatureId, channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : null } });
|
||||
if (reportType === 'drainage' && !existing) throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核');
|
||||
const task = existing
|
||||
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: item.status, reason: data.reason } })
|
||||
: await tx.channelSignatureReportTask.create({ data: { tenantId: signature.tenantId, signatureId: item.signatureId, channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : undefined, status: item.status, reason: data.reason, createdById: data.operatorId } });
|
||||
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: item.channelId, action: 'manual_status_change', statusBefore: existing?.status, statusAfter: item.status, reason: data.reason, operatorId: data.operatorId } });
|
||||
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: item.channelId, action: 'manual_status_change', statusBefore: existing?.status, statusAfter: item.status, reason: data.reason, operatorId: data.operatorId, sourceEntry } });
|
||||
if (reportType === 'drainage') drainageResults.push({ signatureId: item.signatureId, reportType, drainageItemId: item.drainageItemId!, channelId: item.channelId, status: item.status });
|
||||
}
|
||||
const summaries = [];
|
||||
@@ -1086,15 +1104,19 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
listReportRecords(taskId?: string, channelId?: string) {
|
||||
return this.prisma.channelSignatureReportRecord.findMany({
|
||||
where: { taskId, channelId },
|
||||
include: { channel: true, task: { include: { signature: true, drainageInfo: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
private async getReportTaskOrThrow(taskId: string) {
|
||||
const task = await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId } });
|
||||
const task = await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId }, include: { drainageInfo: true } });
|
||||
if (!task) {
|
||||
throw new NotFoundException('Report task not found');
|
||||
}
|
||||
if (task.reportType === 'drainage' && task.drainageInfo?.auditStatus !== 'approved') {
|
||||
throw new BadRequestException('引流信息审核通过后才能处理通道报备任务');
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,8 +80,8 @@ export class DictionariesController {
|
||||
}
|
||||
|
||||
@Get('blacklists/enterprise')
|
||||
listEnterpriseBlacklist(@Query('tenantId') tenantId?: string, @Query('applicationId') applicationId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string) {
|
||||
return this.dictionaries.listEnterpriseBlacklist({ tenantId, applicationId, keyword, status });
|
||||
listEnterpriseBlacklist(@Query('tenantId') tenantId?: string, @Query('applicationId') applicationId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('phoneNumber') phoneNumber?: string, @Query('reasonKeyword') reasonKeyword?: string) {
|
||||
return this.dictionaries.listEnterpriseBlacklist({ tenantId, applicationId, keyword, status, enterpriseKeyword, applicationKeyword, phoneNumber, reasonKeyword });
|
||||
}
|
||||
|
||||
@Post('blacklists/enterprise')
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('DictionariesService', () => {
|
||||
|
||||
await service.listSensitiveWords({ keyword: '贷款', status: 'active' });
|
||||
await service.listGlobalBlacklist({ keyword: '138', status: 'active' });
|
||||
await service.listEnterpriseBlacklist({ tenantId: 'tenant-1', applicationId: 'app-1', keyword: '投诉', status: 'active' });
|
||||
await service.listEnterpriseBlacklist({ enterpriseKeyword: '租户', applicationKeyword: '应用', phoneNumber: '138', reasonKeyword: '投诉', status: 'active' });
|
||||
|
||||
expect(prisma.sensitiveWord.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ status: 'active', OR: expect.any(Array) }),
|
||||
@@ -82,7 +82,7 @@ describe('DictionariesService', () => {
|
||||
where: expect.objectContaining({ status: 'active', OR: expect.any(Array) }),
|
||||
}));
|
||||
expect(prisma.enterpriseBlacklist.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', status: 'active', OR: expect.any(Array) }),
|
||||
where: expect.objectContaining({ status: 'active', tenant: { name: { contains: '租户' } }, application: { name: { contains: '应用' } }, phoneNumber: { contains: '138' }, reason: { contains: '投诉' } }),
|
||||
include: { tenant: true, application: true },
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -64,6 +64,10 @@ export interface DictionaryListQuery {
|
||||
applicationId?: string;
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
enterpriseKeyword?: string;
|
||||
applicationKeyword?: string;
|
||||
phoneNumber?: string;
|
||||
reasonKeyword?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -203,6 +207,10 @@ export class DictionariesService {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
status: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
|
||||
application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined,
|
||||
phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined,
|
||||
reason: query.reasonKeyword ? { contains: query.reasonKeyword } : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ phoneNumber: { contains: query.keyword } },
|
||||
{ reason: { contains: query.keyword } },
|
||||
|
||||
@@ -40,6 +40,9 @@ function createPrismaMock() {
|
||||
smsSignature: {
|
||||
count: jest.fn().mockResolvedValue(1),
|
||||
},
|
||||
smsDrainageInfo: {
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
},
|
||||
enterpriseCertification: {
|
||||
count: jest.fn().mockResolvedValue(1),
|
||||
},
|
||||
@@ -261,6 +264,7 @@ describe('OperationsService', () => {
|
||||
enterpriseCertifications: 1,
|
||||
smsAudits: 2,
|
||||
signatures: 1,
|
||||
drainageInfos: 0,
|
||||
templates: 1,
|
||||
total: 5,
|
||||
},
|
||||
|
||||
@@ -731,14 +731,16 @@ export class OperationsService {
|
||||
return Promise.all([
|
||||
this.prisma.smsTemplate.count({ where: { tenantId, auditStatus: 'pending' } }),
|
||||
this.prisma.smsSignature.count({ where: { tenantId, auditStatus: 'pending' } }),
|
||||
this.prisma.smsDrainageInfo.count({ where: { tenantId, auditStatus: 'pending' } }),
|
||||
this.prisma.enterpriseCertification.count({ where: { tenantId, status: 'pending' } }),
|
||||
this.prisma.smsSendTask.count({ where: { tenantId, status: 'pending_review' } }),
|
||||
]).then(([templates, signatures, enterpriseCertifications, smsAudits]) => ({
|
||||
]).then(([templates, signatures, drainageInfos, enterpriseCertifications, smsAudits]) => ({
|
||||
templates,
|
||||
signatures,
|
||||
drainageInfos,
|
||||
enterpriseCertifications,
|
||||
smsAudits,
|
||||
total: templates + signatures + enterpriseCertifications + smsAudits,
|
||||
total: templates + signatures + drainageInfos + enterpriseCertifications + smsAudits,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { CreateSmsApplicationDto, CreateSmsSignatureDto, CreateSmsTemplateDto, ReplaceApplicationRouteRulesDto, ReviewDto, SmsConfigService, StatusChangeDto, UpdateSmsApplicationDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.service';
|
||||
import { CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsTemplateDto, ReplaceApplicationRouteRulesDto, ReviewDto, SmsConfigService, StatusChangeDto, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.service';
|
||||
|
||||
@ApiTags('admin-sms-config')
|
||||
@Controller('admin')
|
||||
@@ -8,8 +8,8 @@ export class AdminSmsConfigController {
|
||||
constructor(private readonly smsConfig: SmsConfigService) {}
|
||||
|
||||
@Get('enterprise-applications')
|
||||
listApplications(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string) {
|
||||
return this.smsConfig.listApplications({ tenantId, keyword, includeConnections: true });
|
||||
listApplications(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('status') status?: string) {
|
||||
return this.smsConfig.listApplications({ tenantId, keyword, enterpriseKeyword, applicationKeyword, status, includeConnections: true });
|
||||
}
|
||||
|
||||
@Get('enterprise-applications/:id')
|
||||
@@ -48,8 +48,8 @@ export class AdminSmsConfigController {
|
||||
}
|
||||
|
||||
@Get('enterprise-signatures')
|
||||
listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string) {
|
||||
return this.smsConfig.listSignatures({ tenantId, keyword, status });
|
||||
listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('signatureKeyword') signatureKeyword?: string, @Query('drainageKeyword') drainageKeyword?: string) {
|
||||
return this.smsConfig.listSignatures({ tenantId, keyword, status, enterpriseKeyword, applicationKeyword, signatureKeyword, drainageKeyword });
|
||||
}
|
||||
|
||||
@Post('enterprise-signatures')
|
||||
@@ -62,9 +62,39 @@ export class AdminSmsConfigController {
|
||||
return this.smsConfig.updateSignature(signatureId, body);
|
||||
}
|
||||
|
||||
@Get('drainage-infos')
|
||||
listDrainageInfos(@Query('tenantId') tenantId?: string, @Query('signatureId') signatureId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string) {
|
||||
return this.smsConfig.listDrainageInfos({ tenantId, signatureId, status, keyword });
|
||||
}
|
||||
|
||||
@Post('enterprise-signatures/:id/drainage-infos')
|
||||
createDrainageInfo(@Param('id') signatureId: string, @Body() body: CreateSmsDrainageInfoDto) {
|
||||
return this.smsConfig.createDrainageInfo(signatureId, body, { initialAuditStatus: 'approved' });
|
||||
}
|
||||
|
||||
@Put('drainage-infos/:id')
|
||||
updateDrainageInfo(@Param('id') itemId: string, @Body() body: UpdateSmsDrainageInfoDto) {
|
||||
return this.smsConfig.updateDrainageInfo(itemId, body, { initialAuditStatus: 'approved' });
|
||||
}
|
||||
|
||||
@Post('drainage-infos/:id/approve')
|
||||
approveDrainageInfo(@Param('id') itemId: string, @Body() body: ReviewDto) {
|
||||
return this.smsConfig.approveDrainageInfo(itemId, body);
|
||||
}
|
||||
|
||||
@Post('drainage-infos/:id/reject')
|
||||
rejectDrainageInfo(@Param('id') itemId: string, @Body() body: ReviewDto) {
|
||||
return this.smsConfig.rejectDrainageInfo(itemId, body);
|
||||
}
|
||||
|
||||
@Post('drainage-infos/:id/status')
|
||||
changeDrainageInfoStatus(@Param('id') itemId: string, @Body() body: StatusChangeDto) {
|
||||
return this.smsConfig.changeDrainageInfoStatus(itemId, body);
|
||||
}
|
||||
|
||||
@Get('enterprise-templates')
|
||||
listTemplates(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string) {
|
||||
return this.smsConfig.listTemplates({ tenantId, status, keyword });
|
||||
listTemplates(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('nameKeyword') nameKeyword?: string, @Query('contentKeyword') contentKeyword?: string) {
|
||||
return this.smsConfig.listTemplates({ tenantId, status, keyword, enterpriseKeyword, applicationKeyword, nameKeyword, contentKeyword });
|
||||
}
|
||||
|
||||
@Post('enterprise-templates')
|
||||
|
||||
@@ -4,11 +4,13 @@ import { TenantId } from '../common/tenant-id.decorator';
|
||||
import {
|
||||
CreateSignatureMaterialDto,
|
||||
CreateSmsApplicationDto,
|
||||
CreateSmsDrainageInfoDto,
|
||||
CreateSmsSignatureDto,
|
||||
CreateSmsTemplateDto,
|
||||
StatusChangeDto,
|
||||
SmsConfigService,
|
||||
UpdateSmsTemplateDto,
|
||||
UpdateSmsDrainageInfoDto,
|
||||
} from './sms-config.service';
|
||||
|
||||
@ApiTags('client-sms-config')
|
||||
@@ -31,6 +33,11 @@ export class ClientSmsConfigController {
|
||||
return this.smsConfig.getApplicationCmppParams(applicationId, tenantId);
|
||||
}
|
||||
|
||||
@Get('applications/:id/report-fields')
|
||||
getApplicationReportFields(@Param('id') applicationId: string, @TenantId() tenantId?: string) {
|
||||
return this.smsConfig.getApplication(applicationId, tenantId).then(() => this.smsConfig.getApplicationReportFields(applicationId, 'drainage'));
|
||||
}
|
||||
|
||||
@Post('applications/:id/secret/reset')
|
||||
resetApplicationSecret(@Param('id') applicationId: string, @Body() body: StatusChangeDto) {
|
||||
return this.smsConfig.resetApplicationSecret(applicationId, body);
|
||||
@@ -56,6 +63,26 @@ export class ClientSmsConfigController {
|
||||
return this.smsConfig.createSignatureMaterial({ ...body, signatureId });
|
||||
}
|
||||
|
||||
@Get('drainage-infos')
|
||||
listDrainageInfos(@TenantId() tenantId?: string) {
|
||||
return this.smsConfig.listDrainageInfos({ tenantId });
|
||||
}
|
||||
|
||||
@Post('signatures/:id/drainage-infos')
|
||||
createDrainageInfo(@Param('id') signatureId: string, @Body() body: CreateSmsDrainageInfoDto, @TenantId() tenantId?: string) {
|
||||
return this.smsConfig.createDrainageInfo(signatureId, body, {}, tenantId);
|
||||
}
|
||||
|
||||
@Put('drainage-infos/:id')
|
||||
updateDrainageInfo(@Param('id') itemId: string, @Body() body: UpdateSmsDrainageInfoDto, @TenantId() tenantId?: string) {
|
||||
return this.smsConfig.updateDrainageInfo(itemId, body, {}, tenantId);
|
||||
}
|
||||
|
||||
@Post('drainage-infos/:id/status')
|
||||
changeDrainageInfoStatus(@Param('id') itemId: string, @Body() body: StatusChangeDto, @TenantId() tenantId?: string) {
|
||||
return this.smsConfig.changeDrainageInfoStatus(itemId, body, tenantId);
|
||||
}
|
||||
|
||||
@Post('signatures/:id/submit')
|
||||
submitSignature(@Param('id') signatureId: string) {
|
||||
return this.smsConfig.submitSignature(signatureId);
|
||||
|
||||
@@ -64,6 +64,8 @@ function createPrismaMock() {
|
||||
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
||||
application: { id: 'app-1', name: '应用A' },
|
||||
materials: [],
|
||||
drainageItems: [],
|
||||
reportTasks: [],
|
||||
}]),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', auditStatus: 'pending' }),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sig-new', tenantId: 'tenant-1', ...data })),
|
||||
@@ -74,11 +76,20 @@ function createPrismaMock() {
|
||||
},
|
||||
drainageReportMaterial: {
|
||||
upsert: jest.fn().mockResolvedValue({ id: 'drainage-report-value-1' }),
|
||||
create: jest.fn().mockResolvedValue({ id: 'drainage-report-value-1' }),
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
smsDrainageInfo: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-1', createdAt: new Date(), updatedAt: new Date(), submittedAt: new Date(), ...data })),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-1', tenantId: 'tenant-1', signatureId: 'sig-1', applicationId: 'app-1', siteName: '官网', url: 'https://example.com', createdAt: new Date(), updatedAt: new Date(), submittedAt: new Date(), ...data })),
|
||||
},
|
||||
channelSignatureReportTask: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-task-1', ...data })),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-task-1', ...data })),
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
channelSignatureReportRecord: {
|
||||
@@ -188,7 +199,7 @@ describe('SmsConfigService', () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.listApplications({ includeConnections: true })).resolves.toEqual([
|
||||
await expect(service.listApplications({ includeConnections: true, enterpriseKeyword: '租户', applicationKeyword: '应用', status: 'active' })).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'app-1',
|
||||
cmppStatus: 'connected',
|
||||
@@ -199,6 +210,7 @@ describe('SmsConfigService', () => {
|
||||
}),
|
||||
]);
|
||||
expect(prisma.smsApplication.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ status: 'active', tenant: { name: { contains: '租户' } }, name: { contains: '应用' } }),
|
||||
include: { tenant: true, ipAllowlist: true },
|
||||
}));
|
||||
expect(prisma.smsApplication.findMany.mock.calls[0][0]).not.toHaveProperty('take');
|
||||
@@ -427,7 +439,7 @@ describe('SmsConfigService', () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.listSignatures({ keyword: '签名A' })).resolves.toEqual([
|
||||
await expect(service.listSignatures({ enterpriseKeyword: '租户', applicationKeyword: '应用', signatureKeyword: '签名', drainageKeyword: '官网' })).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'sig-1',
|
||||
tenant: expect.objectContaining({ name: '租户A' }),
|
||||
@@ -437,9 +449,18 @@ describe('SmsConfigService', () => {
|
||||
expect(prisma.smsSignature.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
auditStatus: { not: 'deleted' },
|
||||
OR: expect.any(Array),
|
||||
tenant: { name: { contains: '租户' } },
|
||||
application: { name: { contains: '应用' } },
|
||||
name: { contains: '签名' },
|
||||
drainageItems: expect.objectContaining({ some: expect.objectContaining({ OR: expect.any(Array) }) }),
|
||||
}),
|
||||
include: { materials: true, tenant: true, application: true, reportTasks: { include: { channel: true } } },
|
||||
include: {
|
||||
materials: true,
|
||||
tenant: true,
|
||||
application: true,
|
||||
drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } },
|
||||
reportTasks: { include: { channel: true, drainageInfo: true } },
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -473,7 +494,7 @@ describe('SmsConfigService', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('validates and persists dynamic signature and drainage report values by channel', async () => {
|
||||
it('validates and persists dynamic signature report values by channel without bypassing drainage audit', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'draft' });
|
||||
prisma.smsSignature.update.mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', ...data }));
|
||||
@@ -498,25 +519,48 @@ describe('SmsConfigService', () => {
|
||||
applicationId: 'app-1',
|
||||
drainageInfo: {
|
||||
signatureReportValues: { license: { fileObjectId: 'file-1', fileName: 'license.pdf' } },
|
||||
links: [{ id: 'drain-1', reportValues: { site_owner: '企业A' } }],
|
||||
links: [{ id: 'legacy-drain-1', reportValues: { site_owner: '企业A' } }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(prisma.signatureReportMaterial.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
create: expect.objectContaining({ signatureId: 'sig-1', channelId: 'channel-1', fieldCode: 'license', fileObjectId: 'file-1' }),
|
||||
}));
|
||||
expect(prisma.drainageReportMaterial.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
create: expect.objectContaining({ signatureId: 'sig-1', drainageItemId: 'drain-1', channelId: 'channel-1', fieldCode: 'site_owner', fieldValue: '企业A' }),
|
||||
}));
|
||||
expect(prisma.drainageReportMaterial.deleteMany).toHaveBeenCalledWith({
|
||||
where: { signatureId: 'sig-1', drainageItemId: { notIn: ['drain-1'] } },
|
||||
});
|
||||
expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'pending' }),
|
||||
});
|
||||
expect(prisma.channelSignatureReportRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ taskId: 'drainage-task-1', action: 'create', statusAfter: 'pending' }),
|
||||
});
|
||||
expect(prisma.drainageReportMaterial.create).not.toHaveBeenCalled();
|
||||
expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates client drainage info as pending without generating channel report tasks', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'approved' });
|
||||
prisma.channelRouteRule.findMany.mockResolvedValue([]);
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.createDrainageInfo('sig-1', { siteName: '官网', url: 'https://example.com', reportValues: {} }, {}, 'tenant-1'))
|
||||
.resolves.toEqual(expect.objectContaining({ id: 'drainage-1', auditStatus: 'pending' }));
|
||||
|
||||
expect(prisma.auditRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ targetType: 'sms_drainage_info', action: 'submit', statusAfter: 'pending' }) });
|
||||
expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates real drainage materials and channel tasks after operations approval', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const pendingItem = { id: 'drainage-1', tenantId: 'tenant-1', signatureId: 'sig-1', applicationId: 'app-1', siteName: '官网', url: 'https://example.com', reportValues: { site_owner: '企业A' }, auditStatus: 'pending' };
|
||||
const approvedItem = { ...pendingItem, auditStatus: 'approved', signature: { id: 'sig-1', applicationId: 'app-1' } };
|
||||
prisma.smsDrainageInfo.findUnique.mockResolvedValueOnce(pendingItem).mockResolvedValueOnce(approvedItem);
|
||||
prisma.smsDrainageInfo.update.mockResolvedValue({ ...approvedItem, tenant: {}, application: {} });
|
||||
prisma.channelRouteRule.findMany.mockResolvedValue([{
|
||||
id: 'route-1', priority: 10,
|
||||
group: { id: 'group-1', name: '默认通道组', items: [{ channel: { id: 'channel-1', code: 'CH-1', name: '通道一', reportFields: [{ status: 'active', required: true, reportType: 'drainage', drainageField: { id: 'field-2', code: 'site_owner', name: '网站主体', fieldType: 'string', description: null, status: 'active' } }] } }] },
|
||||
}] as never);
|
||||
prisma.$transaction.mockImplementation((callback) => callback(prisma));
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await service.approveDrainageInfo('drainage-1', {});
|
||||
|
||||
expect(prisma.drainageReportMaterial.create).toHaveBeenCalledWith({ data: expect.objectContaining({ drainageItemId: 'drainage-1', channelId: 'channel-1', fieldCode: 'site_owner', fieldValue: '企业A' }) });
|
||||
expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledWith({ data: expect.objectContaining({ reportType: 'drainage', drainageItemId: 'drainage-1', status: 'pending' }) });
|
||||
expect(prisma.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'audit_approved_create', statusAfter: 'pending' }) });
|
||||
});
|
||||
|
||||
it('creates admin signatures with an approved initial audit status', async () => {
|
||||
@@ -565,7 +609,7 @@ describe('SmsConfigService', () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.listTemplates({ keyword: '模板A' })).resolves.toEqual([
|
||||
await expect(service.listTemplates({ enterpriseKeyword: '租户', applicationKeyword: '应用', nameKeyword: '模板', contentKeyword: '验证码' })).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'tpl-1',
|
||||
tenant: expect.objectContaining({ name: '租户A' }),
|
||||
@@ -576,7 +620,10 @@ describe('SmsConfigService', () => {
|
||||
expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
auditStatus: { not: 'deleted' },
|
||||
OR: expect.any(Array),
|
||||
tenant: { name: { contains: '租户' } },
|
||||
application: { name: { contains: '应用' } },
|
||||
name: { contains: '模板' },
|
||||
content: { contains: '验证码' },
|
||||
}),
|
||||
include: { variables: true, application: true, tenant: true, signature: true },
|
||||
}));
|
||||
|
||||
@@ -52,6 +52,22 @@ export type UpdateSmsSignatureDto = Partial<Omit<CreateSmsSignatureDto, 'tenantI
|
||||
auditStatus?: string;
|
||||
};
|
||||
|
||||
export interface CreateSmsDrainageInfoDto {
|
||||
siteName: string;
|
||||
url: string;
|
||||
remark?: string;
|
||||
reportValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type UpdateSmsDrainageInfoDto = Partial<CreateSmsDrainageInfoDto>;
|
||||
|
||||
export interface DrainageInfoListQuery {
|
||||
tenantId?: string;
|
||||
signatureId?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export interface CreateSignatureMaterialDto {
|
||||
signatureId: string;
|
||||
fileObjectId?: string;
|
||||
@@ -93,14 +109,31 @@ export interface TemplateListQuery {
|
||||
tenantId?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
enterpriseKeyword?: string;
|
||||
applicationKeyword?: string;
|
||||
nameKeyword?: string;
|
||||
contentKeyword?: string;
|
||||
}
|
||||
|
||||
export interface ApplicationListQuery {
|
||||
tenantId?: string;
|
||||
keyword?: string;
|
||||
enterpriseKeyword?: string;
|
||||
applicationKeyword?: string;
|
||||
status?: string;
|
||||
includeConnections?: boolean;
|
||||
}
|
||||
|
||||
export interface SignatureListQuery {
|
||||
tenantId?: string;
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
enterpriseKeyword?: string;
|
||||
applicationKeyword?: string;
|
||||
signatureKeyword?: string;
|
||||
drainageKeyword?: string;
|
||||
}
|
||||
|
||||
export interface GatewayDownstreamConnectionEventDto {
|
||||
account: string;
|
||||
connectionId: string;
|
||||
@@ -130,6 +163,9 @@ export class SmsConfigService {
|
||||
const applications = await this.prisma.smsApplication.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
|
||||
name: query.applicationKeyword ? { contains: query.applicationKeyword } : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ name: { contains: query.keyword } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
@@ -171,7 +207,7 @@ export class SmsConfigService {
|
||||
});
|
||||
}
|
||||
|
||||
async getApplication(applicationId: string) {
|
||||
async getApplication(applicationId: string, tenantId?: string) {
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
include: {
|
||||
@@ -179,7 +215,7 @@ export class SmsConfigService {
|
||||
ipAllowlist: true,
|
||||
},
|
||||
});
|
||||
if (!application) {
|
||||
if (!application || (tenantId && application.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
return application;
|
||||
@@ -574,12 +610,25 @@ export class SmsConfigService {
|
||||
});
|
||||
}
|
||||
|
||||
async listSignatures(queryOrTenantId?: string | { tenantId?: string; keyword?: string; status?: string }) {
|
||||
async listSignatures(queryOrTenantId?: string | SignatureListQuery) {
|
||||
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
|
||||
const signatures = await this.prisma.smsSignature.findMany({
|
||||
where: {
|
||||
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,
|
||||
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 } },
|
||||
@@ -587,7 +636,13 @@ export class SmsConfigService {
|
||||
{ application: { name: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
},
|
||||
include: { materials: true, tenant: true, application: true, reportTasks: { include: { channel: true } } },
|
||||
include: {
|
||||
materials: true,
|
||||
tenant: true,
|
||||
application: true,
|
||||
drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } },
|
||||
reportTasks: { include: { channel: true, drainageInfo: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const applicationIds = signatures.map((signature) => signature.applicationId).filter((id): id is string => Boolean(id));
|
||||
@@ -595,24 +650,43 @@ export class SmsConfigService {
|
||||
where: { applicationId: { in: applicationIds }, status: 'active' },
|
||||
include: { group: { include: { items: { include: { channel: { include: { reportFields: true } } } } } } },
|
||||
}) : [];
|
||||
return signatures.map((signature) => ({
|
||||
return signatures.map((signature) => {
|
||||
const legacyPayload = isRecord(signature.drainageInfo) ? signature.drainageInfo : {};
|
||||
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(),
|
||||
}));
|
||||
return {
|
||||
...signature,
|
||||
drainageInfo: { ...legacyPayload, links: drainageLinks },
|
||||
reportTargets: (() => {
|
||||
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');
|
||||
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task]));
|
||||
return [...new Map(channels.map((channel) => [channel.id, channel])).values()].map((channel) => ({ channel, channelId: channel.id, status: taskByChannel.get(channel.id)?.status ?? 'pending', taskId: taskByChannel.get(channel.id)?.id }));
|
||||
})(),
|
||||
drainageReportTargets: Object.fromEntries((Array.isArray((signature.drainageInfo as Record<string, unknown> | null)?.links) ? (signature.drainageInfo as Record<string, unknown>).links as Array<Record<string, unknown>> : []).map((link) => {
|
||||
const drainageItemId = String(link.id ?? '');
|
||||
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' && 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()].map((channel) => ({ channel, channelId: channel.id, status: taskByChannel.get(channel.id)?.status ?? 'pending', taskId: taskByChannel.get(channel.id)?.id }))];
|
||||
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((Array.isArray((signature.drainageInfo as Record<string, unknown> | null)?.links) ? (signature.drainageInfo as Record<string, unknown>).links as Array<Record<string, unknown>> : []).map((link) => {
|
||||
const drainageItemId = String(link.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))
|
||||
@@ -621,7 +695,7 @@ export class SmsConfigService {
|
||||
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) => channel.carrier === carrier || channel.carrier === 'all');
|
||||
const statuses = carrierTargets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
|
||||
const statuses = carrierTargets.flatMap((channel) => taskByChannel.get(channel.id)?.status ? [taskByChannel.get(channel.id)!.status] : []);
|
||||
const approved = statuses.filter((status) => status === 'approved').length;
|
||||
const status = !statuses.length ? 'not_applicable' : approved === statuses.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending';
|
||||
return [carrier, { status, approved, total: statuses.length }];
|
||||
@@ -636,7 +710,8 @@ export class SmsConfigService {
|
||||
const status = !targets.length ? 'not_applicable' : approved === targets.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending';
|
||||
return [carrier, { status, approved, total: targets.length }];
|
||||
})),
|
||||
}));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async createSignature(data: CreateSmsSignatureDto, options: CreateSmsSignatureOptions = {}) {
|
||||
@@ -691,6 +766,118 @@ export class SmsConfigService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
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' },
|
||||
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('签名审核通过后才能新增引流信息');
|
||||
if (!data.siteName?.trim() || !data.url?.trim()) throw new BadRequestException('siteName and url are required');
|
||||
await this.validateDrainageReportValues(signature.applicationId ?? undefined, data.reportValues);
|
||||
const auditStatus = options.initialAuditStatus ?? 'pending';
|
||||
const item = await this.prisma.smsDrainageInfo.create({
|
||||
data: {
|
||||
tenantId: signature.tenantId,
|
||||
signatureId,
|
||||
applicationId: signature.applicationId,
|
||||
siteName: data.siteName.trim(),
|
||||
url: data.url.trim(),
|
||||
remark: data.remark,
|
||||
reportValues: data.reportValues as Prisma.InputJsonValue | undefined,
|
||||
auditStatus,
|
||||
reviewedAt: auditStatus === 'approved' ? new Date() : undefined,
|
||||
},
|
||||
include: { tenant: true, signature: true, application: true },
|
||||
});
|
||||
await this.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.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('已删除的引流信息不能修改');
|
||||
if (data.siteName !== undefined && !data.siteName.trim()) throw new BadRequestException('siteName is required');
|
||||
if (data.url !== undefined && !data.url.trim()) throw new BadRequestException('url is required');
|
||||
const applicationId = current.signature.applicationId ?? current.applicationId ?? undefined;
|
||||
await this.validateDrainageReportValues(applicationId, data.reportValues ?? (isRecord(current.reportValues) ? current.reportValues : {}));
|
||||
const auditStatus = options.initialAuditStatus ?? 'pending';
|
||||
const updated = await this.prisma.smsDrainageInfo.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
applicationId,
|
||||
siteName: data.siteName?.trim(),
|
||||
url: data.url?.trim(),
|
||||
remark: data.remark,
|
||||
reportValues: data.reportValues as Prisma.InputJsonValue | undefined,
|
||||
auditStatus,
|
||||
rejectReason: null,
|
||||
submittedAt: new Date(),
|
||||
reviewedAt: auditStatus === 'approved' ? new Date() : null,
|
||||
},
|
||||
include: { tenant: true, signature: true, application: true },
|
||||
});
|
||||
await this.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.activateDrainageReporting(itemId);
|
||||
else await this.suspendDrainageReporting(itemId, '引流信息修改后等待运营审核');
|
||||
return updated;
|
||||
}
|
||||
|
||||
approveDrainageInfo(itemId: string, data: ReviewDto) {
|
||||
return this.reviewDrainageInfo(itemId, 'approved', 'approve', data);
|
||||
}
|
||||
|
||||
rejectDrainageInfo(itemId: string, data: ReviewDto) {
|
||||
return this.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.suspendDrainageReporting(itemId, data.reason ?? '引流信息已删除', 'abandoned');
|
||||
await this.createAuditRecord({ tenantId: current.tenantId, targetType: 'sms_drainage_info', targetId: itemId, action: status, statusBefore: current.auditStatus, statusAfter: status, reason: data.reason });
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async withReportRequirementSnapshot(applicationId?: string, drainageInfo?: Record<string, unknown>) {
|
||||
if (!drainageInfo || !applicationId) return drainageInfo;
|
||||
const fields = await this.getApplicationReportFields(applicationId);
|
||||
@@ -716,21 +903,6 @@ export class SmsConfigService {
|
||||
if (!applicationId || !drainageInfo) return;
|
||||
const fields = await this.getApplicationReportFields(applicationId);
|
||||
const signatureValues = isRecord(drainageInfo.signatureReportValues) ? drainageInfo.signatureReportValues : {};
|
||||
const links = Array.isArray(drainageInfo.links) ? drainageInfo.links.filter(isRecord) : [];
|
||||
const drainageItemIds = links.map((link) => String(link.id ?? '')).filter(Boolean);
|
||||
await this.prisma.drainageReportMaterial.deleteMany({
|
||||
where: {
|
||||
signatureId,
|
||||
...(drainageItemIds.length > 0 ? { drainageItemId: { notIn: drainageItemIds } } : {}),
|
||||
},
|
||||
});
|
||||
await this.prisma.channelSignatureReportTask.deleteMany({
|
||||
where: {
|
||||
signatureId,
|
||||
reportType: 'drainage',
|
||||
...(drainageItemIds.length > 0 ? { drainageItemId: { notIn: drainageItemIds } } : {}),
|
||||
},
|
||||
});
|
||||
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) {
|
||||
@@ -741,46 +913,6 @@ export class SmsConfigService {
|
||||
});
|
||||
}
|
||||
}
|
||||
const drainageFields = fields.filter((item) => item.reportTypes.some((type) => type === 'drainage' || type === 'both'));
|
||||
const drainageChannels = new Map(drainageFields.flatMap((item) => item.channels).map((channel) => [channel.id, channel]));
|
||||
const signatureOwner = links.length > 0
|
||||
? await this.prisma.smsSignature.findUnique({ where: { id: signatureId }, select: { tenantId: true } })
|
||||
: null;
|
||||
for (const link of links) {
|
||||
const drainageItemId = String(link.id ?? '');
|
||||
const values = isRecord(link.reportValues) ? link.reportValues : {};
|
||||
if (!drainageItemId) continue;
|
||||
for (const channel of drainageChannels.values()) {
|
||||
const existingTask = await this.prisma.channelSignatureReportTask.findFirst({
|
||||
where: { signatureId, channelId: channel.id, reportType: 'drainage', drainageItemId },
|
||||
});
|
||||
if (!existingTask && signatureOwner) {
|
||||
const task = await this.prisma.channelSignatureReportTask.create({
|
||||
data: { tenantId: signatureOwner.tenantId, signatureId, channelId: channel.id, reportType: 'drainage', drainageItemId, status: 'pending' },
|
||||
});
|
||||
await this.prisma.channelSignatureReportRecord.create({
|
||||
data: { taskId: task.id, channelId: channel.id, action: 'create', statusAfter: 'pending' },
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const field of drainageFields) {
|
||||
const value = reportValueParts(values[field.code]);
|
||||
for (const channel of field.channels) {
|
||||
await this.prisma.drainageReportMaterial.upsert({
|
||||
where: {
|
||||
signatureId_drainageItemId_channelId_fieldCode: {
|
||||
signatureId,
|
||||
drainageItemId,
|
||||
channelId: channel.id,
|
||||
fieldCode: field.code,
|
||||
},
|
||||
},
|
||||
update: value,
|
||||
create: { signatureId, drainageItemId, channelId: channel.id, fieldCode: field.code, ...value },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async validateSignatureReportValues(applicationId?: string, drainageInfo?: Record<string, unknown>) {
|
||||
@@ -793,19 +925,68 @@ export class SmsConfigService {
|
||||
if (missingSignature.length > 0) {
|
||||
throw new BadRequestException(`缺少必填签名报备资料:${missingSignature.map((field) => field.name).join('、')}`);
|
||||
}
|
||||
const drainageFields = fields.filter(
|
||||
(field) => field.required && field.reportTypes.some((type) => type === 'drainage' || type === 'both'),
|
||||
);
|
||||
const links = Array.isArray(drainageInfo.links) ? drainageInfo.links.filter(isRecord) : [];
|
||||
for (const link of links) {
|
||||
const values = isRecord(link.reportValues) ? link.reportValues : {};
|
||||
const missing = drainageFields.filter((field) => !hasReportValue(values[field.code]));
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException(`引流信息缺少必填报备资料:${missing.map((field) => field.name).join('、')}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async validateDrainageReportValues(applicationId?: string, reportValues: Record<string, unknown> = {}) {
|
||||
if (!applicationId) return;
|
||||
const fields = await this.getApplicationReportFields(applicationId, 'drainage');
|
||||
const missing = fields.filter((field) => field.required && !hasReportValue(reportValues[field.code]));
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException(`引流信息缺少必填报备资料:${missing.map((field) => field.name).join('、')}`);
|
||||
}
|
||||
}
|
||||
|
||||
private 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.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: '引流信息运营审核通过' },
|
||||
});
|
||||
}
|
||||
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: '应用当前路由已不包含此通道' } });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private 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 } });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createSignatureMaterial(data: CreateSignatureMaterialDto) {
|
||||
return this.prisma.signatureMaterial.create({
|
||||
data: {
|
||||
@@ -845,6 +1026,10 @@ export class SmsConfigService {
|
||||
where: {
|
||||
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.nameKeyword ? { contains: query.nameKeyword } : undefined,
|
||||
content: query.contentKeyword ? { contains: query.contentKeyword } : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ name: { contains: query.keyword } },
|
||||
{ content: { contains: query.keyword } },
|
||||
@@ -1030,6 +1215,40 @@ export class SmsConfigService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async reviewDrainageInfo(itemId: string, statusAfter: string, action: string, data: ReviewDto) {
|
||||
const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } });
|
||||
if (!item) throw new NotFoundException('Drainage info not found');
|
||||
if (!['pending', 'rejected'].includes(item.auditStatus)) {
|
||||
throw new BadRequestException('只有待审核或已驳回的引流信息可以审核');
|
||||
}
|
||||
if (statusAfter === 'rejected' && !data.reason?.trim()) {
|
||||
throw new BadRequestException('驳回引流信息时必须填写原因');
|
||||
}
|
||||
const reviewerId = await this.resolveReviewerId(data.reviewerId);
|
||||
const updated = await this.prisma.smsDrainageInfo.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
auditStatus: statusAfter,
|
||||
rejectReason: statusAfter === 'rejected' ? data.reason?.trim() : null,
|
||||
reviewedAt: new Date(),
|
||||
},
|
||||
include: { tenant: true, signature: true, application: true },
|
||||
});
|
||||
await this.createAuditRecord({
|
||||
tenantId: item.tenantId,
|
||||
targetType: 'sms_drainage_info',
|
||||
targetId: itemId,
|
||||
action,
|
||||
statusBefore: item.auditStatus,
|
||||
statusAfter,
|
||||
reason: data.reason,
|
||||
reviewerId,
|
||||
});
|
||||
if (statusAfter === 'approved') await this.activateDrainageReporting(itemId);
|
||||
else await this.suspendDrainageReporting(itemId, data.reason?.trim() || '引流信息运营审核驳回');
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async reviewTemplate(templateId: string, statusAfter: string, action: string, data: ReviewDto) {
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!template) {
|
||||
|
||||
Reference in New Issue
Block a user