feat: complete reporting and filing workflows

This commit is contained in:
hectorzhao
2026-07-28 20:28:47 +08:00
parent 352a6293b4
commit 99c8c7c68b
52 changed files with 3490 additions and 376 deletions
@@ -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");
+29
View File
@@ -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);
+23 -5
View File
@@ -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,
}));
}
}
+2 -2
View File
@@ -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')
+18 -2
View File
@@ -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' },
});
});
+18 -2
View File
@@ -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 () => {
+37 -6
View File
@@ -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'),
},
}),
}));
});
+236 -1
View File
@@ -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;
+13
View File
@@ -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',
+55 -4
View File
@@ -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' });
+33
View File
@@ -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')
+3
View File
@@ -1622,6 +1622,9 @@ export class SmsConfigService implements OnModuleInit, OnModuleDestroy {
targetType,
targetId,
},
include: {
reviewer: { select: { id: true, username: true, displayName: true } },
},
orderBy: { createdAt: 'desc' },
});
}
+30 -1
View File
@@ -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);
+23 -10
View File
@@ -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' },
});
});
}
+259
View File
@@ -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());