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
@@ -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;