feat: enhance operations dashboard and reporting controls

This commit is contained in:
hectorzhao
2026-09-04 16:26:22 +08:00
parent bc18c7ff12
commit 48d0363920
23 changed files with 544 additions and 49 deletions
@@ -12,6 +12,8 @@ import {
CreateSensitiveWordDto,
DictionariesService,
DictionaryStatusDto,
ReorderCommonReportFieldsDto,
UpdateDrainageFieldDto,
} from './dictionaries.service';
@ApiTags('dictionaries')
@@ -128,6 +130,15 @@ export class DictionariesController {
return this.dictionaries.createDrainageField(body);
}
@Put('drainage-fields/:id')
updateDrainageField(
@Param('id') id: string,
@Body() body: UpdateDrainageFieldDto,
@CurrentSessionUserId() operatorId?: string,
) {
return this.dictionaries.updateDrainageField(id, body, operatorId);
}
@Delete('drainage-fields/:id')
deleteDrainageField(@Param('id') id: string) {
return this.dictionaries.deleteDrainageField(id);
@@ -168,6 +179,14 @@ export class DictionariesController {
return this.dictionaries.createCommonReportField(body);
}
@Put('common-report-fields/order')
reorderCommonReportFields(
@Body() body: ReorderCommonReportFieldsDto,
@CurrentSessionUserId() operatorId?: string,
) {
return this.dictionaries.reorderCommonReportFields(body, operatorId);
}
@Delete('common-report-fields/:id')
deleteCommonReportField(@Param('id') id: string) {
return this.dictionaries.deleteCommonReportField(id);
@@ -33,6 +33,7 @@ function createPrismaMock() {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
delete: jest.fn().mockResolvedValue({ id: 'field-1' }),
},
channelReportField: {
@@ -46,6 +47,7 @@ function createPrismaMock() {
count: jest.fn().mockResolvedValue(0),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'common-1', ...data })),
delete: jest.fn().mockResolvedValue({ id: 'common-1' }),
update: jest.fn(),
},
smsApplication: {
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
@@ -76,6 +78,42 @@ describe('DictionariesService', () => {
await expect(service.updateCommonReportField('common-1', body)).rejects.toThrow('已停用');
await expect(service.updateCommonReportField('common-1', { ...body, required: 'false' as never })).rejects.toThrow('无效');
});
it('reorders every common field in one report type transaction and writes an audit trail', async () => {
const prisma = createPrismaMock();
const tx = {
commonReportField: {
update: jest.fn().mockResolvedValue({}),
findMany: jest.fn().mockResolvedValue([{ id: 'common-2' }, { id: 'common-1' }]),
},
operationLog: { create: jest.fn().mockResolvedValue({}) },
};
prisma.$transaction.mockImplementation((callback) => callback(tx));
const service = new DictionariesService(prisma as never);
await expect(
service.reorderCommonReportFields({ reportType: 'signature', ids: ['common-2', 'common-1'] }, 'admin-1'),
).resolves.toEqual([{ id: 'common-2' }, { id: 'common-1' }]);
expect(tx.commonReportField.update).toHaveBeenNthCalledWith(1, {
where: { id: 'common-2' },
data: { sortOrder: 10 },
});
expect(tx.commonReportField.update).toHaveBeenNthCalledWith(2, {
where: { id: 'common-1' },
data: { sortOrder: 20 },
});
expect(tx.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: 'common_report_field.reorder', userId: 'admin-1' }),
});
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: 'Serializable' });
tx.commonReportField.findMany.mockResolvedValue([{ id: 'common-1' }]);
await expect(
service.reorderCommonReportFields({ reportType: 'signature', ids: ['common-1'] }),
).resolves.toEqual([{ id: 'common-1' }]);
await expect(
service.reorderCommonReportFields({ reportType: 'signature', ids: ['common-1', 'common-2'] }),
).rejects.toThrow('排序范围已变化');
});
it('builds the enterprise province and city library from distinct real phone segment regions', async () => {
const prisma = createPrismaMock();
prisma.phoneSegment.findMany.mockResolvedValue([
@@ -130,6 +168,32 @@ describe('DictionariesService', () => {
});
});
it('edits an unreferenced field atomically and protects mapping keys once referenced', async () => {
const prisma = createPrismaMock();
const existing = { id: 'field-1', code: 'license', name: '主体证明', fieldType: 'file', description: null };
prisma.drainageField.findUnique.mockResolvedValue(existing as never);
const tx = {
drainageField: { update: jest.fn().mockResolvedValue({ ...existing, name: '企业主体证明', description: '最新版' }) },
operationLog: { create: jest.fn().mockResolvedValue({}) },
};
prisma.$transaction.mockImplementation((callback) => callback(tx));
const service = new DictionariesService(prisma as never);
await expect(service.updateDrainageField('field-1', {
code: 'license', name: '企业主体证明', fieldType: 'file', description: ' 最新版 ',
}, 'admin-1')).resolves.toEqual(expect.objectContaining({ name: '企业主体证明' }));
expect(tx.drainageField.update).toHaveBeenCalledWith({
where: { id: 'field-1' },
data: { code: 'license', name: '企业主体证明', fieldType: 'file', description: '最新版' },
});
expect(tx.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'drainage_field.update', userId: 'admin-1' }) });
prisma.channelReportField.count.mockResolvedValue(1);
await expect(service.updateDrainageField('field-1', {
code: 'newCode', name: '企业主体证明', fieldType: 'file', description: '',
})).rejects.toThrow('不能修改字段代码或类型');
});
it('ignores deleted-channel references and removes those stale mappings when deleting the field', async () => {
const prisma = createPrismaMock();
const tx = {
@@ -63,6 +63,8 @@ export interface CreateDrainageFieldDto {
description?: string;
}
export type UpdateDrainageFieldDto = CreateDrainageFieldDto;
export interface UpsertDrainageDetectionRuleDto {
code: string;
name: string;
@@ -87,6 +89,11 @@ export interface CreateCommonReportFieldDto {
sortOrder?: number;
}
export interface ReorderCommonReportFieldsDto {
reportType: 'signature' | 'drainage';
ids: string[];
}
export interface DictionaryStatusDto {
status?: string;
operatorId?: string;
@@ -399,6 +406,58 @@ export class DictionariesService {
});
}
async updateDrainageField(id: string, data: UpdateDrainageFieldDto, operatorId?: string) {
const code = data.code?.trim();
const name = data.name?.trim();
if (!code || !/^[A-Za-z0-9]+$/.test(code)) {
throw new BadRequestException('code must contain only Arabic numerals and English letters');
}
if (!name) throw new BadRequestException('name is required');
if (!['string', 'image', 'file'].includes(data.fieldType)) {
throw new BadRequestException('fieldType must be string, image or file');
}
const existing = await this.prisma.drainageField.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('报备字段不存在');
const [usageCount, commonUsageCount] = await Promise.all([
this.prisma.channelReportField.count({ where: { drainageFieldId: id, channel: { status: { not: 'deleted' } } } }),
this.prisma.commonReportField.count({ where: { drainageFieldId: id } }),
]);
if ((usageCount > 0 || commonUsageCount > 0) && (code !== existing.code || data.fieldType !== existing.fieldType)) {
throw new BadRequestException('字段已被引用,只能修改名称和说明,不能修改字段代码或类型');
}
try {
return await this.prisma.$transaction(async (tx) => {
const updated = await tx.drainageField.update({
where: { id },
data: {
code,
name,
fieldType: data.fieldType,
description: data.description?.trim() || null,
},
});
await tx.operationLog.create({
data: {
userId: operatorId,
action: 'drainage_field.update',
resource: 'drainage_field',
resourceId: id,
detail: {
before: { code: existing.code, name: existing.name, fieldType: existing.fieldType, description: existing.description },
after: { code: updated.code, name: updated.name, fieldType: updated.fieldType, description: updated.description },
} as Prisma.InputJsonValue,
},
});
return updated;
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
throw new ConflictException('字段代码已存在');
}
throw error;
}
}
async deleteDrainageField(id: string) {
const [usageCount, commonUsageCount] = await Promise.all([
this.prisma.channelReportField.count({
@@ -541,6 +600,40 @@ export class DictionariesService {
});
}
async reorderCommonReportFields(data: ReorderCommonReportFieldsDto, operatorId?: string) {
if (!['signature', 'drainage'].includes(data?.reportType) || !Array.isArray(data?.ids) || !data.ids.length) {
throw new BadRequestException('通用字段排序参数无效');
}
if (new Set(data.ids).size !== data.ids.length) throw new BadRequestException('通用字段排序不能包含重复项');
return this.prisma.$transaction(async (tx) => {
const existing = await tx.commonReportField.findMany({
where: { reportType: data.reportType, status: 'active' },
select: { id: true },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
});
const existingIds = existing.map((field) => field.id);
if (existingIds.length !== data.ids.length || existingIds.some((id) => !data.ids.includes(id))) {
throw new BadRequestException('通用字段排序范围已变化,请刷新页面后重试');
}
for (const [index, id] of data.ids.entries()) {
await tx.commonReportField.update({ where: { id }, data: { sortOrder: (index + 1) * 10 } });
}
await tx.operationLog.create({
data: {
userId: operatorId,
action: 'common_report_field.reorder',
resource: 'common_report_field',
detail: { reportType: data.reportType, before: existingIds, after: data.ids } as Prisma.InputJsonValue,
},
});
return tx.commonReportField.findMany({
where: { reportType: data.reportType, status: 'active' },
include: { drainageField: true },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
});
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
}
deleteCommonReportField(id: string) {
return this.prisma.commonReportField.delete({ where: { id } });
}
+19 -2
View File
@@ -511,6 +511,11 @@ describe('OperationsService', () => {
todaySpendCents: 24000n,
balanceCents: 1000000n,
creditCents: 50000n,
}]).mockResolvedValueOnce([{
segmentCount: 20n,
deliveredSegmentCount: 18n,
billedCents: 360n,
costCents: 216n,
}]).mockResolvedValueOnce([
{ hour: 9, submittedCount: 12n, successCount: 10n },
{ hour: 10, submittedCount: 5n, successCount: 4n },
@@ -541,7 +546,15 @@ describe('OperationsService', () => {
templates: 1,
total: 5,
},
today: expect.objectContaining({ returnedCents: 10 }),
today: expect.objectContaining({
returnedCents: 10,
segmentCount: 20,
deliveredSegmentCount: 18,
arrivalRate: 90,
billedCents: 360,
profitCents: 144,
profitRate: 40,
}),
hourlySendTrend: expect.arrayContaining([
{ hour: 9, label: '09:00', submittedCount: 12, successCount: 10 },
{ hour: 10, label: '10:00', submittedCount: 5, successCount: 4 },
@@ -592,7 +605,11 @@ describe('OperationsService', () => {
updatedAt: { gte: expect.any(Date) },
},
});
const hourlyTrendQuery = prisma.$queryRaw.mock.calls[1]?.[0] as { sql?: string };
const dashboardMetricsQuery = prisma.$queryRaw.mock.calls[1]?.[0] as { sql?: string };
expect(dashboardMetricsQuery.sql).toContain('FROM "SmsMessageSegmentAudit" segment');
expect(dashboardMetricsQuery.sql).toContain('message."billingUnits" * message."unitPrice"');
expect(dashboardMetricsQuery.sql).toContain('submit."costUnitPrice"');
const hourlyTrendQuery = prisma.$queryRaw.mock.calls[2]?.[0] as { sql?: string };
expect(hourlyTrendQuery.sql).toContain(
`HOUR FROM (message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai'`,
);
@@ -32,6 +32,7 @@ async dashboard(query: { tenantId?: string }) {
recentTasks,
recentRecharges,
enterpriseSpendRows,
todayBusinessMetricsRows,
downstreamPendingCount,
downstreamFailedCount,
downstreamDeliveredCount,
@@ -117,6 +118,67 @@ async dashboard(query: { tenantId?: string }) {
GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents"
ORDER BY "todaySpendCents" DESC, tenant.name ASC
`),
this.prisma.$queryRaw<Array<{
segmentCount: bigint;
deliveredSegmentCount: bigint;
billedCents: bigint;
costCents: bigint;
}>>(Prisma.sql`
WITH segment_metrics AS (
SELECT
COUNT(segment.id)::bigint AS "segmentCount",
COUNT(segment.id) FILTER (WHERE segment."receiptStatus" = 'delivered')::bigint AS "deliveredSegmentCount"
FROM "SmsMessageSegmentAudit" segment
JOIN "SmsMessageRecord" message ON message.id = segment."messageRecordId"
WHERE message."queuedAt" >= ${businessDay.startAt}
AND message."queuedAt" < ${businessDay.endAt}
AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null})
),
message_revenue AS (
SELECT COALESCE(SUM(
CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
THEN message."billingUnits" * message."unitPrice" ELSE 0 END
), 0)::bigint AS "billedCents"
FROM "SmsMessageRecord" message
WHERE message."queuedAt" >= ${businessDay.startAt}
AND message."queuedAt" < ${businessDay.endAt}
AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null})
),
submit_cost AS (
SELECT 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 AS "costCents"
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'
AND message."queuedAt" >= ${businessDay.startAt}
AND message."queuedAt" < ${businessDay.endAt}
AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null})
)
SELECT
segment_metrics."segmentCount",
segment_metrics."deliveredSegmentCount",
message_revenue."billedCents",
submit_cost."costCents"
FROM segment_metrics, message_revenue, submit_cost
`),
this.prisma.cmppDownstreamDelivery.count({
where: { tenantId: query.tenantId, status: 'pending' },
}),
@@ -239,6 +301,12 @@ async dashboard(query: { tenantId?: string }) {
`),
]);
const todayTotals = summarizeMessageGroups(todayMessageGroups);
const todayBusinessMetrics = todayBusinessMetricsRows[0];
const segmentCount = Number(todayBusinessMetrics?.segmentCount ?? 0);
const deliveredSegmentCount = Number(todayBusinessMetrics?.deliveredSegmentCount ?? 0);
const billedCents = moneyToNumber(todayBusinessMetrics?.billedCents);
const costCents = moneyToNumber(todayBusinessMetrics?.costCents);
const profitCents = billedCents - costCents;
const hourlyRowsByHour = new Map(hourlySendRows.map((row) => [Number(row.hour), row]));
// Always return all 24 Shanghai-time buckets so the line chart does not imply missing hours are missing data.
const hourlySendTrend = Array.from({ length: 24 }, (_, hour) => {
@@ -279,6 +347,12 @@ async dashboard(query: { tenantId?: string }) {
spendCents: todayTotals.amountCents,
returnedCents: moneyToNumber(transactionAggregate._sum.amountCents),
billingUnits: todayTotals.billingUnits,
segmentCount,
deliveredSegmentCount,
arrivalRate: segmentCount > 0 ? Number(((deliveredSegmentCount / segmentCount) * 100).toFixed(1)) : 0,
billedCents,
profitCents,
profitRate: billedCents > 0 ? Number(((profitCents / billedCents) * 100).toFixed(1)) : 0,
},
uplinkCount,
billing: billingAggregate,
@@ -331,7 +331,8 @@ export class ReportChannelExportService {
};
}
async exportSingleMaterial(data: SingleReportMaterialDto, operatorId?: string) {
async exportSingleMaterial(data: SingleReportMaterialDto | undefined, operatorId?: string) {
if (!data) throw new BadRequestException('导出参数不能为空');
if ((data.reportType ?? 'signature') !== 'signature')
throw new BadRequestException('首版仅支持单条签名报备资料导出');
const detail = await this.getSingleMaterialDetail(data);
@@ -214,7 +214,7 @@ export class ReportMaterialsController {
@Post('single-export')
@RequireRecentAuthentication()
async exportSingleMaterial(
@Body() body: SingleReportMaterialDto,
@Body() body: SingleReportMaterialDto | undefined,
@CurrentSessionUserId() operatorId: string | undefined,
@Res() response: DownloadResponse,
) {
@@ -4,6 +4,11 @@ import { ReportMaterialsService } from './report-materials.service';
import { mappedCorePatchValue } from './report-materials.helpers';
describe('ReportMaterialsService', () => {
it('rejects an unparsed single-export body as a readable 400 instead of throwing a TypeError', async () => {
const service = new ReportMaterialsService({} as never, {} as never, {} as never);
await expect(service.exportSingleMaterial(undefined, 'operator-1')).rejects.toThrow('导出参数不能为空');
});
it('does not clear an existing core field when the import column is unmapped or blank', () => {
expect(mappedCorePatchValue([], {}, 'purpose')).toBeUndefined();
expect(
@@ -121,7 +121,7 @@ export class ReportMaterialsService {
return this.channelExport.getSingleMaterialDetail(data);
}
async exportSingleMaterial(data: SingleReportMaterialDto, operatorId?: string) {
async exportSingleMaterial(data: SingleReportMaterialDto | undefined, operatorId?: string) {
return this.channelExport.exportSingleMaterial(data, operatorId);
}
}