feat(server): 完成 P3 后端API与回归(D2~D9)
- workflow: 任务详情/转审候选/列表分页字段(D5) - reimbursements: 列表API + pay凭证字段/幂等增强(D7) - invoice-pool: 列表/详情API + 上传pending_ocr(D9) - ocr/audit: mock provider + 回归脚本 + regression-all开关(D2/D3) - CI: 新增 ci-minimal / ci-regression 工作流(D4)
这个提交包含在:
@@ -25,6 +25,13 @@ const bindBodySchema = z.object({
|
||||
linkedItemId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
const invoiceListQuerySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).optional(),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
||||
status: z.enum(["pending_ocr", "ocr_failed", "available", "bound", "archived"]).optional(),
|
||||
ownerScope: z.enum(["own", "all"]).optional(),
|
||||
});
|
||||
|
||||
const DEDUP_CONSTRAINT_NAMES = new Set([
|
||||
"invoice_pool_file_sha256_dedup_ux",
|
||||
"invoice_pool_invoice_code_number_dedup_ux",
|
||||
@@ -149,7 +156,187 @@ function sanitizeFilename(fileName) {
|
||||
return fileName.replace(/[\r\n"]/g, "_");
|
||||
}
|
||||
|
||||
function parseInvoiceListQuery(query) {
|
||||
const parsed = invoiceListQuerySchema.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
page: parsed.data.page ?? 1,
|
||||
pageSize: parsed.data.pageSize ?? 20,
|
||||
status: parsed.data.status,
|
||||
ownerScope: parsed.data.ownerScope,
|
||||
};
|
||||
}
|
||||
|
||||
function mapInvoiceSummary(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
status: item.status,
|
||||
ocrType: item.ocr_type,
|
||||
ocrProvider: item.ocr_provider,
|
||||
ocrAttempts: item.ocr_attempts,
|
||||
ocrLastError: item.ocr_last_error,
|
||||
ocrCompletedAt: item.ocr_completed_at?.toISOString?.() ?? null,
|
||||
invoiceCode: item.invoice_code,
|
||||
invoiceNumber: item.invoice_number,
|
||||
invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
amount: toMoneyString(item.amount),
|
||||
taxAmount: toMoneyString(item.tax_amount),
|
||||
totalAmount: toMoneyString(item.total_amount),
|
||||
linkedReimbursementId: item.linked_reimbursement_id,
|
||||
linkedItemId: item.linked_item_id,
|
||||
originalName: item.original_name,
|
||||
mimeType: item.mime_type,
|
||||
fileSize: item.file_size,
|
||||
owner: item.owner_employee
|
||||
? {
|
||||
id: item.owner_employee.id,
|
||||
employeeNo: item.owner_employee.employee_no,
|
||||
name: item.owner_employee.name,
|
||||
}
|
||||
: null,
|
||||
uploadedBy: item.uploaded_by_employee
|
||||
? {
|
||||
id: item.uploaded_by_employee.id,
|
||||
employeeNo: item.uploaded_by_employee.employee_no,
|
||||
name: item.uploaded_by_employee.name,
|
||||
}
|
||||
: null,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeInvoiceResponse(invoice) {
|
||||
return {
|
||||
id: invoice.id,
|
||||
status: invoice.status,
|
||||
ownerEmployeeId: invoice.owner_employee_id,
|
||||
uploadedByEmployeeId: invoice.uploaded_by_employee_id,
|
||||
ocrType: invoice.ocr_type,
|
||||
ocrProvider: invoice.ocr_provider,
|
||||
ocrAttempts: invoice.ocr_attempts,
|
||||
ocrLastError: invoice.ocr_last_error,
|
||||
ocrCompletedAt: invoice.ocr_completed_at?.toISOString?.() ?? null,
|
||||
invoiceCode: invoice.invoice_code,
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
invoiceDate: invoice.invoice_date ? invoice.invoice_date.toISOString().slice(0, 10) : null,
|
||||
amount: toMoneyString(invoice.amount),
|
||||
taxAmount: toMoneyString(invoice.tax_amount),
|
||||
totalAmount: toMoneyString(invoice.total_amount),
|
||||
linkedReimbursementId: invoice.linked_reimbursement_id,
|
||||
linkedItemId: invoice.linked_item_id,
|
||||
createdAt: invoice.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: invoice.updated_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function invoicePoolRoutes(fastify) {
|
||||
|
||||
fastify.get(
|
||||
"/invoice-pool",
|
||||
{
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = parseInvoiceListQuery(request.query);
|
||||
|
||||
const canViewOwn = fastify.hasPermission(request.employee, "invoice.view_own");
|
||||
const canManage = fastify.hasPermission(request.employee, "invoice.manage");
|
||||
|
||||
if (!canViewOwn && !canManage) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const ownerScope = query.ownerScope ?? (canManage ? "all" : "own");
|
||||
if (ownerScope === "all" && !canManage) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const skip = (query.page - 1) * query.pageSize;
|
||||
const where = {
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(ownerScope === "all"
|
||||
? {}
|
||||
: {
|
||||
OR: [
|
||||
{ owner_employee_id: request.employee.id },
|
||||
{ uploaded_by_employee_id: request.employee.id },
|
||||
{
|
||||
linked_reimbursement: {
|
||||
OR: [
|
||||
{ owner_id: request.employee.id },
|
||||
{ submitter_id: request.employee.id },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
fastify.prisma.invoicePool.findMany({
|
||||
where,
|
||||
orderBy: { created_at: "desc" },
|
||||
skip,
|
||||
take: query.pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
ocr_type: true,
|
||||
ocr_provider: true,
|
||||
ocr_attempts: true,
|
||||
ocr_last_error: true,
|
||||
ocr_completed_at: true,
|
||||
invoice_code: true,
|
||||
invoice_number: true,
|
||||
invoice_date: true,
|
||||
amount: true,
|
||||
tax_amount: true,
|
||||
total_amount: true,
|
||||
linked_reimbursement_id: true,
|
||||
linked_item_id: true,
|
||||
original_name: true,
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
owner_employee: {
|
||||
select: { id: true, employee_no: true, name: true },
|
||||
},
|
||||
uploaded_by_employee: {
|
||||
select: { id: true, employee_no: true, name: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
fastify.prisma.invoicePool.count({ where }),
|
||||
]);
|
||||
|
||||
return sendData(reply, request, {
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
ownerScope,
|
||||
items: items.map(mapInvoiceSummary),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/invoice-pool/upload",
|
||||
{
|
||||
|
||||
在新工单中引用
屏蔽一个用户