feat: 完成P6-P8后端能力与P9路由重构(V1-V2,D1,D3,B1-B2,B4,I1-I4,P9A2-P9A3)

这个提交包含在:
2026-02-10 11:28:28 +08:00
父节点 bec21e1b38
当前提交 6bc09f557b
共修改 33 个文件,包含 3467 行新增和 13 行删除
@@ -0,0 +1,20 @@
const { JOB_INVOICE_VERIFY } = require("../../../jobs/pgBossJobs");
const { runVerificationForRecord } = require("../../../jobs/verificationJob");
async function triggerVerificationJob(fastify, recordId) {
if (fastify.config.jobDriver === "pgboss" && fastify.boss) {
await fastify.boss.send(JOB_INVOICE_VERIFY, { recordId });
return { mode: "queued" };
}
const result = await runVerificationForRecord(
fastify.prisma,
fastify.config,
recordId,
);
return { mode: "inline", result };
}
module.exports = {
triggerVerificationJob,
};
@@ -0,0 +1,116 @@
const { toMoneyString } = require("../../../utils/workflow");
function mapVerificationRecord(item) {
if (!item) {
return null;
}
return {
id: item.id,
provider: item.provider,
status: item.status,
resultCode: item.result_code ?? null,
resultMessage: item.result_message ?? null,
errorCode: item.error_code ?? null,
errorMessage: item.error_message ?? null,
durationMs: item.duration_ms ?? null,
requestedBy: item.requested_by ?? null,
requestedAt: item.requested_at?.toISOString?.() ?? null,
completedAt: item.completed_at?.toISOString?.() ?? null,
retryOfId: item.retry_of_id ?? null,
attempts: item.attempts ?? 1,
};
}
function mapInvoiceSummary(item) {
const latestVerification = Array.isArray(item.verification_records)
? item.verification_records[0] ?? null
: null;
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,
verification: mapVerificationRecord(latestVerification),
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) {
const latestVerification = Array.isArray(invoice.verification_records)
? invoice.verification_records[0] ?? null
: null;
return {
id: invoice.id,
status: invoice.status,
archivedReason: invoice.archived_reason ?? null,
archivedAt: invoice.archived_at?.toISOString?.() ?? null,
ownerEmployeeId: invoice.owner_employee_id,
uploadedByEmployeeId: invoice.uploaded_by_employee_id,
originalName: invoice.original_name ?? null,
mimeType: invoice.mime_type ?? null,
fileSize: invoice.file_size ?? null,
fileSha256: invoice.file_sha256 ?? null,
remarks: invoice.remarks ?? null,
ocrType: invoice.ocr_type,
ocrProvider: invoice.ocr_provider,
ocrAttempts: invoice.ocr_attempts,
ocrLastRequestedAt: invoice.ocr_last_requested_at?.toISOString?.() ?? null,
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,
sellerName: invoice.seller_name ?? null,
buyerName: invoice.buyer_name ?? 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,
verification: mapVerificationRecord(latestVerification),
isDuplicateOverride: invoice.is_duplicate_override ?? false,
overrideRefFormNumber: invoice.override_ref_form_number ?? null,
overrideReason: invoice.override_reason ?? null,
overriddenBy: invoice.overridden_by ?? null,
createdAt: invoice.created_at?.toISOString?.() ?? null,
updatedAt: invoice.updated_at?.toISOString?.() ?? null,
};
}
module.exports = {
mapVerificationRecord,
mapInvoiceSummary,
normalizeInvoiceResponse,
};
@@ -0,0 +1,27 @@
const { z } = require("zod");
const { AppError } = require("../../../utils/AppError");
const verificationListQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(50).optional(),
});
function parseVerificationListQuery(query) {
const parsed = verificationListQuerySchema.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return {
limit: parsed.data.limit ?? 20,
};
}
module.exports = {
parseVerificationListQuery,
};
@@ -0,0 +1,114 @@
function buildVerificationRecordSelect() {
return {
id: true,
provider: true,
status: true,
result_code: true,
result_message: true,
error_code: true,
error_message: true,
duration_ms: true,
requested_by: true,
requested_at: true,
completed_at: true,
retry_of_id: true,
attempts: true,
};
}
function buildLatestVerificationSelect() {
return {
orderBy: { created_at: "desc" },
take: 1,
select: buildVerificationRecordSelect(),
};
}
function buildInvoiceSummarySelect() {
return {
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,
verification_records: buildLatestVerificationSelect(),
owner_employee: {
select: { id: true, employee_no: true, name: true },
},
uploaded_by_employee: {
select: { id: true, employee_no: true, name: true },
},
};
}
function buildInvoiceDetailSelect(options = {}) {
const select = {
id: true,
status: true,
archived_reason: true,
archived_at: true,
owner_employee_id: true,
uploaded_by_employee_id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
remarks: true,
ocr_type: true,
ocr_provider: true,
ocr_attempts: true,
ocr_last_requested_at: true,
ocr_last_error: true,
ocr_completed_at: true,
invoice_code: true,
invoice_number: true,
invoice_date: true,
seller_name: true,
buyer_name: true,
amount: true,
tax_amount: true,
total_amount: true,
linked_reimbursement_id: true,
linked_item_id: true,
is_duplicate_override: true,
override_ref_form_number: true,
override_reason: true,
overridden_by: true,
created_at: true,
updated_at: true,
verification_records: buildLatestVerificationSelect(),
};
if (options.includeLinkedReimbursement) {
select.linked_reimbursement = {
select: {
owner_id: true,
submitter_id: true,
},
};
}
return select;
}
module.exports = {
buildInvoiceSummarySelect,
buildInvoiceDetailSelect,
buildVerificationRecordSelect,
buildLatestVerificationSelect,
};
@@ -0,0 +1,384 @@
const { z } = require("zod");
const { AppError } = require("../../utils/AppError");
const { sendData } = require("../../utils/reply");
const { buildAuditLogData } = require("../../utils/audit");
const { getProvider, isVerificationEnabled } = require("../../services/verification");
const { mapVerificationRecord } = require("./shared/mappers");
const { parseVerificationListQuery } = require("./shared/schemas");
const { triggerVerificationJob } = require("./shared/helpers");
function registerInvoiceVerificationRoutes(fastify) {
fastify.post(
"/invoice-pool/:id/verify",
{
preHandler: fastify.requirePermission("invoice.verify"),
},
async (request, reply) => {
if (!isVerificationEnabled(fastify.config)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "disabled" },
});
}
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
}
const invoiceId = parsedInvoiceId.data;
const provider = getProvider(fastify.config);
const now = Date.now();
const response = await fastify.prisma.$transaction(async (tx) => {
const lockedRows = await tx.$queryRaw`
SELECT id
FROM "invoice_pool"
WHERE id = ${invoiceId}::uuid
FOR UPDATE
`;
if (!Array.isArray(lockedRows) || lockedRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const invoice = await tx.invoicePool.findUnique({
where: { id: invoiceId },
select: {
id: true,
status: true,
invoice_code: true,
invoice_number: true,
total_amount: true,
remarks: true,
},
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const latest = await tx.invoiceVerificationRecord.findFirst({
where: { invoice_id: invoiceId },
orderBy: { created_at: "desc" },
select: {
id: true,
status: true,
requested_at: true,
attempts: true,
},
});
if (latest?.status === "pending") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "already_pending" },
});
}
if (latest?.requested_at) {
const diffMs = now - new Date(latest.requested_at).getTime();
const cooldownMs =
Math.max(1, Number(fastify.config.verificationRetryCooldownSeconds ?? 60)) * 1000;
if (diffMs < cooldownMs) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "too_frequent" },
});
}
}
const created = await tx.invoiceVerificationRecord.create({
data: {
invoice_id: invoiceId,
provider,
status: "pending",
request_payload: {
invoiceCode: invoice.invoice_code ?? null,
invoiceNumber: invoice.invoice_number ?? null,
totalAmount: invoice.total_amount?.toString?.() ?? null,
},
requested_by: request.employee.id,
requested_at: new Date(now),
attempts: 1,
},
select: {
id: true,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "INVOICE_VERIFICATION_REQUESTED",
targetType: "invoice_pool",
targetId: invoiceId,
extra: {
verificationId: created.id,
provider,
},
}),
});
return {
verificationId: created.id,
provider,
};
});
const trigger = await triggerVerificationJob(fastify, response.verificationId);
if (trigger.mode === "inline") {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "INVOICE_VERIFICATION_INLINE_EXECUTED",
targetType: "invoice_pool",
targetId: invoiceId,
extra: {
verificationId: response.verificationId,
status: trigger.result?.status ?? null,
},
}),
});
}
return sendData(reply, request, {
invoiceId,
verificationId: response.verificationId,
provider: response.provider,
triggerMode: trigger.mode,
});
},
);
fastify.get(
"/invoice-pool/:id/verification",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
}
const query = parseVerificationListQuery(request.query);
const invoiceId = parsedInvoiceId.data;
const canManage = fastify.hasPermission(request.employee, "invoice.manage");
const canVerify = fastify.hasPermission(request.employee, "invoice.verify");
if (!canManage && !canVerify) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const invoice = await fastify.prisma.invoicePool.findUnique({
where: { id: invoiceId },
select: {
id: true,
owner_employee_id: true,
uploaded_by_employee_id: true,
linked_reimbursement: {
select: {
owner_id: true,
submitter_id: true,
},
},
},
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const readable =
canManage ||
canVerify ||
invoice.owner_employee_id === request.employee.id ||
invoice.uploaded_by_employee_id === request.employee.id ||
invoice.linked_reimbursement?.owner_id === request.employee.id ||
invoice.linked_reimbursement?.submitter_id === request.employee.id;
if (!readable) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const items = await fastify.prisma.invoiceVerificationRecord.findMany({
where: { invoice_id: invoiceId },
orderBy: { created_at: "desc" },
take: query.limit,
});
return sendData(reply, request, {
invoiceId,
items: items.map(mapVerificationRecord),
});
},
);
fastify.post(
"/invoice-pool/:id/verification/retry",
{
preHandler: fastify.requirePermission("invoice.verify"),
},
async (request, reply) => {
if (!isVerificationEnabled(fastify.config)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "disabled" },
});
}
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
}
const invoiceId = parsedInvoiceId.data;
const provider = getProvider(fastify.config);
const created = await fastify.prisma.$transaction(async (tx) => {
const latest = await tx.invoiceVerificationRecord.findFirst({
where: { invoice_id: invoiceId },
orderBy: { created_at: "desc" },
select: {
id: true,
status: true,
attempts: true,
requested_at: true,
},
});
if (!latest) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "not_found" },
});
}
if (latest.status === "pending") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "already_pending" },
});
}
const cooldownMs =
Math.max(1, Number(fastify.config.verificationRetryCooldownSeconds ?? 60)) * 1000;
const diffMs = Date.now() - new Date(latest.requested_at).getTime();
if (diffMs < cooldownMs) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "too_frequent" },
});
}
const attempts = Number(latest.attempts ?? 1) + 1;
if (attempts > Number(fastify.config.verificationMaxAttempts ?? 3)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "attempts_exceeded" },
});
}
const createdRecord = await tx.invoiceVerificationRecord.create({
data: {
invoice_id: invoiceId,
provider,
status: "pending",
retry_of_id: latest.id,
attempts,
requested_by: request.employee.id,
requested_at: new Date(),
request_payload: {
retryOfId: latest.id,
},
},
select: {
id: true,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "INVOICE_VERIFICATION_RETRIED",
targetType: "invoice_pool",
targetId: invoiceId,
extra: {
verificationId: createdRecord.id,
retryOfId: latest.id,
attempts,
},
}),
});
return createdRecord;
});
await triggerVerificationJob(fastify, created.id);
return sendData(reply, request, {
invoiceId,
verificationId: created.id,
status: "pending",
});
},
);
}
module.exports = {
registerInvoiceVerificationRoutes,
};