feat: complete reporting and filing workflows
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user