feat: complete reporting and filing workflows
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
||||
ALTER TABLE "ReportMaterialImportBatch"
|
||||
ADD COLUMN "reviewedById" TEXT,
|
||||
ADD COLUMN "reviewedAt" TIMESTAMP(3);
|
||||
|
||||
CREATE TABLE "ReportMaterialImportItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"batchId" TEXT NOT NULL,
|
||||
"rowNumber" INTEGER NOT NULL,
|
||||
"reportType" TEXT NOT NULL,
|
||||
"operation" TEXT NOT NULL,
|
||||
"targetId" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending_review',
|
||||
"payload" JSONB NOT NULL,
|
||||
"originalSnapshot" JSONB,
|
||||
"errorMessage" TEXT,
|
||||
"reviewReason" TEXT,
|
||||
"reviewedById" TEXT,
|
||||
"reviewedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ReportMaterialImportItem_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "ReportMaterialImportItem_batchId_fkey"
|
||||
FOREIGN KEY ("batchId") REFERENCES "ReportMaterialImportBatch"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "ReportMaterialImportItem_batchId_rowNumber_key"
|
||||
ON "ReportMaterialImportItem"("batchId", "rowNumber");
|
||||
CREATE INDEX "ReportMaterialImportItem_batchId_status_idx"
|
||||
ON "ReportMaterialImportItem"("batchId", "status");
|
||||
CREATE INDEX "ReportMaterialImportItem_reportType_status_createdAt_idx"
|
||||
ON "ReportMaterialImportItem"("reportType", "status", "createdAt");
|
||||
CREATE INDEX "ReportMaterialImportItem_targetId_idx"
|
||||
ON "ReportMaterialImportItem"("targetId");
|
||||
@@ -1194,13 +1194,42 @@ model ReportMaterialImportBatch {
|
||||
rowCount Int @default(0)
|
||||
successCount Int @default(0)
|
||||
failedCount Int @default(0)
|
||||
reviewedById String?
|
||||
reviewedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
completedAt DateTime?
|
||||
|
||||
items ReportMaterialImportItem[]
|
||||
|
||||
@@index([tenantId, createdAt])
|
||||
@@index([status, createdAt])
|
||||
}
|
||||
|
||||
model ReportMaterialImportItem {
|
||||
id String @id @default(cuid())
|
||||
batchId String
|
||||
rowNumber Int
|
||||
reportType String
|
||||
operation String
|
||||
targetId String?
|
||||
status String @default("pending_review")
|
||||
payload Json
|
||||
originalSnapshot Json?
|
||||
errorMessage String?
|
||||
reviewReason String?
|
||||
reviewedById String?
|
||||
reviewedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
batch ReportMaterialImportBatch @relation(fields: [batchId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([batchId, rowNumber])
|
||||
@@index([batchId, status])
|
||||
@@index([reportType, status, createdAt])
|
||||
@@index([targetId])
|
||||
}
|
||||
|
||||
model ReportReceiptImport {
|
||||
id String @id @default(cuid())
|
||||
taskId String
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { CertificationService, ReviewCertificationDto, SubmitCertificationDto } from './certification.service';
|
||||
|
||||
@@ -35,12 +36,12 @@ export class AdminCertificationController {
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
approve(@Param('id') id: string, @Body() body: ReviewCertificationDto) {
|
||||
return this.certifications.approve(id, body);
|
||||
approve(@Param('id') id: string, @Body() body: ReviewCertificationDto, @CurrentSessionUserId() reviewerId?: string) {
|
||||
return this.certifications.approve(id, { ...body, reviewerId });
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
reject(@Param('id') id: string, @Body() body: ReviewCertificationDto) {
|
||||
return this.certifications.reject(id, body);
|
||||
reject(@Param('id') id: string, @Body() body: ReviewCertificationDto, @CurrentSessionUserId() reviewerId?: string) {
|
||||
return this.certifications.reject(id, { ...body, reviewerId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,13 @@ function createPrismaMock() {
|
||||
},
|
||||
enterpriseCertification: {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'cert-1', ...data })),
|
||||
findMany: jest.fn(),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'cert-1', tenantId: 'tenant-1', status: 'pending' }),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'cert-1', ...data })),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'reviewer-1' }),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'reviewer-1', username: 'reviewer', displayName: '审核员' }),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
operationLog: {
|
||||
create: jest.fn(),
|
||||
@@ -22,6 +23,21 @@ function createPrismaMock() {
|
||||
}
|
||||
|
||||
describe('CertificationService', () => {
|
||||
it('returns the reviewer username with enterprise certification records', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.enterpriseCertification.findMany.mockResolvedValue([
|
||||
{ id: 'cert-1', tenantId: 'tenant-1', reviewerId: 'reviewer-1' },
|
||||
]);
|
||||
prisma.user.findMany.mockResolvedValue([
|
||||
{ id: 'reviewer-1', username: 'reviewer', displayName: '审核员' },
|
||||
]);
|
||||
const service = new CertificationService(prisma as never);
|
||||
|
||||
await expect(service.list()).resolves.toEqual([
|
||||
expect.objectContaining({ reviewer: expect.objectContaining({ username: 'reviewer' }) }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('submits certification and marks tenant pending', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new CertificationService(prisma as never);
|
||||
|
||||
@@ -20,8 +20,8 @@ export interface ReviewCertificationDto {
|
||||
export class CertificationService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
list(tenantId?: string, status?: string, keyword?: string) {
|
||||
return this.prisma.enterpriseCertification.findMany({
|
||||
async list(tenantId?: string, status?: string, keyword?: string) {
|
||||
const records = await this.prisma.enterpriseCertification.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
status: status && status !== 'all' ? status : undefined,
|
||||
@@ -36,6 +36,7 @@ export class CertificationService {
|
||||
include: { tenant: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return this.attachReviewers(records);
|
||||
}
|
||||
|
||||
async get(id: string) {
|
||||
@@ -46,7 +47,7 @@ export class CertificationService {
|
||||
if (!certification) {
|
||||
throw new NotFoundException('Enterprise certification not found');
|
||||
}
|
||||
return certification;
|
||||
return (await this.attachReviewers([certification]))[0];
|
||||
}
|
||||
|
||||
async submit(data: SubmitCertificationDto) {
|
||||
@@ -97,8 +98,12 @@ export class CertificationService {
|
||||
if (!certification) {
|
||||
throw new NotFoundException('Enterprise certification not found');
|
||||
}
|
||||
let reviewer: { id: string; username: string; displayName: string } | null = null;
|
||||
if (data.reviewerId) {
|
||||
const reviewer = await this.prisma.user.findUnique({ where: { id: data.reviewerId }, select: { id: true } });
|
||||
reviewer = await this.prisma.user.findUnique({
|
||||
where: { id: data.reviewerId },
|
||||
select: { id: true, username: true, displayName: true },
|
||||
});
|
||||
if (!reviewer) {
|
||||
throw new BadRequestException('reviewerId does not reference an existing user');
|
||||
}
|
||||
@@ -126,6 +131,19 @@ export class CertificationService {
|
||||
detail: { reason: data.reason } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
return { ...updated, reviewer };
|
||||
}
|
||||
|
||||
private async attachReviewers<T extends { reviewerId: string | null }>(records: T[]) {
|
||||
const reviewerIds = [...new Set(records.map((record) => record.reviewerId).filter((id): id is string => Boolean(id)))];
|
||||
const reviewers = reviewerIds.length ? await this.prisma.user.findMany({
|
||||
where: { id: { in: reviewerIds } },
|
||||
select: { id: true, username: true, displayName: true },
|
||||
}) : [];
|
||||
const reviewerById = new Map(reviewers.map((reviewer) => [reviewer.id, reviewer]));
|
||||
return records.map((record) => ({
|
||||
...record,
|
||||
reviewer: record.reviewerId ? reviewerById.get(record.reviewerId) ?? null : null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,8 +178,8 @@ export class ChannelsController {
|
||||
|
||||
@Post('report-tasks/status-change')
|
||||
@RequireRecentAuthentication()
|
||||
changeReportTaskStatuses(@Body() body: ChangeReportTaskStatusesDto) {
|
||||
return this.channels.changeReportTaskStatuses(body);
|
||||
changeReportTaskStatuses(@Body() body: ChangeReportTaskStatusesDto, @CurrentSessionUserId() operatorId?: string) {
|
||||
return this.channels.changeReportTaskStatuses({ ...body, operatorId });
|
||||
}
|
||||
|
||||
@Post('report-tasks/:id/export')
|
||||
|
||||
@@ -213,8 +213,24 @@ describe('ChannelsService', () => {
|
||||
await service.listReportTasks(undefined, undefined, 'channel-1');
|
||||
|
||||
expect(prisma.channelSignatureReportTask.findMany).toHaveBeenCalledWith({
|
||||
where: { tenantId: undefined, status: undefined, channelId: 'channel-1', reportType: undefined },
|
||||
include: { signature: true, channel: true, drainageInfo: true },
|
||||
where: {
|
||||
tenantId: undefined,
|
||||
status: undefined,
|
||||
channelId: 'channel-1',
|
||||
reportType: undefined,
|
||||
signature: { auditStatus: { not: 'deleted' } },
|
||||
},
|
||||
include: {
|
||||
signature: { include: { tenant: true, application: true } },
|
||||
channel: true,
|
||||
drainageInfo: true,
|
||||
exportItems: {
|
||||
include: { exportFile: true, batchItem: { include: { batch: true } } },
|
||||
orderBy: { id: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
records: { orderBy: { createdAt: 'desc' }, take: 20 },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1155,8 +1155,24 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
async listReportTasks(tenantId?: string, status?: string, channelId?: string, reportType?: string) {
|
||||
const tasks = await this.prisma.channelSignatureReportTask.findMany({
|
||||
where: { tenantId, status, channelId, reportType },
|
||||
include: { signature: true, channel: true, drainageInfo: true },
|
||||
where: {
|
||||
tenantId,
|
||||
status,
|
||||
channelId,
|
||||
reportType,
|
||||
signature: { auditStatus: { not: 'deleted' } },
|
||||
},
|
||||
include: {
|
||||
signature: { include: { tenant: true, application: true } },
|
||||
channel: true,
|
||||
drainageInfo: true,
|
||||
exportItems: {
|
||||
include: { exportFile: true, batchItem: { include: { batch: true } } },
|
||||
orderBy: { id: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
records: { orderBy: { createdAt: 'desc' }, take: 20 },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (tasks.length === 0) {
|
||||
|
||||
@@ -34,7 +34,9 @@ function createPrismaMock() {
|
||||
delete: jest.fn().mockResolvedValue({ id: 'field-1' }),
|
||||
},
|
||||
channelReportField: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
deleteMany: jest.fn(),
|
||||
},
|
||||
commonReportField: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
@@ -49,6 +51,7 @@ function createPrismaMock() {
|
||||
operationLog: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,13 +67,45 @@ describe('DictionariesService', () => {
|
||||
|
||||
it('returns drainage field usage counts and blocks deleting fields used by channels', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.drainageField.findMany.mockResolvedValue([{ id: 'field-1', code: 'license', _count: { channelReportFields: 2, commonReportFields: 0 } }]);
|
||||
prisma.drainageField.findMany.mockResolvedValue([{ id: 'field-1', code: 'license' }]);
|
||||
prisma.channelReportField.findMany.mockResolvedValue([
|
||||
{ drainageFieldId: 'field-1', channelId: 'channel-1' },
|
||||
{ drainageFieldId: 'field-1', channelId: 'channel-1' },
|
||||
{ drainageFieldId: 'field-1', channelId: 'channel-2' },
|
||||
]);
|
||||
prisma.channelReportField.count.mockResolvedValue(2);
|
||||
const service = new DictionariesService(prisma as never);
|
||||
|
||||
await expect(service.listDrainageFields()).resolves.toEqual([{ id: 'field-1', code: 'license', usageCount: 2, commonUsageCount: 0 }]);
|
||||
await expect(service.deleteDrainageField('field-1')).rejects.toThrow('不能删除');
|
||||
expect(prisma.drainageField.delete).not.toHaveBeenCalled();
|
||||
expect(prisma.channelReportField.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
drainageFieldId: { in: ['field-1'] },
|
||||
channel: { status: { not: 'deleted' } },
|
||||
},
|
||||
select: { drainageFieldId: true, channelId: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores deleted-channel references and removes those stale mappings when deleting the field', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const tx = {
|
||||
channelReportField: { deleteMany: jest.fn().mockResolvedValue({ count: 1 }) },
|
||||
drainageField: { delete: jest.fn().mockResolvedValue({ id: 'field-1' }) },
|
||||
};
|
||||
prisma.$transaction.mockImplementation((callback) => callback(tx));
|
||||
const service = new DictionariesService(prisma as never);
|
||||
|
||||
await expect(service.deleteDrainageField('field-1')).resolves.toEqual({ id: 'field-1' });
|
||||
|
||||
expect(prisma.channelReportField.count).toHaveBeenCalledWith({
|
||||
where: { drainageFieldId: 'field-1', channel: { status: { not: 'deleted' } } },
|
||||
});
|
||||
expect(tx.channelReportField.deleteMany).toHaveBeenCalledWith({
|
||||
where: { drainageFieldId: 'field-1', channel: { status: 'deleted' } },
|
||||
});
|
||||
expect(tx.drainageField.delete).toHaveBeenCalledWith({ where: { id: 'field-1' } });
|
||||
});
|
||||
|
||||
it('creates and deletes real common signature and drainage field configurations', async () => {
|
||||
|
||||
@@ -294,13 +294,37 @@ export class DictionariesService {
|
||||
|
||||
async listDrainageFields() {
|
||||
const fields = await this.prisma.drainageField.findMany({
|
||||
include: { _count: { select: { channelReportFields: true, commonReportFields: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return fields.map(({ _count, ...field }) => ({
|
||||
const fieldIds = fields.map((field) => field.id);
|
||||
const [channelReferences, commonReferences] = fieldIds.length ? await Promise.all([
|
||||
this.prisma.channelReportField.findMany({
|
||||
where: {
|
||||
drainageFieldId: { in: fieldIds },
|
||||
channel: { status: { not: 'deleted' } },
|
||||
},
|
||||
select: { drainageFieldId: true, channelId: true },
|
||||
}),
|
||||
this.prisma.commonReportField.findMany({
|
||||
where: { drainageFieldId: { in: fieldIds } },
|
||||
select: { drainageFieldId: true },
|
||||
}),
|
||||
]) : [[], []];
|
||||
const channelsByField = new Map<string, Set<string>>();
|
||||
for (const reference of channelReferences) {
|
||||
if (!reference.drainageFieldId) continue;
|
||||
const channelIds = channelsByField.get(reference.drainageFieldId) ?? new Set<string>();
|
||||
channelIds.add(reference.channelId);
|
||||
channelsByField.set(reference.drainageFieldId, channelIds);
|
||||
}
|
||||
const commonCountByField = new Map<string, number>();
|
||||
for (const reference of commonReferences) {
|
||||
commonCountByField.set(reference.drainageFieldId, (commonCountByField.get(reference.drainageFieldId) ?? 0) + 1);
|
||||
}
|
||||
return fields.map((field) => ({
|
||||
...field,
|
||||
usageCount: _count.channelReportFields,
|
||||
commonUsageCount: _count.commonReportFields,
|
||||
usageCount: channelsByField.get(field.id)?.size ?? 0,
|
||||
commonUsageCount: commonCountByField.get(field.id) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -326,13 +350,20 @@ export class DictionariesService {
|
||||
|
||||
async deleteDrainageField(id: string) {
|
||||
const [usageCount, commonUsageCount] = await Promise.all([
|
||||
this.prisma.channelReportField.count({ where: { drainageFieldId: id } }),
|
||||
this.prisma.channelReportField.count({
|
||||
where: { drainageFieldId: id, channel: { status: { not: 'deleted' } } },
|
||||
}),
|
||||
this.prisma.commonReportField.count({ where: { drainageFieldId: id } }),
|
||||
]);
|
||||
if (usageCount > 0 || commonUsageCount > 0) {
|
||||
throw new BadRequestException(`该字段已被 ${usageCount} 个通道和 ${commonUsageCount} 个通用配置使用,不能删除`);
|
||||
}
|
||||
return this.prisma.drainageField.delete({ where: { id } });
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.channelReportField.deleteMany({
|
||||
where: { drainageFieldId: id, channel: { status: 'deleted' } },
|
||||
});
|
||||
return tx.drainageField.delete({ where: { id } });
|
||||
});
|
||||
}
|
||||
|
||||
listCommonReportFields() {
|
||||
|
||||
@@ -99,6 +99,21 @@ export class AdminOperationsController {
|
||||
return this.operations.sendQuality(date);
|
||||
}
|
||||
|
||||
@Get('signature-quality')
|
||||
signatureQuality(
|
||||
@Query('date') date?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.operations.signatureQuality({
|
||||
date,
|
||||
keyword,
|
||||
page: Number(page),
|
||||
pageSize: Number(pageSize),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('audit-logs')
|
||||
auditLogs(
|
||||
@Query('tenantId') tenantId?: string,
|
||||
@@ -211,6 +226,8 @@ export class AdminOperationsController {
|
||||
@Query('state') state?: string,
|
||||
@Query('failureCategory') failureCategory?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('updatedAtFrom') updatedAtFrom?: string,
|
||||
@Query('updatedAtTo') updatedAtTo?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
@@ -220,6 +237,8 @@ export class AdminOperationsController {
|
||||
state,
|
||||
failureCategory,
|
||||
keyword,
|
||||
updatedAtFrom,
|
||||
updatedAtTo,
|
||||
page: Number(page),
|
||||
pageSize: Number(pageSize),
|
||||
});
|
||||
@@ -232,6 +251,8 @@ export class AdminOperationsController {
|
||||
@Query('state') state: string | undefined,
|
||||
@Query('failureCategory') failureCategory: string | undefined,
|
||||
@Query('keyword') keyword: string | undefined,
|
||||
@Query('updatedAtFrom') updatedAtFrom: string | undefined,
|
||||
@Query('updatedAtTo') updatedAtTo: string | undefined,
|
||||
@Res() response: DownloadResponse,
|
||||
) {
|
||||
const exported = await this.operations.exportDownstreamRecoveryStatuses({
|
||||
@@ -240,6 +261,8 @@ export class AdminOperationsController {
|
||||
state,
|
||||
failureCategory,
|
||||
keyword,
|
||||
updatedAtFrom,
|
||||
updatedAtTo,
|
||||
});
|
||||
response.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
response.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(exported.fileName)}`);
|
||||
|
||||
@@ -521,6 +521,96 @@ describe('OperationsService', () => {
|
||||
await expect(service.sendQuality('2026-02-31')).rejects.toThrow('统计日期无效');
|
||||
});
|
||||
|
||||
it('returns paged registered-signature quality with channel and carrier breakdowns', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.$queryRaw
|
||||
.mockResolvedValueOnce([{
|
||||
signatureId: 'signature-1',
|
||||
signatureName: '【测试签名】',
|
||||
tenantId: 'tenant-1',
|
||||
tenantName: '租户A',
|
||||
applicationNames: '通知应用、营销应用',
|
||||
total: 5,
|
||||
acceptedCount: 4,
|
||||
submitFailureCount: 1,
|
||||
successCount: 3,
|
||||
unknownCount: 1,
|
||||
failureCount: 0,
|
||||
successRate: 75,
|
||||
averageArrivalMs: 1200,
|
||||
rowCount: 12,
|
||||
}])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
signatureId: 'signature-1',
|
||||
channelId: 'channel-1',
|
||||
channelName: '通道一',
|
||||
carrier: 'mobile',
|
||||
total: 4,
|
||||
acceptedCount: 4,
|
||||
submitFailureCount: 0,
|
||||
successCount: 3,
|
||||
unknownCount: 1,
|
||||
failureCount: 0,
|
||||
successRate: 75,
|
||||
averageArrivalMs: 1200,
|
||||
},
|
||||
{
|
||||
signatureId: 'signature-1',
|
||||
channelId: 'channel-2',
|
||||
channelName: '通道二',
|
||||
carrier: 'telecom',
|
||||
total: 2,
|
||||
acceptedCount: 1,
|
||||
submitFailureCount: 1,
|
||||
successCount: 1,
|
||||
unknownCount: 0,
|
||||
failureCount: 0,
|
||||
successRate: 100,
|
||||
averageArrivalMs: 1800,
|
||||
},
|
||||
]);
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
await expect(service.signatureQuality({
|
||||
date: '2026-07-24',
|
||||
keyword: '测试',
|
||||
page: 2,
|
||||
pageSize: 5,
|
||||
})).resolves.toEqual({
|
||||
date: '2026-07-24',
|
||||
items: [expect.objectContaining({
|
||||
signatureId: 'signature-1',
|
||||
signatureName: '【测试签名】',
|
||||
total: 5,
|
||||
channelSubmitTotal: 6,
|
||||
breakdowns: [
|
||||
expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', total: 4 }),
|
||||
expect.objectContaining({ channelId: 'channel-2', carrier: 'telecom', total: 2 }),
|
||||
],
|
||||
})],
|
||||
total: 12,
|
||||
page: 2,
|
||||
pageSize: 5,
|
||||
});
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not query channel details when the selected date has no registered signatures', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.$queryRaw.mockResolvedValueOnce([]);
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
await expect(service.signatureQuality({ date: '2026-07-24' })).resolves.toEqual({
|
||||
date: '2026-07-24',
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns trace details and reconciliation diffs', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
@@ -813,6 +903,8 @@ describe('OperationsService', () => {
|
||||
state: 'waiting_connection',
|
||||
failureCategory: 'client_disconnected',
|
||||
keyword: '100001',
|
||||
updatedAtFrom: '2026-07-02',
|
||||
updatedAtTo: '2026-07-08',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})).resolves.toEqual({
|
||||
@@ -832,6 +924,14 @@ describe('OperationsService', () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(prisma.gatewayDownstreamRecoveryStatus.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
updatedAt: {
|
||||
gte: new Date('2026-07-01T16:00:00.000Z'),
|
||||
lte: new Date('2026-07-08T15:59:59.999Z'),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns downstream recovery status detail', async () => {
|
||||
@@ -860,6 +960,8 @@ describe('OperationsService', () => {
|
||||
tenantId: 'tenant-1',
|
||||
state: 'waiting_connection',
|
||||
keyword: '100001',
|
||||
updatedAtFrom: '2026-07-02',
|
||||
updatedAtTo: '2026-07-08',
|
||||
})).resolves.toEqual(expect.objectContaining({
|
||||
total: 1,
|
||||
fileName: expect.stringMatching(/^gateway-downstream-recovery-statuses-\d{8}-\d{6}\.csv$/),
|
||||
@@ -868,6 +970,10 @@ describe('OperationsService', () => {
|
||||
expect(prisma.gatewayDownstreamRecoveryStatus.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
failureCategory: undefined,
|
||||
updatedAt: {
|
||||
gte: new Date('2026-07-01T16:00:00.000Z'),
|
||||
lte: new Date('2026-07-08T15:59:59.999Z'),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -69,6 +69,8 @@ export interface DownstreamRecoveryStatusQuery {
|
||||
state?: string;
|
||||
failureCategory?: string;
|
||||
keyword?: string;
|
||||
updatedAtFrom?: string;
|
||||
updatedAtTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -78,6 +80,13 @@ export interface MessageSegmentAuditQuery {
|
||||
messageRecordId?: string;
|
||||
}
|
||||
|
||||
export interface SignatureQualityQuery {
|
||||
date?: string;
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OperationsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -274,7 +283,8 @@ export class OperationsService {
|
||||
ON billing."tenantId" = tenant.id
|
||||
AND billing."createdAt" >= ${businessDay.startAt}
|
||||
AND billing."createdAt" < ${businessDay.endAt}
|
||||
WHERE (${query.tenantId ?? null}::text IS NULL OR tenant.id = ${query.tenantId ?? null})
|
||||
WHERE tenant.status <> 'deleted'
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR tenant.id = ${query.tenantId ?? null})
|
||||
GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents"
|
||||
ORDER BY "todaySpendCents" DESC, tenant.name ASC
|
||||
`),
|
||||
@@ -675,6 +685,228 @@ export class OperationsService {
|
||||
return { date: day.key, summary, channels, signatures, applications };
|
||||
}
|
||||
|
||||
async signatureQuality(query: SignatureQualityQuery) {
|
||||
const day = qualityBusinessDay(query.date);
|
||||
const page = positiveInteger(query.page, 1);
|
||||
const pageSize = Math.min(50, positiveInteger(query.pageSize, 10));
|
||||
const keyword = query.keyword?.trim() || null;
|
||||
const keywordPattern = keyword ? `%${keyword}%` : null;
|
||||
const summaries = await this.prisma.$queryRaw<Array<{
|
||||
signatureId: string;
|
||||
signatureName: string;
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
applicationNames: string | null;
|
||||
total: number;
|
||||
acceptedCount: number;
|
||||
submitFailureCount: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
averageArrivalMs: number | null;
|
||||
rowCount: number;
|
||||
}>>(Prisma.sql`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
message."signatureId" AS signature_id,
|
||||
message."applicationId" AS application_id,
|
||||
message.status,
|
||||
message."submitStatus" AS submit_status,
|
||||
message."receiptStatus" AS receipt_status,
|
||||
CASE
|
||||
WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
|
||||
AND message."submittedAt" IS NOT NULL
|
||||
AND message."deliveredAt" >= message."submittedAt"
|
||||
THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000
|
||||
END AS arrival_ms
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."signatureId" IS NOT NULL
|
||||
AND message."queuedAt" >= ${day.startAt}
|
||||
AND message."queuedAt" < ${day.endAt}
|
||||
)
|
||||
SELECT
|
||||
signature.id AS "signatureId",
|
||||
signature.name AS "signatureName",
|
||||
tenant.id AS "tenantId",
|
||||
tenant.name AS "tenantName",
|
||||
STRING_AGG(DISTINCT application.name, '、') FILTER (WHERE application.name IS NOT NULL) AS "applicationNames",
|
||||
COUNT(*)::integer AS total,
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
)::integer AS "acceptedCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE base.status = 'submit_failed'
|
||||
OR base.submit_status IN ('rejected', 'timeout')
|
||||
)::integer AS "submitFailureCount",
|
||||
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND NOT (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
)::integer AS "unknownCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
)::integer AS "failureCount",
|
||||
CASE
|
||||
WHEN COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
) = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
|
||||
* 100.0
|
||||
/ COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
),
|
||||
1
|
||||
)::double precision
|
||||
END AS "successRate",
|
||||
ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL))::integer AS "averageArrivalMs",
|
||||
COUNT(*) OVER()::integer AS "rowCount"
|
||||
FROM base
|
||||
JOIN "SmsSignature" signature ON signature.id = base.signature_id
|
||||
JOIN "Tenant" tenant ON tenant.id = signature."tenantId"
|
||||
LEFT JOIN "SmsApplication" application ON application.id = base.application_id
|
||||
WHERE (
|
||||
${keyword}::text IS NULL
|
||||
OR signature.name ILIKE ${keywordPattern}
|
||||
OR tenant.name ILIKE ${keywordPattern}
|
||||
OR application.name ILIKE ${keywordPattern}
|
||||
)
|
||||
GROUP BY signature.id, signature.name, tenant.id, tenant.name
|
||||
ORDER BY total DESC, signature.name
|
||||
LIMIT ${pageSize}
|
||||
OFFSET ${(page - 1) * pageSize}
|
||||
`);
|
||||
const signatureIds = summaries.map((item) => item.signatureId);
|
||||
const breakdowns = signatureIds.length === 0
|
||||
? []
|
||||
: await this.prisma.$queryRaw<Array<{
|
||||
signatureId: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
carrier: string;
|
||||
total: number;
|
||||
acceptedCount: number;
|
||||
submitFailureCount: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
averageArrivalMs: number | null;
|
||||
}>>(Prisma.sql`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
message."signatureId" AS signature_id,
|
||||
submit."channelId" AS channel_id,
|
||||
channel.name AS channel_name,
|
||||
COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier,
|
||||
submit."submitStatus" AS submit_status,
|
||||
receipt."deliveredAt" AS delivered_at,
|
||||
failed_receipt."failedAt" AS failed_at,
|
||||
COALESCE(segment_summary.segment_count, 0) AS segment_count,
|
||||
COALESCE(segment_summary.delivered_count, 0) AS segment_delivered_count,
|
||||
COALESCE(segment_summary.failure_count, 0) AS segment_failure_count,
|
||||
CASE
|
||||
WHEN segment_summary.segment_count > 0
|
||||
AND segment_summary.delivered_count = segment_summary.segment_count
|
||||
AND segment_summary.completed_at >= COALESCE(submit."submittedAt", submit."createdAt")
|
||||
THEN EXTRACT(EPOCH FROM (segment_summary.completed_at - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
|
||||
WHEN segment_summary.segment_count = 0
|
||||
AND receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt")
|
||||
THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
|
||||
END AS arrival_ms
|
||||
FROM "SmsSubmitRecord" submit
|
||||
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
|
||||
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COUNT(*)::integer AS segment_count,
|
||||
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count,
|
||||
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count,
|
||||
MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at
|
||||
FROM "SmsMessageSegmentAudit" segment
|
||||
WHERE segment."submitRecordId" = submit.id
|
||||
) segment_summary ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MIN(receipt."deliveredAt") AS "deliveredAt"
|
||||
FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'delivered'
|
||||
) receipt ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MIN(receipt."deliveredAt") AS "failedAt"
|
||||
FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'undelivered'
|
||||
) failed_receipt ON TRUE
|
||||
WHERE message."signatureId" IN (${Prisma.join(signatureIds)})
|
||||
AND submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
|
||||
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt}
|
||||
AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt}
|
||||
), classified AS (
|
||||
SELECT
|
||||
*,
|
||||
CASE
|
||||
WHEN submit_status <> 'accepted' THEN 'submit_failed'
|
||||
WHEN segment_count > 0 AND segment_failure_count > 0 THEN 'failure'
|
||||
WHEN segment_count > 0 AND segment_delivered_count = segment_count THEN 'success'
|
||||
WHEN segment_count = 0 AND failed_at IS NOT NULL THEN 'failure'
|
||||
WHEN segment_count = 0 AND delivered_at IS NOT NULL THEN 'success'
|
||||
ELSE 'unknown'
|
||||
END AS delivery_status
|
||||
FROM base
|
||||
)
|
||||
SELECT
|
||||
signature_id AS "signatureId",
|
||||
channel_id AS "channelId",
|
||||
MAX(channel_name) AS "channelName",
|
||||
carrier,
|
||||
COUNT(*)::integer AS total,
|
||||
COUNT(*) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'submit_failed')::integer AS "submitFailureCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'success')::integer AS "successCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'unknown')::integer AS "unknownCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'failure')::integer AS "failureCount",
|
||||
CASE
|
||||
WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'success')
|
||||
* 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'),
|
||||
1
|
||||
)::double precision
|
||||
END AS "successRate",
|
||||
ROUND(AVG(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL))::integer AS "averageArrivalMs"
|
||||
FROM classified
|
||||
GROUP BY signature_id, channel_id, carrier
|
||||
ORDER BY signature_id, COUNT(*) DESC, channel_id, carrier
|
||||
`);
|
||||
const items = summaries.map(({ rowCount: _rowCount, ...summary }) => {
|
||||
const signatureBreakdowns = breakdowns.filter((item) => item.signatureId === summary.signatureId);
|
||||
return {
|
||||
...summary,
|
||||
channelSubmitTotal: signatureBreakdowns.reduce((sum, item) => sum + item.total, 0),
|
||||
breakdowns: signatureBreakdowns,
|
||||
};
|
||||
});
|
||||
return {
|
||||
date: day.key,
|
||||
items,
|
||||
total: summaries[0]?.rowCount ?? 0,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async auditLogs(query: { tenantId?: string; userId?: string; page?: number; pageSize?: number }) {
|
||||
const page = positiveInteger(query.page, 1);
|
||||
const pageSize = Math.min(100, positiveInteger(query.pageSize, 20));
|
||||
@@ -1420,11 +1652,14 @@ function parseDateBoundary(value?: string, endOfDay = false) {
|
||||
}
|
||||
|
||||
function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQuery) {
|
||||
const updatedAtFrom = parseDateBoundary(query.updatedAtFrom, false);
|
||||
const updatedAtTo = parseDateBoundary(query.updatedAtTo, true);
|
||||
return {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
state: query.state && query.state !== 'all' ? query.state : undefined,
|
||||
failureCategory: query.failureCategory && query.failureCategory !== 'all' ? query.failureCategory : undefined,
|
||||
updatedAt: updatedAtFrom || updatedAtTo ? { gte: updatedAtFrom, lte: updatedAtTo } : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ account: { contains: query.keyword } },
|
||||
{ gatewayInstanceId: { contains: query.keyword } },
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { CreateImportProfileDto, CreateReportBatchDto, ImportCommitDto, ReportMaterialsService } from './report-materials.service';
|
||||
import { CreateImportProfileDto, CreateReportBatchDto, ImportCommitDto, ReportMaterialsService, ReviewImportItemsDto } from './report-materials.service';
|
||||
|
||||
type UploadedWorkbook = { originalname: string; mimetype: string; size: number; buffer: Buffer };
|
||||
type DownloadResponse = { setHeader(name: string, value: string): void; send(content: Buffer): void };
|
||||
@@ -14,8 +14,17 @@ export class ReportMaterialsController {
|
||||
constructor(private readonly service: ReportMaterialsService) {}
|
||||
|
||||
@Get('pending')
|
||||
listPending(@Query('reportType') reportType?: 'signature' | 'drainage', @Query('tenantId') tenantId?: string, @Query('applicationId') applicationId?: string) {
|
||||
return this.service.listPending({ reportType, tenantId, applicationId });
|
||||
listPending(
|
||||
@Query('reportType') reportType?: 'signature' | 'drainage',
|
||||
@Query('tenantId') tenantId?: string,
|
||||
@Query('applicationId') applicationId?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('startAt') startAt?: string,
|
||||
@Query('endAt') endAt?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.service.listPending({ reportType, tenantId, applicationId, keyword, startAt, endAt, page: Number(page), pageSize: Number(pageSize) });
|
||||
}
|
||||
|
||||
@Get('templates/:reportType')
|
||||
@@ -62,9 +71,34 @@ export class ReportMaterialsController {
|
||||
return this.service.commitImport(id, { ...body, operatorId });
|
||||
}
|
||||
|
||||
@Get('imports/review-batches')
|
||||
listImportReviewBatches(
|
||||
@Query('reportType') reportType?: 'signature' | 'drainage',
|
||||
@Query('status') status?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('startAt') startAt?: string,
|
||||
@Query('endAt') endAt?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.service.listImportReviewBatches({ reportType, status, keyword, startAt, endAt, page: Number(page), pageSize: Number(pageSize) });
|
||||
}
|
||||
|
||||
@Post('imports/:id/review')
|
||||
@RequireRecentAuthentication()
|
||||
reviewImportItems(@Param('id') id: string, @Body() body: ReviewImportItemsDto, @CurrentSessionUserId() reviewerId?: string) {
|
||||
return this.service.reviewImportItems(id, { ...body, reviewerId });
|
||||
}
|
||||
|
||||
@Get('batches')
|
||||
listBatches() {
|
||||
return this.service.listBatches();
|
||||
listBatches(
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('startAt') startAt?: string,
|
||||
@Query('endAt') endAt?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.service.listBatches({ keyword, startAt, endAt, page: Number(page), pageSize: Number(pageSize) });
|
||||
}
|
||||
|
||||
@Post('batches/preflight')
|
||||
|
||||
@@ -151,6 +151,120 @@ describe('ReportMaterialsService', () => {
|
||||
expect(prisma.reportMaterialBatch.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stages imported signatures for review without changing or approving business data', async () => {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('签名资料');
|
||||
sheet.addRow(['短信签名', '用途说明']);
|
||||
sheet.addRow(['待审签名', '验证码']);
|
||||
const content = Buffer.from(await workbook.xlsx.writeBuffer());
|
||||
const stagedRows: Array<Record<string, unknown>> = [];
|
||||
const prisma = {
|
||||
reportMaterialImportBatch: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 'import-review-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
fileObjectId: 'source-1',
|
||||
fileName: '签名资料.xlsx',
|
||||
reportType: 'signature',
|
||||
status: 'analyzed',
|
||||
sheetName: '签名资料',
|
||||
dataStartRow: 2,
|
||||
}),
|
||||
update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'import-review-1', ...data, items: stagedRows })),
|
||||
},
|
||||
reportMaterialImportItem: {
|
||||
createMany: jest.fn().mockImplementation(({ data }: { data: Array<Record<string, unknown>> }) => {
|
||||
stagedRows.push(...data);
|
||||
return Promise.resolve({ count: data.length });
|
||||
}),
|
||||
},
|
||||
smsSignature: { findFirst: jest.fn().mockResolvedValue(null) },
|
||||
operationLog: { create: jest.fn().mockResolvedValue({ id: 'log-import-review' }) },
|
||||
$transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)),
|
||||
};
|
||||
const files = { getDownload: jest.fn().mockResolvedValue({ content }) };
|
||||
const smsConfig = {
|
||||
createSignature: jest.fn(),
|
||||
updateSignature: jest.fn(),
|
||||
approveSignature: jest.fn(),
|
||||
};
|
||||
const service = new ReportMaterialsService(prisma as never, files as never, smsConfig as never);
|
||||
|
||||
const result = await service.commitImport('import-review-1', {
|
||||
operatorId: 'operator-1',
|
||||
mappings: [
|
||||
{ sourceHeader: '短信签名', sourceColumnIndex: 1, targetFieldCode: 'signature_name', targetKind: 'signatureName', fieldType: 'string', required: true },
|
||||
{ sourceHeader: '用途说明', sourceColumnIndex: 2, targetFieldCode: 'purpose', targetKind: 'purpose', fieldType: 'string' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ status: 'pending_review', successCount: 1, failedCount: 0 });
|
||||
expect(stagedRows).toEqual([expect.objectContaining({
|
||||
rowNumber: 2,
|
||||
reportType: 'signature',
|
||||
operation: 'create',
|
||||
status: 'pending_review',
|
||||
payload: expect.objectContaining({ name: '待审签名', purpose: '验证码' }),
|
||||
})]);
|
||||
expect(smsConfig.createSignature).not.toHaveBeenCalled();
|
||||
expect(smsConfig.updateSignature).not.toHaveBeenCalled();
|
||||
expect(smsConfig.approveSignature).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows rejecting selected imported rows without requiring a reason', async () => {
|
||||
const prisma = {
|
||||
reportMaterialImportBatch: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 'import-review-2',
|
||||
items: [{ id: 'item-1', rowNumber: 2, status: 'pending_review' }],
|
||||
}),
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
reportMaterialImportItem: {
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
groupBy: jest.fn().mockResolvedValue([{ status: 'rejected', _count: { _all: 1 } }]),
|
||||
},
|
||||
};
|
||||
const service = new ReportMaterialsService(prisma as never, {} as never, {} as never);
|
||||
|
||||
await expect(service.reviewImportItems('import-review-2', {
|
||||
decision: 'reject',
|
||||
itemIds: ['item-1'],
|
||||
reviewerId: 'reviewer-1',
|
||||
})).resolves.toMatchObject({ status: 'rejected', rejectedCount: 1, failedCount: 0 });
|
||||
expect(prisma.reportMaterialImportItem.update).toHaveBeenCalledWith({
|
||||
where: { id: 'item-1' },
|
||||
data: expect.objectContaining({ status: 'rejected', reviewReason: undefined, reviewedById: 'reviewer-1' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('calculates generated batch totals and success rate from per-channel report tasks', async () => {
|
||||
const prisma = {
|
||||
reportMaterialBatch: {
|
||||
findMany: jest.fn().mockResolvedValue([{
|
||||
id: 'batch-stats-1',
|
||||
batchNo: 'RB-STATS-1',
|
||||
exportFiles: [
|
||||
{ items: [{ task: { id: 'task-1', status: 'approved' } }, { task: { id: 'task-2', status: 'rejected' } }] },
|
||||
{ items: [{ task: { id: 'task-3', status: 'approved' } }] },
|
||||
],
|
||||
items: [],
|
||||
}]),
|
||||
count: jest.fn().mockResolvedValue(1),
|
||||
},
|
||||
};
|
||||
const service = new ReportMaterialsService(prisma as never, {} as never, {} as never);
|
||||
|
||||
await expect(service.listBatches({ keyword: 'RB-STATS', page: 2, pageSize: 10 })).resolves.toMatchObject({
|
||||
items: [{ id: 'batch-stats-1', reportTotal: 3, successCount: 2, successRate: 2 / 3 }],
|
||||
total: 1,
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
});
|
||||
expect(prisma.reportMaterialBatch.findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 10, take: 10 }));
|
||||
});
|
||||
|
||||
it('rejects malformed preflight items as a readable 400 before Prisma is called', async () => {
|
||||
const prisma = { smsSignature: { findUnique: jest.fn() } };
|
||||
const service = new ReportMaterialsService(prisma as never, {} as never, {} as never);
|
||||
|
||||
@@ -38,6 +38,21 @@ export interface ImportCommitDto {
|
||||
operatorId?: string;
|
||||
}
|
||||
|
||||
export interface ReviewImportItemsDto {
|
||||
decision: 'approve' | 'reject';
|
||||
itemIds?: string[];
|
||||
reason?: string;
|
||||
reviewerId?: string;
|
||||
}
|
||||
|
||||
type PagedQuery = {
|
||||
keyword?: string;
|
||||
startAt?: string;
|
||||
endAt?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
export interface CreateReportBatchDto {
|
||||
createdById?: string;
|
||||
idempotencyKey?: string;
|
||||
@@ -105,7 +120,7 @@ export class ReportMaterialsService {
|
||||
}
|
||||
|
||||
async exportPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string }, operatorId?: string) {
|
||||
const items = await this.listPending(query);
|
||||
const items = await this.findPendingItems(query);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('待报备资料', { views: [{ state: 'frozen', ySplit: 1 }] });
|
||||
sheet.addRow(['资料类型', '企业', '企业应用', '签名/站点', '详情', '变更时间']);
|
||||
@@ -123,15 +138,53 @@ export class ReportMaterialsService {
|
||||
return { fileName, content: Buffer.from(await workbook.xlsx.writeBuffer()) };
|
||||
}
|
||||
|
||||
async listPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string }) {
|
||||
async listPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery) {
|
||||
const items = await this.findPendingItems(query);
|
||||
const page = normalizePage(query.page);
|
||||
const pageSize = normalizePageSize(query.pageSize);
|
||||
return {
|
||||
items: items.slice((page - 1) * pageSize, page * pageSize),
|
||||
total: items.length,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
private async findPendingItems(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery) {
|
||||
const changedAt = dateRange(query.startAt, query.endAt);
|
||||
const keyword = query.keyword?.trim();
|
||||
const [signatures, drainageInfos] = await Promise.all([
|
||||
query.reportType === 'drainage' ? Promise.resolve([]) : this.prisma.smsSignature.findMany({
|
||||
where: { pendingReport: true, auditStatus: 'approved', tenantId: query.tenantId, applicationId: query.applicationId },
|
||||
where: {
|
||||
pendingReport: true,
|
||||
auditStatus: 'approved',
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
reportChangedAt: changedAt,
|
||||
OR: keyword ? [
|
||||
{ name: { contains: keyword } },
|
||||
{ tenant: { name: { contains: keyword } } },
|
||||
{ application: { name: { contains: keyword } } },
|
||||
] : undefined,
|
||||
},
|
||||
include: { tenant: true, application: true },
|
||||
orderBy: { reportChangedAt: 'desc' },
|
||||
}),
|
||||
query.reportType === 'signature' ? Promise.resolve([]) : this.prisma.smsDrainageInfo.findMany({
|
||||
where: { pendingReport: true, auditStatus: 'approved', tenantId: query.tenantId, applicationId: query.applicationId },
|
||||
where: {
|
||||
pendingReport: true,
|
||||
auditStatus: 'approved',
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
reportChangedAt: changedAt,
|
||||
OR: keyword ? [
|
||||
{ siteName: { contains: keyword } },
|
||||
{ url: { contains: keyword } },
|
||||
{ signature: { name: { contains: keyword } } },
|
||||
{ tenant: { name: { contains: keyword } } },
|
||||
{ application: { name: { contains: keyword } } },
|
||||
] : undefined,
|
||||
},
|
||||
include: { tenant: true, application: true, signature: true },
|
||||
orderBy: { reportChangedAt: 'desc' },
|
||||
}),
|
||||
@@ -246,6 +299,7 @@ export class ReportMaterialsService {
|
||||
async commitImport(batchId: string, data: ImportCommitDto) {
|
||||
const batch = await this.prisma.reportMaterialImportBatch.findUnique({ where: { id: batchId } });
|
||||
if (!batch) throw new NotFoundException('导入批次不存在');
|
||||
if (batch.status !== 'analyzed') throw new ConflictException('该导入批次已提交审核,不能重复导入');
|
||||
if (!data.mappings?.length) throw new BadRequestException('请至少配置一个导入字段映射');
|
||||
if (data.profile) await this.saveImportProfile({ ...data.profile, reportType: batch.reportType as 'signature' | 'drainage', columns: data.mappings });
|
||||
const { content } = await this.files.getDownload(batch.fileObjectId);
|
||||
@@ -257,9 +311,10 @@ export class ReportMaterialsService {
|
||||
const imageByCell = new Map(images.map((image) => [`${image.row}:${image.column}`, image]));
|
||||
let successCount = 0;
|
||||
const failures: Array<{ rowNumber: number; reason: string }> = [];
|
||||
const stagedItems: Prisma.ReportMaterialImportItemCreateManyInput[] = [];
|
||||
for (let rowNumber = batch.dataStartRow; rowNumber <= worksheet.rowCount; rowNumber += 1) {
|
||||
const values: Record<string, unknown> = {};
|
||||
try {
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const mapping of data.mappings) {
|
||||
const image = imageByCell.get(`${rowNumber}:${mapping.sourceColumnIndex}`);
|
||||
if (image && mapping.fieldType !== 'string') {
|
||||
@@ -278,23 +333,47 @@ export class ReportMaterialsService {
|
||||
for (const mapping of data.mappings.filter((item) => item.required)) {
|
||||
if (!hasValue(values[mapping.targetFieldCode])) throw new Error(`缺少必填字段:${mapping.sourceHeader}`);
|
||||
}
|
||||
if (batch.reportType === 'signature') await this.importSignatureRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values);
|
||||
else await this.importDrainageRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values);
|
||||
const staged = batch.reportType === 'signature'
|
||||
? await this.stageSignatureRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values)
|
||||
: await this.stageDrainageRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values);
|
||||
stagedItems.push({
|
||||
batchId,
|
||||
rowNumber,
|
||||
reportType: batch.reportType,
|
||||
operation: staged.operation,
|
||||
targetId: staged.targetId,
|
||||
status: 'pending_review',
|
||||
payload: staged.payload as Prisma.InputJsonValue,
|
||||
originalSnapshot: staged.originalSnapshot as Prisma.InputJsonValue | undefined,
|
||||
});
|
||||
successCount += 1;
|
||||
} catch (error) {
|
||||
failures.push({ rowNumber, reason: error instanceof Error ? error.message : '导入失败' });
|
||||
const reason = error instanceof Error ? error.message : '导入失败';
|
||||
failures.push({ rowNumber, reason });
|
||||
stagedItems.push({
|
||||
batchId,
|
||||
rowNumber,
|
||||
reportType: batch.reportType,
|
||||
operation: 'invalid',
|
||||
status: 'invalid',
|
||||
payload: values as Prisma.InputJsonValue,
|
||||
errorMessage: reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
const updated = await this.prisma.reportMaterialImportBatch.update({
|
||||
where: { id: batchId },
|
||||
data: {
|
||||
status: failures.length ? (successCount ? 'partial_failed' : 'failed') : 'completed',
|
||||
mapping: data.mappings as Prisma.InputJsonValue,
|
||||
result: { failures } as Prisma.InputJsonValue,
|
||||
successCount,
|
||||
failedCount: failures.length,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
if (stagedItems.length) await tx.reportMaterialImportItem.createMany({ data: stagedItems });
|
||||
return tx.reportMaterialImportBatch.update({
|
||||
where: { id: batchId },
|
||||
data: {
|
||||
status: successCount ? 'pending_review' : 'failed',
|
||||
mapping: data.mappings as Prisma.InputJsonValue,
|
||||
result: { failures } as Prisma.InputJsonValue,
|
||||
successCount,
|
||||
failedCount: failures.length,
|
||||
},
|
||||
include: { items: { orderBy: { rowNumber: 'asc' } } },
|
||||
});
|
||||
});
|
||||
await this.prisma.operationLog.create({ data: {
|
||||
tenantId: batch.tenantId, userId: data.operatorId, action: 'report_material.import_committed', resource: 'report_material_import', resourceId: batch.id,
|
||||
@@ -303,12 +382,167 @@ export class ReportMaterialsService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
listBatches() {
|
||||
return this.prisma.reportMaterialBatch.findMany({
|
||||
include: { exportFiles: true, items: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
async listImportReviewBatches(query: PagedQuery & { reportType?: 'signature' | 'drainage'; status?: string } = {}) {
|
||||
const page = normalizePage(query.page);
|
||||
const pageSize = normalizePageSize(query.pageSize);
|
||||
const where: Prisma.ReportMaterialImportBatchWhereInput = {
|
||||
reportType: query.reportType,
|
||||
status: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
createdAt: dateRange(query.startAt, query.endAt),
|
||||
OR: query.keyword?.trim() ? [
|
||||
{ fileName: { contains: query.keyword.trim() } },
|
||||
{ id: { contains: query.keyword.trim() } },
|
||||
] : undefined,
|
||||
};
|
||||
const [batches, total] = await Promise.all([
|
||||
this.prisma.reportMaterialImportBatch.findMany({
|
||||
where,
|
||||
include: { items: { orderBy: { rowNumber: 'asc' } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.reportMaterialImportBatch.count({ where }),
|
||||
]);
|
||||
const tenantIds = [...new Set(batches.map((batch) => batch.tenantId))];
|
||||
const applicationIds = [...new Set(batches.map((batch) => batch.applicationId).filter((id): id is string => Boolean(id)))];
|
||||
const reviewerIds = [...new Set(batches.flatMap((batch) => [
|
||||
batch.reviewedById,
|
||||
...batch.items.map((item) => item.reviewedById),
|
||||
]).filter((id): id is string => Boolean(id)))];
|
||||
const [tenants, applications, reviewers] = await Promise.all([
|
||||
tenantIds.length ? this.prisma.tenant.findMany({ where: { id: { in: tenantIds } }, select: { id: true, name: true } }) : [],
|
||||
applicationIds.length ? this.prisma.smsApplication.findMany({ where: { id: { in: applicationIds } }, select: { id: true, name: true } }) : [],
|
||||
reviewerIds.length ? this.prisma.user.findMany({ where: { id: { in: reviewerIds } }, select: { id: true, username: true, displayName: true } }) : [],
|
||||
]);
|
||||
const tenantById = new Map(tenants.map((item) => [item.id, item]));
|
||||
const applicationById = new Map(applications.map((item) => [item.id, item]));
|
||||
const reviewerById = new Map(reviewers.map((item) => [item.id, item]));
|
||||
return {
|
||||
items: batches.map((batch) => ({
|
||||
...batch,
|
||||
tenant: tenantById.get(batch.tenantId) ?? null,
|
||||
application: batch.applicationId ? applicationById.get(batch.applicationId) ?? null : null,
|
||||
reviewer: batch.reviewedById ? reviewerById.get(batch.reviewedById) ?? null : null,
|
||||
items: batch.items.map((item) => ({
|
||||
...item,
|
||||
reviewer: item.reviewedById ? reviewerById.get(item.reviewedById) ?? null : null,
|
||||
})),
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async reviewImportItems(batchId: string, data: ReviewImportItemsDto) {
|
||||
if (!data.reviewerId) throw new BadRequestException('Reviewer session is required');
|
||||
if (!['approve', 'reject'].includes(data.decision)) throw new BadRequestException('Unsupported import review decision');
|
||||
const batch = await this.prisma.reportMaterialImportBatch.findUnique({
|
||||
where: { id: batchId },
|
||||
include: { items: { where: { id: data.itemIds?.length ? { in: data.itemIds } : undefined, status: 'pending_review' }, orderBy: { rowNumber: 'asc' } } },
|
||||
});
|
||||
if (!batch) throw new NotFoundException('导入审核批次不存在');
|
||||
if (!batch.items.length) throw new BadRequestException('没有可审核的导入明细');
|
||||
let approvedCount = 0;
|
||||
let rejectedCount = 0;
|
||||
const failures: Array<{ itemId: string; rowNumber: number; reason: string }> = [];
|
||||
for (const item of batch.items) {
|
||||
if (data.decision === 'reject') {
|
||||
await this.prisma.reportMaterialImportItem.update({
|
||||
where: { id: item.id },
|
||||
data: { status: 'rejected', reviewReason: data.reason?.trim(), reviewedById: data.reviewerId, reviewedAt: new Date() },
|
||||
});
|
||||
rejectedCount += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const targetId = await this.applyImportItem(batch, item, data.reviewerId);
|
||||
await this.prisma.reportMaterialImportItem.update({
|
||||
where: { id: item.id },
|
||||
data: { targetId, status: 'approved', reviewReason: data.reason?.trim(), reviewedById: data.reviewerId, reviewedAt: new Date(), errorMessage: null },
|
||||
});
|
||||
approvedCount += 1;
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : '导入审核应用失败';
|
||||
failures.push({ itemId: item.id, rowNumber: item.rowNumber, reason });
|
||||
await this.prisma.reportMaterialImportItem.update({
|
||||
where: { id: item.id },
|
||||
data: { status: 'invalid', errorMessage: reason, reviewedById: data.reviewerId, reviewedAt: new Date() },
|
||||
});
|
||||
}
|
||||
}
|
||||
const counts = await this.prisma.reportMaterialImportItem.groupBy({
|
||||
by: ['status'],
|
||||
where: { batchId },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const countByStatus = new Map(counts.map((item) => [item.status, item._count._all]));
|
||||
const pendingCount = countByStatus.get('pending_review') ?? 0;
|
||||
const totalApproved = countByStatus.get('approved') ?? 0;
|
||||
const totalRejected = countByStatus.get('rejected') ?? 0;
|
||||
const totalInvalid = countByStatus.get('invalid') ?? 0;
|
||||
const status = pendingCount
|
||||
? 'partially_reviewed'
|
||||
: totalApproved && (totalRejected || totalInvalid)
|
||||
? 'partially_approved'
|
||||
: totalApproved
|
||||
? 'approved'
|
||||
: totalRejected
|
||||
? 'rejected'
|
||||
: 'failed';
|
||||
await this.prisma.reportMaterialImportBatch.update({
|
||||
where: { id: batchId },
|
||||
data: {
|
||||
status,
|
||||
reviewedById: pendingCount ? undefined : data.reviewerId,
|
||||
reviewedAt: pendingCount ? undefined : new Date(),
|
||||
completedAt: pendingCount ? undefined : new Date(),
|
||||
},
|
||||
});
|
||||
return { batchId, status, approvedCount, rejectedCount, failedCount: failures.length, failures };
|
||||
}
|
||||
|
||||
async listBatches(query: PagedQuery = {}) {
|
||||
const page = normalizePage(query.page);
|
||||
const pageSize = normalizePageSize(query.pageSize);
|
||||
const where: Prisma.ReportMaterialBatchWhereInput = {
|
||||
createdAt: dateRange(query.startAt, query.endAt),
|
||||
batchNo: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
|
||||
};
|
||||
const [batches, total] = await Promise.all([
|
||||
this.prisma.reportMaterialBatch.findMany({
|
||||
where,
|
||||
include: {
|
||||
exportFiles: {
|
||||
include: {
|
||||
items: { include: { task: { select: { id: true, status: true } } } },
|
||||
},
|
||||
},
|
||||
items: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.reportMaterialBatch.count({ where }),
|
||||
]);
|
||||
return {
|
||||
items: batches.map((batch) => {
|
||||
const reportItems = batch.exportFiles.flatMap((file) => file.items);
|
||||
const reportTotal = reportItems.length;
|
||||
const successCount = reportItems.filter((item) => item.task.status === 'approved').length;
|
||||
return {
|
||||
...batch,
|
||||
reportTotal,
|
||||
successCount,
|
||||
successRate: reportTotal ? successCount / reportTotal : 0,
|
||||
};
|
||||
}),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async createBatch(data: CreateReportBatchDto) {
|
||||
@@ -402,17 +636,35 @@ export class ReportMaterialsService {
|
||||
};
|
||||
}
|
||||
|
||||
private async importSignatureRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record<string, unknown>) {
|
||||
private async stageSignatureRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record<string, unknown>) {
|
||||
const name = mappedCoreValue(mappings, values, 'signatureName');
|
||||
if (!name) throw new Error('缺少短信签名');
|
||||
const purpose = mappedCoreValue(mappings, values, 'purpose');
|
||||
const signatureReportValues = dynamicValues(mappings, values);
|
||||
const existing = await this.prisma.smsSignature.findFirst({ where: { tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } } });
|
||||
if (existing) return this.smsConfig.updateSignature(existing.id, { applicationId, name, purpose, drainageInfo: { ...jsonRecord(existing.drainageInfo), signatureReportValues } });
|
||||
return this.smsConfig.createSignature({ tenantId, applicationId, name, purpose, drainageInfo: { signatureReportValues } }, { initialAuditStatus: 'approved' });
|
||||
return {
|
||||
operation: existing ? 'update' : 'create',
|
||||
targetId: existing?.id,
|
||||
payload: {
|
||||
tenantId,
|
||||
applicationId,
|
||||
name,
|
||||
purpose,
|
||||
drainageInfo: { ...jsonRecord(existing?.drainageInfo), signatureReportValues },
|
||||
},
|
||||
originalSnapshot: existing ? {
|
||||
id: existing.id,
|
||||
applicationId: existing.applicationId,
|
||||
name: existing.name,
|
||||
purpose: existing.purpose,
|
||||
drainageInfo: existing.drainageInfo,
|
||||
auditStatus: existing.auditStatus,
|
||||
updatedAt: existing.updatedAt,
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async importDrainageRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record<string, unknown>) {
|
||||
private async stageDrainageRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record<string, unknown>) {
|
||||
const signatureName = mappedCoreValue(mappings, values, 'signatureName');
|
||||
const siteName = mappedCoreValue(mappings, values, 'siteName');
|
||||
const url = mappedCoreValue(mappings, values, 'url');
|
||||
@@ -422,8 +674,85 @@ export class ReportMaterialsService {
|
||||
const remark = mappedCoreValue(mappings, values, 'remark');
|
||||
const reportValues = dynamicValues(mappings, values);
|
||||
const existing = await this.prisma.smsDrainageInfo.findFirst({ where: { signatureId: signature.id, url, auditStatus: { not: 'deleted' } } });
|
||||
if (existing) return this.smsConfig.updateDrainageInfo(existing.id, { siteName, url, remark, reportValues }, { initialAuditStatus: 'approved' });
|
||||
return this.smsConfig.createDrainageInfo(signature.id, { siteName, url, remark, reportValues }, { initialAuditStatus: 'approved' });
|
||||
return {
|
||||
operation: existing ? 'update' : 'create',
|
||||
targetId: existing?.id,
|
||||
payload: { tenantId, applicationId, signatureId: signature.id, signatureName, siteName, url, remark, reportValues },
|
||||
originalSnapshot: existing ? {
|
||||
id: existing.id,
|
||||
siteName: existing.siteName,
|
||||
url: existing.url,
|
||||
remark: existing.remark,
|
||||
reportValues: existing.reportValues,
|
||||
auditStatus: existing.auditStatus,
|
||||
updatedAt: existing.updatedAt,
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async applyImportItem(
|
||||
batch: { tenantId: string; applicationId: string | null; reportType: string },
|
||||
item: { reportType: string; targetId: string | null; payload: Prisma.JsonValue },
|
||||
reviewerId: string,
|
||||
) {
|
||||
const payload = jsonRecord(item.payload);
|
||||
if (item.reportType === 'signature') {
|
||||
const name = String(payload.name ?? '');
|
||||
const applicationId = typeof payload.applicationId === 'string' ? payload.applicationId : undefined;
|
||||
const body = {
|
||||
applicationId,
|
||||
name,
|
||||
purpose: typeof payload.purpose === 'string' ? payload.purpose : undefined,
|
||||
drainageInfo: jsonRecord(payload.drainageInfo),
|
||||
};
|
||||
let targetId = item.targetId;
|
||||
if (targetId) {
|
||||
const current = await this.prisma.smsSignature.findUnique({ where: { id: targetId } });
|
||||
if (!current || current.auditStatus === 'deleted') throw new Error('原签名已删除,不能应用导入修改');
|
||||
await this.smsConfig.updateSignature(targetId, body, batch.tenantId);
|
||||
} else {
|
||||
const duplicate = await this.prisma.smsSignature.findFirst({
|
||||
where: { tenantId: batch.tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } },
|
||||
});
|
||||
if (duplicate) {
|
||||
targetId = duplicate.id;
|
||||
await this.smsConfig.updateSignature(targetId, body, batch.tenantId);
|
||||
} else {
|
||||
const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...body });
|
||||
targetId = created.id;
|
||||
}
|
||||
}
|
||||
await this.smsConfig.approveSignature(targetId, { reviewerId, reason: `批量导入审核通过:${name}` });
|
||||
return targetId;
|
||||
}
|
||||
const signatureId = String(payload.signatureId ?? '');
|
||||
const siteName = String(payload.siteName ?? '');
|
||||
const url = String(payload.url ?? '');
|
||||
const body = {
|
||||
siteName,
|
||||
url,
|
||||
remark: typeof payload.remark === 'string' ? payload.remark : undefined,
|
||||
reportValues: jsonRecord(payload.reportValues),
|
||||
};
|
||||
let targetId = item.targetId;
|
||||
if (targetId) {
|
||||
const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: targetId } });
|
||||
if (!current || current.auditStatus === 'deleted') throw new Error('原引流信息已删除,不能应用导入修改');
|
||||
await this.smsConfig.updateDrainageInfo(targetId, body, { initialAuditStatus: 'pending' }, batch.tenantId);
|
||||
} else {
|
||||
const duplicate = await this.prisma.smsDrainageInfo.findFirst({
|
||||
where: { signatureId, url, auditStatus: { not: 'deleted' } },
|
||||
});
|
||||
if (duplicate) {
|
||||
targetId = duplicate.id;
|
||||
await this.smsConfig.updateDrainageInfo(targetId, body, { initialAuditStatus: 'pending' }, batch.tenantId);
|
||||
} else {
|
||||
const created = await this.smsConfig.createDrainageInfo(signatureId, body, { initialAuditStatus: 'pending' }, batch.tenantId);
|
||||
targetId = created.id;
|
||||
}
|
||||
}
|
||||
await this.smsConfig.approveDrainageInfo(targetId, { reviewerId, reason: `批量导入审核通过:${siteName || url}` });
|
||||
return targetId;
|
||||
}
|
||||
|
||||
private async prepareBatchItem(batchId: string, selected: CreateReportBatchDto['items'][number], inspection: ReportBatchInspection) {
|
||||
@@ -707,6 +1036,15 @@ function drainageCoreMapping(header: string): { code: string; kind: ImportMappin
|
||||
function normalizeHeader(value: string) { return value.toLowerCase().replace(/[\s**::()()_-]/g, ''); }
|
||||
function normalizeFieldCode(value: string) { return `import_${value.trim().toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_').slice(0, 40) || randomUUID().slice(0, 8)}`; }
|
||||
function clamp(value: number, minimum: number, maximum: number) { return Math.min(maximum, Math.max(minimum, Number.isFinite(value) ? Math.round(value) : minimum)); }
|
||||
function normalizePage(value?: number) { return Math.max(1, Math.floor(Number(value) || 1)); }
|
||||
function normalizePageSize(value?: number) { return Math.min(100, Math.max(1, Math.floor(Number(value) || 20))); }
|
||||
function dateRange(startAt?: string, endAt?: string) {
|
||||
const start = startAt ? new Date(`${startAt}T00:00:00+08:00`) : undefined;
|
||||
const end = endAt ? new Date(`${endAt}T23:59:59.999+08:00`) : undefined;
|
||||
if (start && Number.isNaN(start.getTime())) throw new BadRequestException('开始日期无效');
|
||||
if (end && Number.isNaN(end.getTime())) throw new BadRequestException('结束日期无效');
|
||||
return start || end ? { gte: start, lte: end } : undefined;
|
||||
}
|
||||
|
||||
function cellText(cell: ExcelJS.Cell) {
|
||||
const value = cell.value;
|
||||
|
||||
@@ -41,6 +41,19 @@ describe('ReportsService', () => {
|
||||
expect(tx.$executeRaw).toHaveBeenCalledTimes(28);
|
||||
});
|
||||
|
||||
it('calculates profit cost from channel unit price times delivered fragment count', async () => {
|
||||
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
|
||||
const firstDayQueries = tx.$executeRaw.mock.calls.slice(0, 7).map(([query]) =>
|
||||
Array.isArray(query?.strings) ? query.strings.join(' ') : String(query),
|
||||
);
|
||||
const profitQueries = firstDayQueries.slice(1, 3).join('\n');
|
||||
|
||||
expect(profitQueries).toContain('"SmsMessageSegmentAudit"');
|
||||
expect(profitQueries).toContain(`FILTER (WHERE audit."receiptStatus" = 'delivered')`);
|
||||
expect(profitQueries).toContain('submit."costUnitPrice"');
|
||||
expect(profitQueries).not.toContain('SUM(submit."costAmountCents")');
|
||||
});
|
||||
|
||||
it('queries reconciliation reports with server-side filters and bounded pagination', async () => {
|
||||
await expect(service.listReconciliation({
|
||||
dateFrom: '2026-07-01',
|
||||
|
||||
@@ -156,8 +156,31 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
FROM "SmsBillingRecord"
|
||||
GROUP BY "messageId"
|
||||
), costs AS (
|
||||
SELECT submit."messageRecordId", SUM(submit."costAmountCents")::bigint AS cost
|
||||
SELECT
|
||||
submit."messageRecordId",
|
||||
SUM(submit."costUnitPrice" * CASE
|
||||
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
|
||||
WHEN legacy_receipt.delivered THEN message."billingUnits"
|
||||
ELSE 0
|
||||
END)::bigint AS cost
|
||||
FROM "SmsSubmitRecord" submit
|
||||
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COUNT(*)::integer AS audit_count,
|
||||
COUNT(*) FILTER (WHERE audit."receiptStatus" = 'delivered')::integer AS delivered_count
|
||||
FROM "SmsMessageSegmentAudit" audit
|
||||
WHERE audit."submitRecordId" = submit.id
|
||||
) segment_receipts ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'delivered'
|
||||
) AS delivered
|
||||
) legacy_receipt ON TRUE
|
||||
WHERE submit."submitStatus" = 'accepted'
|
||||
GROUP BY submit."messageRecordId"
|
||||
)
|
||||
@@ -256,10 +279,22 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
) OR submit."submitStatus" IN ('rejected', 'timeout')) THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0)::bigint,
|
||||
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.refund ELSE 0 END), 0)::bigint,
|
||||
COALESCE(SUM(submit."costAmountCents"), 0)::bigint,
|
||||
(COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costAmountCents"), 0))::bigint,
|
||||
COALESCE(SUM(submit."costUnitPrice" * CASE
|
||||
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
|
||||
WHEN legacy_receipt.delivered THEN message."billingUnits"
|
||||
ELSE 0
|
||||
END), 0)::bigint,
|
||||
(COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costUnitPrice" * CASE
|
||||
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
|
||||
WHEN legacy_receipt.delivered THEN message."billingUnits"
|
||||
ELSE 0
|
||||
END), 0))::bigint,
|
||||
CASE WHEN COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) = 0 THEN 0
|
||||
ELSE ROUND((COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costAmountCents"), 0)) * 10000.0 /
|
||||
ELSE ROUND((COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costUnitPrice" * CASE
|
||||
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
|
||||
WHEN legacy_receipt.delivered THEN message."billingUnits"
|
||||
ELSE 0
|
||||
END), 0)) * 10000.0 /
|
||||
SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END))::integer END,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
@@ -267,6 +302,22 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
|
||||
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
|
||||
LEFT JOIN billing ON billing."messageId" = message."messageId"
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COUNT(*)::integer AS audit_count,
|
||||
COUNT(*) FILTER (WHERE audit."receiptStatus" = 'delivered')::integer AS delivered_count
|
||||
FROM "SmsMessageSegmentAudit" audit
|
||||
WHERE audit."submitRecordId" = submit.id
|
||||
) segment_receipts ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'delivered'
|
||||
) AS delivered
|
||||
) legacy_receipt ON TRUE
|
||||
WHERE submit."submitStatus" = 'accepted'
|
||||
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt}
|
||||
AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt}
|
||||
|
||||
@@ -61,6 +61,21 @@ function createPrismaMock(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
describe('RiskReviewService', () => {
|
||||
it('includes the sending enterprise and application in SMS review rows', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsSendTask.findMany.mockResolvedValue([]);
|
||||
const service = new RiskReviewService(prisma as never);
|
||||
|
||||
await service.listTasks(undefined, 'pending_review');
|
||||
|
||||
expect(prisma.smsSendTask.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
include: expect.objectContaining({
|
||||
tenant: { select: { id: true, name: true } },
|
||||
application: { select: { id: true, name: true } },
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('groups identical CMPP template mismatches into a deterministic short review window', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsSendTask.findUnique.mockResolvedValue({
|
||||
|
||||
@@ -200,6 +200,8 @@ export class RiskReviewService {
|
||||
},
|
||||
include: {
|
||||
riskHits: true,
|
||||
tenant: { select: { id: true, name: true } },
|
||||
application: { select: { id: true, name: true } },
|
||||
reviewedBy: { select: { id: true, username: true, displayName: true } },
|
||||
_count: { select: { messageRecords: true } },
|
||||
},
|
||||
|
||||
@@ -12,6 +12,16 @@ export class AdminSendChainController {
|
||||
return this.sendChain.listBatchTasks(tenantId, status);
|
||||
}
|
||||
|
||||
@Get('batch-tasks/:id/messages')
|
||||
listBatchTaskMessages(
|
||||
@Param('id') taskId: string,
|
||||
@Query('phone') phone?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.sendChain.listAdminBatchTaskMessages(taskId, phone, Number(page), Number(pageSize));
|
||||
}
|
||||
|
||||
@Get('messages')
|
||||
listMessages(
|
||||
@Query('tenantId') tenantId?: string,
|
||||
|
||||
@@ -128,6 +128,7 @@ function createPrismaMock() {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...message, ...data })),
|
||||
createMany: jest.fn().mockResolvedValue({ count: 2 }),
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'record-1', batchTaskId: 'task-1' }]),
|
||||
count: jest.fn().mockResolvedValue(1),
|
||||
findUnique: jest.fn().mockResolvedValue(message),
|
||||
findFirst: jest.fn().mockResolvedValue(message),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...message, ...data })),
|
||||
@@ -790,6 +791,35 @@ describe('SendChainService', () => {
|
||||
expect(prisma.smsMessageRecord.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('paginates the real phone list for an admin batch task', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsBatchTask.findFirst.mockResolvedValue({ id: 'task-1', sourceType: 'client' });
|
||||
prisma.smsMessageRecord.findMany.mockResolvedValue([
|
||||
{ id: 'record-1', phoneNumber: '13800000001', province: '上海', carrier: 'mobile', status: 'delivered' },
|
||||
]);
|
||||
prisma.smsMessageRecord.count.mockResolvedValue(21);
|
||||
|
||||
await expect(service.listAdminBatchTaskMessages('task-1', '138', 2, 20)).resolves.toEqual({
|
||||
items: [expect.objectContaining({ phoneNumber: '13800000001' })],
|
||||
total: 21,
|
||||
page: 2,
|
||||
pageSize: 20,
|
||||
});
|
||||
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith({
|
||||
where: { batchTaskId: 'task-1', phoneNumber: { contains: '138' } },
|
||||
select: {
|
||||
id: true,
|
||||
phoneNumber: true,
|
||||
province: true,
|
||||
carrier: true,
|
||||
status: true,
|
||||
},
|
||||
orderBy: [{ queuedAt: 'asc' }, { id: 'asc' }],
|
||||
skip: 20,
|
||||
take: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('terminates non-final tasks by canceling unsubmitted messages', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsBatchTask.findUnique.mockResolvedValue({ id: 'task-1', status: 'sending' });
|
||||
|
||||
@@ -599,6 +599,39 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
return this.listMessages({ tenantId, taskId });
|
||||
}
|
||||
|
||||
async listAdminBatchTaskMessages(taskId: string, phone?: string, page = 1, pageSize = 20) {
|
||||
const task = await this.prisma.smsBatchTask.findFirst({
|
||||
where: { id: taskId, sourceType: 'client' },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!task) {
|
||||
throw new NotFoundException('SMS batch task not found');
|
||||
}
|
||||
const normalizedPage = Math.max(1, Math.floor(Number(page) || 1));
|
||||
const normalizedPageSize = Math.min(100, Math.max(1, Math.floor(Number(pageSize) || 20)));
|
||||
const where: Prisma.SmsMessageRecordWhereInput = {
|
||||
batchTaskId: taskId,
|
||||
...(phone?.trim() ? { phoneNumber: { contains: phone.trim() } } : {}),
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.smsMessageRecord.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
phoneNumber: true,
|
||||
province: true,
|
||||
carrier: true,
|
||||
status: true,
|
||||
},
|
||||
orderBy: [{ queuedAt: 'asc' }, { id: 'asc' }],
|
||||
skip: (normalizedPage - 1) * normalizedPageSize,
|
||||
take: normalizedPageSize,
|
||||
}),
|
||||
this.prisma.smsMessageRecord.count({ where }),
|
||||
]);
|
||||
return { items, total, page: normalizedPage, pageSize: normalizedPageSize };
|
||||
}
|
||||
|
||||
listMessages(query: {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
|
||||
@@ -96,14 +96,14 @@ export class AdminSmsConfigController {
|
||||
|
||||
@Post('drainage-infos/:id/approve')
|
||||
@RequireRecentAuthentication()
|
||||
approveDrainageInfo(@Param('id') itemId: string, @Body() body: ReviewDto) {
|
||||
return this.smsConfig.approveDrainageInfo(itemId, body);
|
||||
approveDrainageInfo(@Param('id') itemId: string, @Body() body: ReviewDto, @CurrentSessionUserId() reviewerId?: string) {
|
||||
return this.smsConfig.approveDrainageInfo(itemId, { ...body, reviewerId });
|
||||
}
|
||||
|
||||
@Post('drainage-infos/:id/reject')
|
||||
@RequireRecentAuthentication()
|
||||
rejectDrainageInfo(@Param('id') itemId: string, @Body() body: ReviewDto) {
|
||||
return this.smsConfig.rejectDrainageInfo(itemId, body);
|
||||
rejectDrainageInfo(@Param('id') itemId: string, @Body() body: ReviewDto, @CurrentSessionUserId() reviewerId?: string) {
|
||||
return this.smsConfig.rejectDrainageInfo(itemId, { ...body, reviewerId });
|
||||
}
|
||||
|
||||
@Post('drainage-infos/:id/status')
|
||||
@@ -140,8 +140,8 @@ export class AdminSmsConfigController {
|
||||
|
||||
@Post('signatures/:id/reject')
|
||||
@RequireRecentAuthentication()
|
||||
rejectSignature(@Param('id') signatureId: string, @Body() body: ReviewDto) {
|
||||
return this.smsConfig.rejectSignature(signatureId, body);
|
||||
rejectSignature(@Param('id') signatureId: string, @Body() body: ReviewDto, @CurrentSessionUserId() reviewerId?: string) {
|
||||
return this.smsConfig.rejectSignature(signatureId, { ...body, reviewerId });
|
||||
}
|
||||
|
||||
@Post('templates/:id/approve')
|
||||
@@ -152,8 +152,8 @@ export class AdminSmsConfigController {
|
||||
|
||||
@Post('templates/:id/reject')
|
||||
@RequireRecentAuthentication()
|
||||
rejectTemplate(@Param('id') templateId: string, @Body() body: ReviewDto) {
|
||||
return this.smsConfig.rejectTemplate(templateId, body);
|
||||
rejectTemplate(@Param('id') templateId: string, @Body() body: ReviewDto, @CurrentSessionUserId() reviewerId?: string) {
|
||||
return this.smsConfig.rejectTemplate(templateId, { ...body, reviewerId });
|
||||
}
|
||||
|
||||
@Post('enterprise-applications/:id/status')
|
||||
|
||||
@@ -1622,6 +1622,9 @@ export class SmsConfigService implements OnModuleInit, OnModuleDestroy {
|
||||
targetType,
|
||||
targetId,
|
||||
},
|
||||
include: {
|
||||
reviewer: { select: { id: true, username: true, displayName: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ function createPrismaMock() {
|
||||
createdAt: new Date('2026-07-03T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-07-03T00:00:00.000Z'),
|
||||
};
|
||||
return {
|
||||
const prisma = {
|
||||
$executeRaw: jest.fn().mockResolvedValue(1),
|
||||
tenant: {
|
||||
findMany: jest.fn().mockResolvedValue([{ ...tenant, enterpriseCertifications: [] }]),
|
||||
findUnique: jest.fn().mockResolvedValue({ ...tenant, enterpriseCertifications: [] }),
|
||||
@@ -27,6 +28,7 @@ function createPrismaMock() {
|
||||
},
|
||||
tenantAccount: {
|
||||
findMany: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', balanceCents: 12000, status: 'active' }]),
|
||||
findUnique: jest.fn().mockResolvedValue({ balanceCents: 0 }),
|
||||
},
|
||||
smsMessageRecord: {
|
||||
groupBy: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', _sum: { amountCents: 350 } }]),
|
||||
@@ -35,6 +37,10 @@ function createPrismaMock() {
|
||||
groupBy: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', _sum: { amountCents: 125 } }]),
|
||||
},
|
||||
};
|
||||
return {
|
||||
...prisma,
|
||||
$transaction: jest.fn((callback: (tx: typeof prisma) => unknown) => callback(prisma)),
|
||||
};
|
||||
}
|
||||
|
||||
describe('TenantsService', () => {
|
||||
@@ -96,6 +102,29 @@ describe('TenantsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks enterprise deletion until a non-zero account balance is cleared', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.tenantAccount.findUnique.mockResolvedValue({ balanceCents: -1250 });
|
||||
const service = new TenantsService(prisma as never);
|
||||
|
||||
await expect(service.delete('tenant-1')).rejects.toThrow('完成余额清算后方可删除,请给企业充值到金额为0');
|
||||
expect(prisma.tenant.update).not.toHaveBeenCalled();
|
||||
expect(prisma.smsApplication.count).not.toHaveBeenCalled();
|
||||
expect(prisma.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('allows enterprise deletion only when the account balance is zero and no applications are active', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.tenantAccount.findUnique.mockResolvedValue({ balanceCents: 0 });
|
||||
const service = new TenantsService(prisma as never);
|
||||
|
||||
await expect(service.delete('tenant-1')).resolves.toMatchObject({ id: 'tenant-1' });
|
||||
expect(prisma.tenant.update).toHaveBeenCalledWith({
|
||||
where: { id: 'tenant-1' },
|
||||
data: { status: 'deleted' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects enterprise credit codes containing non-alphanumeric characters', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new TenantsService(prisma as never);
|
||||
|
||||
@@ -127,16 +127,29 @@ export class TenantsService {
|
||||
}
|
||||
|
||||
private async deleteAfterApplicationCheck(id: string) {
|
||||
await this.ensureTenant(id);
|
||||
const blockingApplications = await this.prisma.smsApplication.count({
|
||||
where: { tenantId: id, status: { in: ['active', 'disabling'] } },
|
||||
});
|
||||
if (blockingApplications > 0) {
|
||||
throw new BadRequestException(`该企业还有 ${blockingApplications} 个启用或停用中的企业应用,请先完成应用停用`);
|
||||
}
|
||||
return this.prisma.tenant.update({
|
||||
where: { id },
|
||||
data: { status: 'deleted' },
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'tenant-account:' + id}, 0))`;
|
||||
const tenant = await tx.tenant.findUnique({ where: { id } });
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('Tenant not found');
|
||||
}
|
||||
const account = await tx.tenantAccount.findUnique({
|
||||
where: { tenantId: id },
|
||||
select: { balanceCents: true },
|
||||
});
|
||||
if (moneyToNumber(account?.balanceCents) !== 0) {
|
||||
throw new BadRequestException('完成余额清算后方可删除,请给企业充值到金额为0');
|
||||
}
|
||||
const blockingApplications = await tx.smsApplication.count({
|
||||
where: { tenantId: id, status: { in: ['active', 'disabling'] } },
|
||||
});
|
||||
if (blockingApplications > 0) {
|
||||
throw new BadRequestException(`该企业还有 ${blockingApplications} 个启用或停用中的企业应用,请先完成应用停用`);
|
||||
}
|
||||
return tx.tenant.update({
|
||||
where: { id },
|
||||
data: { status: 'deleted' },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL
|
||||
?? 'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public';
|
||||
const databaseHost = new URL(databaseUrl).hostname;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('Refusing to seed signature-quality demo data in production mode');
|
||||
}
|
||||
if (process.env.ALLOW_LOCAL_SIGNATURE_QUALITY_DEMO !== 'true') {
|
||||
throw new Error('Set ALLOW_LOCAL_SIGNATURE_QUALITY_DEMO=true explicitly to seed local demo data');
|
||||
}
|
||||
if (!['localhost', '127.0.0.1', '::1'].includes(databaseHost)) {
|
||||
throw new Error(`Refusing to seed a non-local database host: ${databaseHost}`);
|
||||
}
|
||||
|
||||
const prisma = new PrismaService();
|
||||
|
||||
async function main() {
|
||||
const date = shanghaiDateKey();
|
||||
const messagePrefix = `LOCAL-SIGSTAT-${date.replaceAll('-', '')}-`;
|
||||
const existing = await prisma.smsMessageRecord.count({
|
||||
where: { messageId: { startsWith: messagePrefix } },
|
||||
});
|
||||
if (existing > 0) {
|
||||
console.log(`Local signature-quality demo already exists for ${date}: ${existing} messages`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tenant = await prisma.tenant.upsert({
|
||||
where: { code: 'LOCAL-SIG-QUALITY-DEMO' },
|
||||
update: { name: '本地统计演示企业', status: 'active' },
|
||||
create: {
|
||||
id: 'local-signature-quality-tenant',
|
||||
code: 'LOCAL-SIG-QUALITY-DEMO',
|
||||
name: '本地统计演示企业',
|
||||
status: 'active',
|
||||
certificationStatus: 'approved',
|
||||
},
|
||||
});
|
||||
const application = await prisma.smsApplication.upsert({
|
||||
where: { cmppAccount: 'LOCAL_SIG_QUALITY_DEMO' },
|
||||
update: { name: '本地短信统计演示应用', status: 'active', interfaceEnabled: false },
|
||||
create: {
|
||||
id: 'local-signature-quality-application',
|
||||
tenantId: tenant.id,
|
||||
name: '本地短信统计演示应用',
|
||||
cmppAccount: 'LOCAL_SIG_QUALITY_DEMO',
|
||||
cmppEnterpriseCode: 'LOCAL',
|
||||
secretHash: 'local-demo-not-for-authentication',
|
||||
interfaceEnabled: false,
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
const channelDefinitions = [
|
||||
{ id: 'local-signature-quality-channel-fulong', code: 'LOCAL-DEMO-FULONG', name: '本地演示-富泷' },
|
||||
{ id: 'local-signature-quality-channel-tiebushan', code: 'LOCAL-DEMO-TIEBUSHAN', name: '本地演示-铁布衫' },
|
||||
{ id: 'local-signature-quality-channel-relay', code: 'LOCAL-DEMO-RELAY', name: '本地演示-行业中转' },
|
||||
];
|
||||
const channels = await Promise.all(channelDefinitions.map((channel) => prisma.smsChannel.upsert({
|
||||
where: { code: channel.code },
|
||||
update: { name: channel.name, status: 'inactive' },
|
||||
create: {
|
||||
...channel,
|
||||
carrier: null,
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 65535,
|
||||
account: channel.code,
|
||||
passwordCipher: 'local-demo',
|
||||
srcId: '10690000',
|
||||
status: 'inactive',
|
||||
},
|
||||
})));
|
||||
const signatureDefinitions = [
|
||||
{ id: 'local-signature-quality-signature-property', name: '【本地演示物业】', count: 24 },
|
||||
{ id: 'local-signature-quality-signature-aerospace', name: '【本地演示航信】', count: 18 },
|
||||
{ id: 'local-signature-quality-signature-member', name: '【本地会员服务】', count: 12 },
|
||||
];
|
||||
const signatures = await Promise.all(signatureDefinitions.map((signature) => prisma.smsSignature.upsert({
|
||||
where: { id: signature.id },
|
||||
update: {
|
||||
name: signature.name,
|
||||
tenantId: tenant.id,
|
||||
applicationId: application.id,
|
||||
auditStatus: 'approved',
|
||||
},
|
||||
create: {
|
||||
id: signature.id,
|
||||
tenantId: tenant.id,
|
||||
applicationId: application.id,
|
||||
name: signature.name,
|
||||
purpose: '本地数据统计页面演示',
|
||||
auditStatus: 'approved',
|
||||
reportStatus: 'approved',
|
||||
pendingReport: false,
|
||||
},
|
||||
})));
|
||||
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
const submits: Array<Record<string, unknown>> = [];
|
||||
const receipts: Array<Record<string, unknown>> = [];
|
||||
const dayStart = new Date(`${date}T00:00:00+08:00`);
|
||||
const carriers = ['mobile', 'unicom', 'telecom'];
|
||||
let sequence = 0;
|
||||
|
||||
signatureDefinitions.forEach((definition, signatureIndex) => {
|
||||
for (let index = 0; index < definition.count; index += 1) {
|
||||
sequence += 1;
|
||||
const key = `${messagePrefix}${String(sequence).padStart(3, '0')}`;
|
||||
const messageRecordId = `local-signature-quality-message-${date}-${sequence}`;
|
||||
const carrier = carriers[(index + signatureIndex) % carriers.length];
|
||||
const primaryChannel = channels[(index + signatureIndex) % channels.length];
|
||||
const retryChannel = channels[(index + signatureIndex + 1) % channels.length];
|
||||
const queuedAt = new Date(dayStart.getTime() + (8 * 60 + sequence * 7) * 60_000);
|
||||
const submittedAt = new Date(queuedAt.getTime() + 500);
|
||||
const isRetry = index % 11 === 0;
|
||||
const isSubmitFailure = !isRetry && index % 9 === 0;
|
||||
const isUnknown = !isRetry && !isSubmitFailure && index % 7 === 0;
|
||||
const isFailure = !isRetry && !isSubmitFailure && !isUnknown && index % 5 === 0;
|
||||
const deliveredAt = isRetry || (!isSubmitFailure && !isUnknown && !isFailure)
|
||||
? new Date(submittedAt.getTime() + 1_400 + (index % 8) * 650)
|
||||
: null;
|
||||
const finalChannel = isRetry ? retryChannel : primaryChannel;
|
||||
const messageStatus = isSubmitFailure
|
||||
? 'submit_failed'
|
||||
: isUnknown
|
||||
? 'submitted'
|
||||
: isFailure
|
||||
? 'failed'
|
||||
: 'delivered';
|
||||
messages.push({
|
||||
id: messageRecordId,
|
||||
tenantId: tenant.id,
|
||||
applicationId: application.id,
|
||||
signatureId: signatures[signatureIndex].id,
|
||||
messageId: key,
|
||||
phoneNumber: `1390000${String(sequence).padStart(4, '0')}`,
|
||||
carrier,
|
||||
province: '上海',
|
||||
content: `${definition.name}本地数据统计页面演示短信`,
|
||||
channelId: finalChannel.id,
|
||||
status: messageStatus,
|
||||
submitStatus: isSubmitFailure ? 'rejected' : 'accepted',
|
||||
receiptStatus: isFailure ? 'undelivered' : deliveredAt ? 'delivered' : null,
|
||||
queuedAt,
|
||||
submittedAt,
|
||||
deliveredAt,
|
||||
});
|
||||
|
||||
const addAttempt = ({
|
||||
attempt,
|
||||
channelId,
|
||||
submitStatus,
|
||||
receiptStatus,
|
||||
attemptSubmittedAt,
|
||||
attemptDeliveredAt,
|
||||
}: {
|
||||
attempt: number;
|
||||
channelId: string;
|
||||
submitStatus: string;
|
||||
receiptStatus?: 'delivered' | 'undelivered';
|
||||
attemptSubmittedAt: Date;
|
||||
attemptDeliveredAt?: Date;
|
||||
}) => {
|
||||
const submitRecordId = `local-signature-quality-submit-${date}-${sequence}-${attempt}`;
|
||||
const submitId = `LOCAL-SUB-${date.replaceAll('-', '')}-${sequence}-${attempt}`;
|
||||
const gatewayMessageId = `LOCAL-GW-${date.replaceAll('-', '')}-${sequence}-${attempt}`;
|
||||
submits.push({
|
||||
id: submitRecordId,
|
||||
tenantId: tenant.id,
|
||||
messageRecordId,
|
||||
channelId,
|
||||
submitId,
|
||||
gatewayMessageId,
|
||||
submitStatus,
|
||||
submittedAt: attemptSubmittedAt,
|
||||
createdAt: attemptSubmittedAt,
|
||||
});
|
||||
if (receiptStatus && attemptDeliveredAt) {
|
||||
receipts.push({
|
||||
id: `local-signature-quality-receipt-${date}-${sequence}-${attempt}`,
|
||||
tenantId: tenant.id,
|
||||
messageRecordId,
|
||||
receiptKey: `LOCAL-RECEIPT-${date.replaceAll('-', '')}-${sequence}-${attempt}`,
|
||||
channelId,
|
||||
messageId: key,
|
||||
gatewayMessageId,
|
||||
phoneNumber: `1390000${String(sequence).padStart(4, '0')}`,
|
||||
receiptStatus,
|
||||
rawStatus: receiptStatus === 'delivered' ? 'DELIVRD' : 'UNDELIV',
|
||||
deliveredAt: attemptDeliveredAt,
|
||||
createdAt: attemptDeliveredAt,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isRetry) {
|
||||
addAttempt({
|
||||
attempt: 0,
|
||||
channelId: primaryChannel.id,
|
||||
submitStatus: 'accepted',
|
||||
receiptStatus: 'undelivered',
|
||||
attemptSubmittedAt: submittedAt,
|
||||
attemptDeliveredAt: new Date(submittedAt.getTime() + 2_100),
|
||||
});
|
||||
const retrySubmittedAt = new Date(submittedAt.getTime() + 2_500);
|
||||
addAttempt({
|
||||
attempt: 1,
|
||||
channelId: retryChannel.id,
|
||||
submitStatus: 'accepted',
|
||||
receiptStatus: 'delivered',
|
||||
attemptSubmittedAt: retrySubmittedAt,
|
||||
attemptDeliveredAt: deliveredAt ?? new Date(retrySubmittedAt.getTime() + 2_000),
|
||||
});
|
||||
} else {
|
||||
addAttempt({
|
||||
attempt: 0,
|
||||
channelId: primaryChannel.id,
|
||||
submitStatus: isSubmitFailure ? 'rejected' : 'accepted',
|
||||
receiptStatus: isFailure ? 'undelivered' : deliveredAt ? 'delivered' : undefined,
|
||||
attemptSubmittedAt: submittedAt,
|
||||
attemptDeliveredAt: isFailure ? new Date(submittedAt.getTime() + 3_300) : deliveredAt ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.smsMessageRecord.createMany({ data: messages as never[] }),
|
||||
prisma.smsSubmitRecord.createMany({ data: submits as never[] }),
|
||||
prisma.smsReceiptRecord.createMany({ data: receipts as never[] }),
|
||||
]);
|
||||
console.log(JSON.stringify({
|
||||
date,
|
||||
tenant: tenant.name,
|
||||
application: application.name,
|
||||
signatures: signatures.map((signature) => signature.name),
|
||||
messages: messages.length,
|
||||
submitAttempts: submits.length,
|
||||
receipts: receipts.length,
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function shanghaiDateKey(value = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(value);
|
||||
const byType = new Map(parts.map((part) => [part.type, part.value]));
|
||||
return `${byType.get('year')}-${byType.get('month')}-${byType.get('day')}`;
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
Reference in New Issue
Block a user