feat: add reconciliation and quality reporting

This commit is contained in:
hectorzhao
2026-07-15 14:22:50 +08:00
parent 16311546af
commit 8c3336600e
32 changed files with 1730 additions and 200 deletions
@@ -1,6 +1,7 @@
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import {
CreateCommonReportFieldDto,
CreateBlacklistDto,
CreateDrainageFieldDto,
CreatePhoneCarrierRuleDto,
@@ -118,4 +119,19 @@ export class DictionariesController {
deleteDrainageField(@Param('id') id: string) {
return this.dictionaries.deleteDrainageField(id);
}
@Get('common-report-fields')
listCommonReportFields() {
return this.dictionaries.listCommonReportFields();
}
@Post('common-report-fields')
createCommonReportField(@Body() body: CreateCommonReportFieldDto) {
return this.dictionaries.createCommonReportField(body);
}
@Delete('common-report-fields/:id')
deleteCommonReportField(@Param('id') id: string) {
return this.dictionaries.deleteCommonReportField(id);
}
}
@@ -28,12 +28,20 @@ function createPrismaMock() {
},
drainageField: {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
delete: jest.fn().mockResolvedValue({ id: 'field-1' }),
},
channelReportField: {
count: jest.fn().mockResolvedValue(0),
},
commonReportField: {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn().mockResolvedValue(null),
count: jest.fn().mockResolvedValue(0),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'common-1', ...data })),
delete: jest.fn().mockResolvedValue({ id: 'common-1' }),
},
smsApplication: {
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
},
@@ -55,14 +63,38 @@ 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 } }]);
prisma.drainageField.findMany.mockResolvedValue([{ id: 'field-1', code: 'license', _count: { channelReportFields: 2, commonReportFields: 0 } }]);
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 }]);
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();
});
it('creates and deletes real common signature and drainage field configurations', async () => {
const prisma = createPrismaMock();
prisma.drainageField.findUnique = jest.fn().mockResolvedValue({ id: 'field-1', code: 'license', required: false, status: 'active' });
const service = new DictionariesService(prisma as never);
await service.createCommonReportField({ drainageFieldId: 'field-1', reportType: 'signature', required: true });
await service.deleteCommonReportField('common-1');
expect(prisma.commonReportField.create).toHaveBeenCalledWith({
data: expect.objectContaining({ drainageFieldId: 'field-1', reportType: 'signature', required: true, status: 'active' }),
include: { drainageField: true },
});
expect(prisma.commonReportField.delete).toHaveBeenCalledWith({ where: { id: 'common-1' } });
});
it('blocks deleting a field referenced by a common configuration', async () => {
const prisma = createPrismaMock();
prisma.commonReportField.count.mockResolvedValue(1);
const service = new DictionariesService(prisma as never);
await expect(service.deleteDrainageField('field-1')).rejects.toThrow('通用配置');
expect(prisma.drainageField.delete).not.toHaveBeenCalled();
});
it('paginates phone segments with a real database count', async () => {
const prisma = createPrismaMock();
prisma.phoneSegment.findMany.mockResolvedValue([
+56 -5
View File
@@ -53,6 +53,13 @@ export interface CreateDrainageFieldDto {
description?: string;
}
export interface CreateCommonReportFieldDto {
drainageFieldId: string;
reportType: 'signature' | 'drainage';
required?: boolean;
sortOrder?: number;
}
export interface DictionaryStatusDto {
status?: string;
operatorId?: string;
@@ -264,10 +271,14 @@ export class DictionariesService {
async listDrainageFields() {
const fields = await this.prisma.drainageField.findMany({
include: { _count: { select: { channelReportFields: true } } },
include: { _count: { select: { channelReportFields: true, commonReportFields: true } } },
orderBy: { createdAt: 'desc' },
});
return fields.map(({ _count, ...field }) => ({ ...field, usageCount: _count.channelReportFields }));
return fields.map(({ _count, ...field }) => ({
...field,
usageCount: _count.channelReportFields,
commonUsageCount: _count.commonReportFields,
}));
}
createDrainageField(data: CreateDrainageFieldDto) {
@@ -291,13 +302,53 @@ export class DictionariesService {
}
async deleteDrainageField(id: string) {
const usageCount = await this.prisma.channelReportField.count({ where: { drainageFieldId: id } });
if (usageCount > 0) {
throw new BadRequestException(`该字段已被 ${usageCount} 个通道使用,不能删除`);
const [usageCount, commonUsageCount] = await Promise.all([
this.prisma.channelReportField.count({ where: { drainageFieldId: id } }),
this.prisma.commonReportField.count({ where: { drainageFieldId: id } }),
]);
if (usageCount > 0 || commonUsageCount > 0) {
throw new BadRequestException(`该字段已被 ${usageCount} 个通道和 ${commonUsageCount} 个通用配置使用,不能删除`);
}
return this.prisma.drainageField.delete({ where: { id } });
}
listCommonReportFields() {
return this.prisma.commonReportField.findMany({
include: { drainageField: true },
orderBy: [{ reportType: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
});
}
async createCommonReportField(data: CreateCommonReportFieldDto) {
if (data.reportType !== 'signature' && data.reportType !== 'drainage') {
throw new BadRequestException('reportType must be signature or drainage');
}
const field = await this.prisma.drainageField.findUnique({ where: { id: data.drainageFieldId } });
if (!field || field.status !== 'active') {
throw new BadRequestException('报备字段库字段不存在或已停用');
}
const existing = await this.prisma.commonReportField.findUnique({
where: { drainageFieldId_reportType: { drainageFieldId: field.id, reportType: data.reportType } },
});
if (existing) {
throw new BadRequestException('该字段已配置为对应类型的通用字段');
}
return this.prisma.commonReportField.create({
data: {
drainageFieldId: field.id,
reportType: data.reportType,
required: data.required ?? field.required,
sortOrder: data.sortOrder ?? 100,
status: 'active',
},
include: { drainageField: true },
});
}
deleteCommonReportField(id: string) {
return this.prisma.commonReportField.delete({ where: { id } });
}
private writeOperationLog(userId: string | undefined, action: string, resource: string, resourceId: string, detail: Record<string, unknown>) {
return this.prisma.operationLog.create({
data: {