文件
hyb-oa/server/src/routes/invoicePool.js
T
wangsli e5685f18b2
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
feat: 选票自动生成报销明细并支持集中分类(08-29-reimbursement-expense-policy-invoice-guard)
2026-09-09 23:19:02 +08:00

1759 行
55 KiB
JavaScript

const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { suggestInvoiceExpenseType } = require("../services/invoiceExpenseClassification");
const crypto = require("node:crypto");
const path = require("node:path");
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { compactDateString } = require("../utils/date");
const { sha256Hex } = require("../utils/crypto");
const { toMoneyString } = require("../utils/workflow");
const { findActiveDelegationForDelegate } = require("../utils/delegation");
const {
OCR_PDF_MAX_BYTES,
getMissingRequiredOcrFields,
isOcrEnabledForType,
} = require("../services/ocr");
const { enqueueOcrInvoice } = require("../jobs/pgBossJobs");
const { registerInvoiceVerificationRoutes } = require("./invoicePool/verification");
const { registerInvoicePoolReadRoutes } = require("./invoicePool/read");
const { normalizeInvoiceResponse } = require("./invoicePool/shared/mappers");
const { buildInvoiceDetailSelect } = require("./invoicePool/shared/selects");
const { writeBuffer } = require("../services/storage");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const {
mapInvoiceUnavailableReason,
resolveInvoiceAvailability,
resolveInvoiceDedupBlocker,
resolveUploadDedupDecision,
} = require("../services/invoiceGovernance");
const INVOICE_UPLOAD_MIME_TYPE = "application/pdf";
const OCR_TYPE_ALLOWLIST = new Set(["vat", "train", "flight", "taxi", "general"]);
const bindBodySchema = z.union([
z
.object({
reimbursementId: z.string().uuid(),
linkedItemId: z.string().uuid().optional(),
createItem: z.literal(true).optional(),
})
.strict()
.refine((body) => !(body.createItem && body.linkedItemId)),
z
.object({
travelOrderId: z.string().uuid(),
})
.strict(),
]);
const invoiceEditBodySchema = z
.object({
status: z.enum(["available"]).optional(),
remarks: z.string().max(500).nullable().optional(),
invoiceCode: z.string().trim().min(1).max(64).nullable().optional(),
invoiceNumber: z.string().trim().min(1).max(64).nullable().optional(),
invoiceDate: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.nullable()
.optional(),
sellerName: z.string().trim().min(1).max(256).nullable().optional(),
buyerName: z.string().trim().min(1).max(256).nullable().optional(),
amount: z.string().trim().min(1).max(32).nullable().optional(),
taxAmount: z.string().trim().min(1).max(32).nullable().optional(),
totalAmount: z.string().trim().min(1).max(32).nullable().optional(),
})
.strict();
const overrideDuplicateBodySchema = z
.object({
refFormNumber: z.string().trim().min(1).max(64),
reason: z.string().trim().min(1).max(200),
})
.strict();
const DEDUP_CONSTRAINT_NAMES = new Set([
"invoice_pool_file_sha256_dedup_ux",
"invoice_pool_invoice_code_number_dedup_ux",
]);
const DEDUP_FIELD_NAMES = new Set(["file_sha256", "invoice_code", "invoice_number"]);
function tokenizeMetaString(value) {
if (typeof value !== "string") {
return [];
}
const normalized = value.trim().toLowerCase();
if (!normalized) {
return [];
}
const splitTokens = normalized
.split(/[^a-z0-9_]+/i)
.map((item) => item.trim().toLowerCase())
.filter(Boolean);
return [normalized, ...splitTokens];
}
function normalizeConstraintMeta(value) {
if (Array.isArray(value)) {
return value
.flatMap((item) => tokenizeMetaString(String(item)))
.filter(Boolean);
}
if (typeof value === "string") {
return tokenizeMetaString(value);
}
return [];
}
function collectFieldTokens(meta) {
return new Set(
[
...normalizeConstraintMeta(meta.target),
...normalizeConstraintMeta(meta?.driverAdapterError?.cause?.constraint?.fields),
].filter((token) => DEDUP_FIELD_NAMES.has(token)),
);
}
function containsDedupConstraintName(metaTokens) {
const constraints = Array.from(DEDUP_CONSTRAINT_NAMES);
return metaTokens.some((token) => constraints.some((constraint) => token.includes(constraint)));
}
function isFieldBasedDedup(fieldTokens) {
if (fieldTokens.size === 1 && fieldTokens.has("file_sha256")) {
return true;
}
if (
fieldTokens.size === 2 &&
fieldTokens.has("invoice_code") &&
fieldTokens.has("invoice_number")
) {
return true;
}
return false;
}
function isInvoiceDedupConflict(error) {
if (!error || error.code !== "P2002") {
return false;
}
const meta = error.meta ?? {};
const metaTokens = [
...normalizeConstraintMeta(meta.constraint),
...normalizeConstraintMeta(meta.target),
...normalizeConstraintMeta(meta?.driverAdapterError?.cause?.originalMessage),
];
if (containsDedupConstraintName(metaTokens)) {
return true;
}
const fieldTokens = collectFieldTokens(meta);
return isFieldBasedDedup(fieldTokens);
}
function normalizeMoneyInput(value) {
if (value == null) {
return null;
}
const input = String(value).trim();
if (!input) {
return null;
}
if (!/^\d+(?:\.\d{1,2})?$/.test(input)) {
return undefined;
}
const [intPart, fracPartRaw = ""] = input.split(".");
const fracPart = `${fracPartRaw}00`.slice(0, 2);
return `${intPart}.${fracPart}`;
}
function parseDateOnlyString(value) {
if (value == null) {
return null;
}
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
return undefined;
}
return new Date(`${trimmed}T00:00:00.000Z`);
}
function isPdfInvoiceUpload(fileName, mimeType) {
return mimeType === INVOICE_UPLOAD_MIME_TYPE && path.extname(fileName ?? "").toLowerCase() === ".pdf";
}
async function resolveDefaultExpenseType(tx) {
const preferred = await tx.expenseType.findFirst({
where: {
enabled: true,
code: { in: ["other_misc", "office_supplies", "business_client_meal"] },
},
orderBy: { sort_order: "asc" },
select: { id: true },
});
if (preferred) {
return preferred;
}
return tx.expenseType.findFirst({
where: { enabled: true },
orderBy: { sort_order: "asc" },
select: { id: true },
});
}
function toDateOnlyFromDate(value) {
if (value instanceof Date) {
return new Date(`${value.toISOString().slice(0, 10)}T00:00:00.000Z`);
}
return null;
}
function buildAutoItemDescription(invoice) {
const sellerName = String(invoice.seller_name ?? "").trim();
if (sellerName) {
return `发票自动生成:${sellerName}`;
}
const originalName = String(invoice.original_name ?? "").trim();
if (originalName) {
return `发票自动生成:${originalName}`.slice(0, 500);
}
return "发票自动生成明细";
}
async function recomputeReimbursementTotal(tx, reimbursementId, actorId) {
const aggregate = await tx.reimbursementItem.aggregate({
where: { reimbursement_id: reimbursementId },
_sum: { amount: true },
});
const nextTotal = aggregate._sum.amount ?? "0";
await tx.reimbursementForm.update({
where: { id: reimbursementId },
data: {
total_amount: nextTotal,
updated_by: actorId,
},
});
return nextTotal;
}
async function resolveInvoiceLinkedItem(tx, { reimbursementId, reimbursementDate, invoice, linkedItemId, actorId, createItem }) {
if (createItem) {
const expenseDate = toDateOnlyFromDate(invoice.invoice_date);
if (!expenseDate || !Number.isFinite(expenseDate.getTime()) || invoice.total_amount == null || Number(invoice.total_amount) <= 0 || !Number.isFinite(Number(invoice.total_amount))) {
throw buildBusinessValidationError("reimbursement.invoice_prefill_incomplete", { invoice: "missing_date_or_positive_amount" }, 409);
}
const expenseTypes = await tx.expenseType.findMany({
where: { enabled: true, code: "travel_train" },
select: { id: true, code: true, enabled: true },
});
const suggestion = suggestInvoiceExpenseType(invoice, expenseTypes);
const created = await tx.reimbursementItem.create({
data: {
reimbursement_id: reimbursementId,
expense_type_id: suggestion?.expenseTypeId ?? null,
expense_date: expenseDate,
amount: toMoneyString(invoice.total_amount),
description: suggestion?.source === "document_category" ? "铁路交通费用" : "",
created_by: actorId,
updated_by: actorId,
},
select: { id: true },
});
return { linkedItemId: created.id, createdItem: true };
}
if (linkedItemId) {
const linkedItem = await tx.reimbursementItem.findFirst({
where: {
id: linkedItemId,
reimbursement_id: reimbursementId,
},
select: { id: true },
});
if (!linkedItem) {
throw buildBusinessValidationError("reimbursement.linked_item_id_item_not_in_reimbursement", { linkedItemId: "item_not_in_reimbursement" }, 400);
}
return { linkedItemId: linkedItem.id, createdItem: false };
}
const items = await tx.reimbursementItem.findMany({
where: { reimbursement_id: reimbursementId },
orderBy: [{ created_at: "asc" }, { id: "asc" }],
select: {
id: true,
amount: true,
},
});
if (items.length === 1) {
return { linkedItemId: items[0].id, createdItem: false };
}
if (items.length > 1 && invoice.total_amount != null) {
const invoiceAmount = toMoneyString(invoice.total_amount);
const matched = items.filter((item) => toMoneyString(item.amount) === invoiceAmount);
if (matched.length === 1) {
return { linkedItemId: matched[0].id, createdItem: false };
}
}
if (invoice.total_amount == null) {
throw buildBusinessValidationError("reimbursement.invoice_amount_required", { invoice: "missing_total_amount_for_auto_item" }, 409);
}
const expenseType = await resolveDefaultExpenseType(tx);
if (!expenseType) {
throw buildBusinessValidationError("reimbursement.expense_type_required", { expenseTypeId: "missing_enabled_expense_type" }, 409);
}
const expenseDate =
toDateOnlyFromDate(invoice.invoice_date) ??
toDateOnlyFromDate(reimbursementDate) ??
new Date();
const created = await tx.reimbursementItem.create({
data: {
reimbursement_id: reimbursementId,
expense_type_id: expenseType.id,
expense_date: expenseDate,
amount: toMoneyString(invoice.total_amount),
description: buildAutoItemDescription(invoice),
created_by: actorId,
updated_by: actorId,
},
select: { id: true },
});
return { linkedItemId: created.id, createdItem: true };
}
async function invoicePoolRoutes(fastify) {
registerInvoicePoolReadRoutes(fastify);
fastify.post(
"/invoice-pool/upload",
{
preHandler: fastify.requirePermission("invoice.upload"),
},
async (request, reply) => {
let fileBuffer = null;
let fileName = null;
let fileMimeType = null;
let fileSize = 0;
const fields = {};
const configuredUploadMaxBytes = Number(fastify.config.maxUploadSizeBytes);
const invoiceUploadMaxBytes =
Number.isFinite(configuredUploadMaxBytes) && configuredUploadMaxBytes > 0
? Math.min(configuredUploadMaxBytes, OCR_PDF_MAX_BYTES)
: OCR_PDF_MAX_BYTES;
try {
for await (const part of request.parts()) {
if (part.type === "file") {
if (fileBuffer) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "only_single_file_supported" },
});
}
fileName = part.filename ?? "upload";
fileMimeType = part.mimetype;
if (!isPdfInvoiceUpload(fileName, fileMimeType)) {
throw new AppError({
code: "VALID_001",
message: "请上传 PDF 发票文件",
statusCode: 400,
details: { file: "pdf_invoice_required" },
});
}
const chunks = [];
for await (const chunk of part.file) {
fileSize += chunk.length;
if (fileSize > invoiceUploadMaxBytes) {
throw new AppError({
code: "VALID_001",
message:
invoiceUploadMaxBytes === OCR_PDF_MAX_BYTES
? "PDF 发票不能超过 7 MiB"
: "参数校验失败",
statusCode: 400,
details: {
file:
invoiceUploadMaxBytes === OCR_PDF_MAX_BYTES
? "ocr_pdf_too_large"
: "file_too_large",
maxBytes: invoiceUploadMaxBytes,
},
});
}
chunks.push(chunk);
}
if (fileSize === 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "empty_file" },
});
}
fileBuffer = Buffer.concat(chunks);
continue;
}
fields[part.fieldname] = part.value;
}
} catch (error) {
if (error?.code === "FST_REQ_FILE_TOO_LARGE") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "file_too_large" },
});
}
throw error;
}
if (!fileBuffer) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "missing" },
});
}
const ownerEmployeeId =
typeof fields.owner_employee_id === "string" && fields.owner_employee_id.trim()
? fields.owner_employee_id.trim()
: request.employee.id;
const isCrossOwner = ownerEmployeeId !== request.employee.id;
let delegationGrantId = null;
if (isCrossOwner) {
const canManageUsers = fastify.hasPermission(request.employee, "system.users");
if (!canManageUsers) {
const isAdministration = request.employee.roleCodes?.includes("administration");
const canProxySubmit = fastify.hasPermission(request.employee, "expense.proxy_submit");
if (!isAdministration || !canProxySubmit) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
details: { owner_employee_id: "cross_owner_upload_not_allowed" },
});
}
const delegation = await findActiveDelegationForDelegate(fastify.prisma, {
principalEmployeeId: ownerEmployeeId,
delegateEmployeeId: request.employee.id,
allowedScopes: ["reimbursement_submit", "all"],
now: new Date(),
});
if (!delegation) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
details: { delegation: "missing_active_grant" },
});
}
delegationGrantId = delegation.id;
}
const ownerEmployee = await fastify.prisma.employee.findUnique({
where: { id: ownerEmployeeId },
select: { id: true, status: true },
});
if (!ownerEmployee || ownerEmployee.status !== "active") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { owner_employee_id: "invalid_owner" },
});
}
}
const ocrType = fields.ocr_type ?? "general";
if (!OCR_TYPE_ALLOWLIST.has(ocrType)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { ocr_type: "invalid" },
});
}
const fileSha256 = sha256Hex(fileBuffer);
const uploadDedupDecision = await fastify.prisma.$transaction((tx) =>
resolveUploadDedupDecision(tx, {
fileSha256,
ownerEmployeeId,
}),
);
if (uploadDedupDecision.action !== "create") {
const message = mapInvoiceUnavailableReason(uploadDedupDecision.reason);
return sendData(reply, request, {
invoicePoolId:
uploadDedupDecision.action === "reuse_existing"
? uploadDedupDecision.invoice.id
: null,
status: uploadDedupDecision.invoice?.status ?? "ocr_failed",
fileSha256,
uploadAction: uploadDedupDecision.action,
unavailableReason: uploadDedupDecision.reason,
unavailableMessage: message,
availabilityReason: uploadDedupDecision.reason,
availabilityMessage: message,
});
}
const datePrefix = compactDateString(fastify.config.appTimezone);
const extension = path.extname(fileName ?? "").toLowerCase();
const objectKey = path.posix.join(datePrefix, `${crypto.randomUUID()}${extension}`);
const storagePath = await writeBuffer({
config: fastify.config,
key: objectKey,
buffer: fileBuffer,
contentType: fileMimeType,
});
const ocrEnabled = isOcrEnabledForType(fastify.config, ocrType);
const status = ocrEnabled ? "pending_ocr" : "available";
const invoice = await fastify.prisma.$transaction(async (tx) => {
const now = new Date();
const created = await tx.invoicePool.create({
data: {
owner_employee_id: ownerEmployeeId,
uploaded_by_employee_id: request.employee.id,
storage_path: storagePath,
original_name: fileName ?? "upload",
mime_type: fileMimeType,
file_size: fileSize,
file_sha256: fileSha256,
ocr_provider: ocrEnabled ? fastify.config.ocrProvider : undefined,
ocr_type: ocrType,
status,
ocr_last_requested_at: status === "pending_ocr" ? now : null,
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: {
id: true,
status: true,
file_sha256: true,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "INVOICE_POOL_UPLOADED",
targetType: "invoice_pool",
targetId: created.id,
extra: {
ownerEmployeeId,
uploadedByEmployeeId: request.employee.id,
crossOwner: isCrossOwner,
delegationGrantId,
ocrType,
fileSha256,
},
}),
});
return created;
});
if (invoice.status === "pending_ocr") {
enqueueOcrInvoice(fastify, invoice.id).catch((error) => {
request.log.warn({ err: error, invoiceId: invoice.id }, "Failed to enqueue OCR invoice job");
});
}
const availability = resolveInvoiceAvailability(invoice);
return sendData(reply, request, {
invoicePoolId: invoice.id,
status: invoice.status,
fileSha256: invoice.file_sha256,
uploadAction: "create",
availabilityReason: availability.reason,
availabilityMessage: availability.message,
});
},
);
registerInvoiceVerificationRoutes(fastify);
fastify.patch(
"/invoice-pool/:id",
{
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 parsedBody = invoiceEditBodySchema.safeParse(request.body ?? {});
if (!parsedBody.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsedBody.error.flatten(),
});
}
if (Object.keys(parsedBody.data).length === 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { body: "empty_patch" },
});
}
const invoiceId = parsedInvoiceId.data;
const canManage = fastify.hasPermission(request.employee, "invoice.manage");
const invoice = await fastify.prisma.$transaction(async (tx) => {
const locked = await tx.$queryRaw`
SELECT id
FROM "invoice_pool"
WHERE id = ${invoiceId}::uuid
FOR UPDATE
`;
if (!Array.isArray(locked) || locked.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const current = await tx.invoicePool.findUnique({
where: { id: invoiceId },
select: buildInvoiceDetailSelect(),
});
if (!current) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const canEdit =
canManage ||
current.owner_employee_id === request.employee.id ||
current.uploaded_by_employee_id === request.employee.id;
if (!canEdit) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
if (!["available", "ocr_failed"].includes(current.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_status_for_edit" },
});
}
const input = parsedBody.data;
const updateData = {};
if ("remarks" in input) {
updateData.remarks = input.remarks;
}
if ("invoiceCode" in input) {
updateData.invoice_code = input.invoiceCode;
}
if ("invoiceNumber" in input) {
updateData.invoice_number = input.invoiceNumber;
}
if ("invoiceDate" in input) {
const parsedDate = parseDateOnlyString(input.invoiceDate);
if (parsedDate === undefined) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { invoiceDate: "invalid_date" },
});
}
updateData.invoice_date = parsedDate;
}
if ("sellerName" in input) {
updateData.seller_name = input.sellerName;
}
if ("buyerName" in input) {
updateData.buyer_name = input.buyerName;
}
if ("amount" in input) {
const parsedAmount = normalizeMoneyInput(input.amount);
if (parsedAmount === undefined) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { amount: "invalid_money" },
});
}
updateData.amount = parsedAmount;
}
if ("taxAmount" in input) {
const parsedAmount = normalizeMoneyInput(input.taxAmount);
if (parsedAmount === undefined) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { taxAmount: "invalid_money" },
});
}
updateData.tax_amount = parsedAmount;
}
if ("totalAmount" in input) {
const parsedAmount = normalizeMoneyInput(input.totalAmount);
if (parsedAmount === undefined) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { totalAmount: "invalid_money" },
});
}
updateData.total_amount = parsedAmount;
}
if (input.status) {
if (input.status === "available" && current.status === "ocr_failed") {
const nextSnapshot = {
invoiceCode: "invoiceCode" in input ? input.invoiceCode : current.invoice_code,
invoiceNumber: "invoiceNumber" in input ? input.invoiceNumber : current.invoice_number,
invoiceDate:
"invoiceDate" in input
? input.invoiceDate
: current.invoice_date?.toISOString?.()?.slice(0, 10) ?? null,
totalAmount:
"totalAmount" in input
? normalizeMoneyInput(input.totalAmount)
: current.total_amount?.toString?.() ?? null,
sellerName: "sellerName" in input ? input.sellerName : current.seller_name,
};
const missingFields = getMissingRequiredOcrFields(current.ocr_type, nextSnapshot);
if (missingFields.length) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
ocr: "missing_required_fields",
missingFields,
},
});
}
updateData.status = "available";
updateData.ocr_last_error = null;
updateData.ocr_completed_at = new Date();
updateData.ocr_locked_at = null;
updateData.ocr_lock_owner = null;
} else {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_status_transition" },
});
}
}
if (Object.keys(updateData).length === 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { body: "empty_patch" },
});
}
updateData.updated_by = request.employee.id;
const updated = await tx.invoicePool.update({
where: { id: invoiceId },
data: updateData,
select: buildInvoiceDetailSelect(),
});
const changes = {};
const trackedFields = [
"status",
"remarks",
"invoice_code",
"invoice_number",
"invoice_date",
"seller_name",
"buyer_name",
"amount",
"tax_amount",
"total_amount",
];
for (const field of trackedFields) {
const before = current[field];
const after = updated[field];
const beforeValue =
before instanceof Date
? before.toISOString().slice(0, 10)
: typeof before === "object" && before?.toString
? before.toString()
: before ?? null;
const afterValue =
after instanceof Date
? after.toISOString().slice(0, 10)
: typeof after === "object" && after?.toString
? after.toString()
: after ?? null;
if (beforeValue !== afterValue) {
changes[field] = {
before: beforeValue,
after: afterValue,
};
}
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "INVOICE_POOL_EDITED",
targetType: "invoice_pool",
targetId: invoiceId,
extra: {
changes,
},
}),
});
return updated;
});
return sendData(reply, request, normalizeInvoiceResponse(invoice));
},
);
fastify.post(
"/invoice-pool/:id/archive",
{
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 invoiceId = parsedInvoiceId.data;
const canManage = fastify.hasPermission(request.employee, "invoice.manage");
const invoice = await fastify.prisma.$transaction(async (tx) => {
const locked = await tx.$queryRaw`
SELECT id
FROM "invoice_pool"
WHERE id = ${invoiceId}::uuid
FOR UPDATE
`;
if (!Array.isArray(locked) || locked.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const current = await tx.invoicePool.findUnique({
where: { id: invoiceId },
select: {
id: true,
status: true,
linked_reimbursement_id: true,
travel_order_id: true,
owner_employee_id: true,
uploaded_by_employee_id: true,
archived_reason: true,
},
});
if (!current) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const canEdit =
canManage ||
current.owner_employee_id === request.employee.id ||
current.uploaded_by_employee_id === request.employee.id;
if (!canEdit) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
if (current.linked_reimbursement_id || current.travel_order_id) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { invoice: "cannot_archive_bound_invoice" },
});
}
if (current.status === "archived" && current.archived_reason === "manual") {
return tx.invoicePool.findUnique({
where: { id: invoiceId },
select: buildInvoiceDetailSelect(),
});
}
if (!["available", "ocr_failed"].includes(current.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_status_for_archive" },
});
}
const now = new Date();
const updated = await tx.invoicePool.update({
where: { id: invoiceId },
data: {
status: "archived",
archived_reason: "manual",
archived_at: now,
updated_by: request.employee.id,
},
select: buildInvoiceDetailSelect(),
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "INVOICE_POOL_ARCHIVED",
targetType: "invoice_pool",
targetId: invoiceId,
extra: {
reason: "manual",
},
}),
});
return updated;
});
return sendData(reply, request, normalizeInvoiceResponse(invoice));
},
);
fastify.post(
"/invoice-pool/:id/unbind",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw buildBusinessValidationError("reimbursement.invalid_uuid", { id: "invalid_uuid" }, 400);
}
const invoiceId = parsedInvoiceId.data;
let responseInvoice = null;
await fastify.prisma.$transaction(async (tx) => {
const bindingSnapshot = await tx.invoicePool.findUnique({
where: { id: invoiceId },
select: { linked_reimbursement_id: true },
});
if (bindingSnapshot?.linked_reimbursement_id) {
await tx.$queryRaw`
SELECT id FROM "reimbursement_forms"
WHERE id = ${bindingSnapshot.linked_reimbursement_id}::uuid FOR UPDATE
`;
}
const invoiceRows = await tx.$queryRaw`
SELECT id, status, linked_reimbursement_id, travel_order_id
FROM "invoice_pool"
WHERE id = ${invoiceId}::uuid
FOR UPDATE
`;
if (!Array.isArray(invoiceRows) || invoiceRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const invoice = invoiceRows[0];
if ((invoice.linked_reimbursement_id ?? null) !== (bindingSnapshot?.linked_reimbursement_id ?? null)) {
throw buildBusinessValidationError("reimbursement.invoice_unbind_status_invalid", { invoice: "binding_changed" }, 409);
}
if (invoice.status !== "bound" || (!invoice.linked_reimbursement_id && !invoice.travel_order_id)) {
throw buildBusinessValidationError("reimbursement.invoice_not_bound", { invoice: "not_bound" }, 409);
}
if (invoice.linked_reimbursement_id) {
const reimbursementRows = await tx.$queryRaw`
SELECT id, status, owner_id, submitter_id
FROM "reimbursement_forms"
WHERE id = ${invoice.linked_reimbursement_id}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const reimbursement = reimbursementRows[0];
const isOwn =
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id;
if (!isOwn) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw buildBusinessValidationError("reimbursement.invoice_unbind_status_invalid", { reimbursement: "invalid_status_for_unbind" }, 409);
}
} else {
if (!fastify.hasPermission(request.employee, "travel.payment.manage")) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
}
responseInvoice = await tx.invoicePool.update({
where: { id: invoiceId },
data: {
status: "available",
linked_reimbursement_id: null,
linked_item_id: null,
travel_order_id: null,
updated_by: request.employee.id,
},
select: buildInvoiceDetailSelect(),
});
});
return sendData(reply, request, normalizeInvoiceResponse(responseInvoice));
},
);
fastify.post(
"/invoice-pool/:id/retry-ocr",
{
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 invoiceId = parsedInvoiceId.data;
const canManage = fastify.hasPermission(request.employee, "invoice.manage");
const invoice = await fastify.prisma.$transaction(async (tx) => {
const locked = await tx.$queryRaw`
SELECT id
FROM "invoice_pool"
WHERE id = ${invoiceId}::uuid
FOR UPDATE
`;
if (!Array.isArray(locked) || locked.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const current = await tx.invoicePool.findUnique({
where: { id: invoiceId },
select: {
id: true,
status: true,
ocr_type: true,
ocr_attempts: true,
ocr_last_requested_at: true,
owner_employee_id: true,
uploaded_by_employee_id: true,
},
});
if (!current) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const canEdit =
canManage ||
current.owner_employee_id === request.employee.id ||
current.uploaded_by_employee_id === request.employee.id;
if (!canEdit) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
if (current.status !== "ocr_failed") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_status_for_retry" },
});
}
if (!isOcrEnabledForType(fastify.config, current.ocr_type)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { ocr: "disabled" },
});
}
const maxAttempts = Number(fastify.config.ocrMaxAttempts ?? 3);
if ((current.ocr_attempts ?? 0) >= maxAttempts) {
throw new AppError({
code: "VALID_006",
message: "OCR 重试次数已达上限",
statusCode: 409,
details: { ocr: "attempts_exceeded" },
});
}
const now = new Date();
if (current.ocr_last_requested_at) {
const diffMs = now.getTime() - new Date(current.ocr_last_requested_at).getTime();
if (diffMs < 60_000) {
throw new AppError({
code: "VALID_007",
message: "OCR 重试过于频繁,请稍后再试",
statusCode: 409,
details: { ocr: "too_frequent" },
});
}
}
const updated = await tx.invoicePool.update({
where: { id: invoiceId },
data: {
status: "pending_ocr",
ocr_provider: fastify.config.ocrProvider,
ocr_last_requested_at: now,
ocr_last_error: null,
ocr_completed_at: null,
ocr_locked_at: null,
ocr_lock_owner: null,
updated_by: request.employee.id,
},
select: buildInvoiceDetailSelect(),
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "INVOICE_OCR_RETRIED",
targetType: "invoice_pool",
targetId: invoiceId,
extra: {
attempts: current.ocr_attempts ?? 0,
nextStatus: "pending_ocr",
},
}),
});
return updated;
});
enqueueOcrInvoice(fastify, invoice.id).catch((error) => {
request.log.warn({ err: error, invoiceId: invoice.id }, "Failed to enqueue retry OCR invoice job");
});
return sendData(reply, request, normalizeInvoiceResponse(invoice));
},
);
fastify.post(
"/invoice-pool/:id/override-duplicate",
{
preHandler: fastify.requirePermission("expense.finance.override_duplicate"),
},
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 parsedBody = overrideDuplicateBodySchema.safeParse(request.body ?? {});
if (!parsedBody.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsedBody.error.flatten(),
});
}
const invoiceId = parsedInvoiceId.data;
const { refFormNumber, reason } = parsedBody.data;
const invoice = await fastify.prisma.$transaction(async (tx) => {
const locked = await tx.$queryRaw`
SELECT id
FROM "invoice_pool"
WHERE id = ${invoiceId}::uuid
FOR UPDATE
`;
if (!Array.isArray(locked) || locked.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const current = await tx.invoicePool.findUnique({
where: { id: invoiceId },
select: {
id: true,
status: true,
linked_reimbursement_id: true,
travel_order_id: true,
file_sha256: true,
invoice_code: true,
invoice_number: true,
is_duplicate_override: true,
},
});
if (!current) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
if (current.linked_reimbursement_id || current.travel_order_id) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { invoice: "already_bound" },
});
}
if (!["available", "ocr_failed"].includes(current.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_status_for_override" },
});
}
if (!current.file_sha256 && !(current.invoice_code && current.invoice_number)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { invoice: "missing_dedup_key" },
});
}
const dedupFilters = [];
if (current.file_sha256) {
dedupFilters.push({ file_sha256: current.file_sha256 });
}
if (current.invoice_code && current.invoice_number) {
dedupFilters.push({
invoice_code: current.invoice_code,
invoice_number: current.invoice_number,
});
}
const conflicts = await tx.invoicePool.findMany({
where: {
id: { not: invoiceId },
is_duplicate_override: false,
AND: [
{ OR: dedupFilters },
{
OR: [
{ status: "bound" },
{ status: "archived", archived_reason: "used_complete" },
],
},
],
},
select: {
id: true,
status: true,
linked_reimbursement_id: true,
linked_reimbursement: {
select: {
status: true,
form_number: true,
},
},
},
});
const blocked = conflicts.find((item) =>
["approved", "paid"].includes(item.linked_reimbursement?.status),
);
if (blocked) {
throw new AppError({
code: "VALID_004",
message: "原单已审批/已付款,禁止判重例外",
statusCode: 409,
details: {
conflictInvoiceId: blocked.id,
conflictFormNumber: blocked.linked_reimbursement?.form_number ?? null,
},
});
}
const updated = await tx.invoicePool.update({
where: { id: invoiceId },
data: {
is_duplicate_override: true,
override_ref_form_number: refFormNumber,
override_reason: reason,
overridden_by: request.employee.id,
updated_by: request.employee.id,
},
select: buildInvoiceDetailSelect(),
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "INVOICE_DUPLICATE_OVERRIDDEN",
targetType: "invoice_pool",
targetId: invoiceId,
extra: {
refFormNumber,
reason,
conflictInvoiceIds: conflicts.map((item) => item.id),
},
}),
});
return updated;
});
return sendData(reply, request, normalizeInvoiceResponse(invoice));
},
);
fastify.post(
"/invoice-pool/:id/bind",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw buildBusinessValidationError("reimbursement.invalid_uuid", { id: "invalid_uuid" }, 400);
}
const parsedBody = bindBodySchema.safeParse(request.body ?? {});
if (!parsedBody.success) {
throw buildBusinessValidationError("reimbursement.invalid_input", parsedBody.error.flatten(), 400);
}
const invoiceId = parsedInvoiceId.data;
try {
await fastify.prisma.$transaction(async (tx) => {
if ("reimbursementId" in parsedBody.data) {
if (!fastify.hasPermission(request.employee, "invoice.upload")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
const forms = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, status FROM "reimbursement_forms"
WHERE id = ${parsedBody.data.reimbursementId}::uuid FOR UPDATE
`;
const form = forms[0];
if (!form) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
if (form.owner_id !== request.employee.id && form.submitter_id !== request.employee.id) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
if (!["draft", "rejected"].includes(form.status)) {
throw buildBusinessValidationError("reimbursement.invoice_bind_status_invalid", { reimbursement: "invalid_status_for_bind" }, 409);
}
}
const invoiceRows = await tx.$queryRaw`
SELECT id
FROM "invoice_pool"
WHERE id = ${invoiceId}::uuid
FOR UPDATE
`;
if (!Array.isArray(invoiceRows) || invoiceRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const invoice = await tx.invoicePool.findUnique({
where: { id: invoiceId },
select: {
status: true,
owner_employee_id: true,
uploaded_by_employee_id: true,
file_sha256: true,
invoice_code: true,
invoice_number: true,
invoice_date: true,
seller_name: true,
original_name: true,
total_amount: true,
linked_reimbursement_id: true,
linked_item_id: true,
travel_order_id: true,
archived_reason: true,
is_duplicate_override: true,
ocr_result: { select: { result_json: true } },
},
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const sameFormRetry = parsedBody.data.createItem === true &&
invoice.status === "bound" && !invoice.travel_order_id &&
invoice.linked_reimbursement_id === parsedBody.data.reimbursementId && Boolean(invoice.linked_item_id);
if (!sameFormRetry && (invoice.status === "bound" || invoice.linked_reimbursement_id || invoice.travel_order_id)) {
throw new AppError({
code: "VALID_005",
message: "发票已被其他业务单据占用/绑定",
statusCode: 409,
});
}
if (!sameFormRetry && invoice.status !== "available") {
throw new AppError({
code: "VALID_005",
message: "发票已被其他业务单据占用/绑定",
statusCode: 409,
details: { status: invoice.status },
});
}
if (!sameFormRetry && !invoice.is_duplicate_override) {
const duplicateBlocker = await resolveInvoiceDedupBlocker(tx, {
excludeInvoiceId: invoiceId,
fileSha256: invoice.file_sha256,
invoiceCode: invoice.invoice_code,
invoiceNumber: invoice.invoice_number,
ownerEmployeeId: invoice.owner_employee_id,
});
if (duplicateBlocker) {
throw new AppError({
code: "VALID_003",
message: "发票不能重复添加",
statusCode: 409,
details: {
invoice: duplicateBlocker.reason,
message: mapInvoiceUnavailableReason(duplicateBlocker.reason),
conflictInvoiceId: duplicateBlocker.conflict.id,
conflictFormNumber:
duplicateBlocker.conflict.linked_reimbursement?.form_number ?? null,
},
});
}
}
if ("reimbursementId" in parsedBody.data) {
if (!fastify.hasPermission(request.employee, "invoice.upload")) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const { reimbursementId, linkedItemId, createItem } = parsedBody.data;
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, department_id, status, reimbursement_date
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const reimbursement = reimbursementRows[0];
const isOwn =
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id;
if (!isOwn) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw buildBusinessValidationError("reimbursement.invoice_bind_status_invalid", { reimbursement: "invalid_status_for_bind" }, 409);
}
if (invoice.owner_employee_id !== reimbursement.owner_id) {
throw buildBusinessValidationError("reimbursement.invoice_owner_mismatch", { invoice: "owner_mismatch" }, 409);
}
if (sameFormRetry) {
await resolveInvoiceLinkedItem(tx, {
reimbursementId, invoice, linkedItemId: invoice.linked_item_id, actorId: request.employee.id,
});
return;
}
const invoiceGuardGate = await evaluateFeatureFlag({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.reimbursementInvoiceGuard,
departmentId: reimbursement.department_id ?? null,
strictDepartment: true,
});
if (invoiceGuardGate.enabled && !linkedItemId && !createItem) {
throw buildBusinessValidationError("reimbursement.invoice_item_required", { linkedItemId: "required_when_invoice_guard_enabled" }, 400);
}
const resolvedLink = await resolveInvoiceLinkedItem(tx, {
reimbursementId,
reimbursementDate: reimbursement.reimbursement_date,
invoice,
linkedItemId,
actorId: request.employee.id,
createItem,
});
await tx.invoicePool.update({
where: { id: invoiceId },
data: {
status: "bound",
linked_reimbursement_id: reimbursementId,
linked_item_id: resolvedLink.linkedItemId,
travel_order_id: null,
updated_by: request.employee.id,
},
});
if (resolvedLink.createdItem) {
await recomputeReimbursementTotal(tx, reimbursementId, request.employee.id);
}
return;
}
if (!fastify.hasPermission(request.employee, "travel.payment.manage")) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const { travelOrderId } = parsedBody.data;
const travelOrderRows = await tx.$queryRaw`
SELECT id, status
FROM "travel_orders"
WHERE id = ${travelOrderId}::uuid
FOR UPDATE
`;
if (!Array.isArray(travelOrderRows) || travelOrderRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const travelOrder = travelOrderRows[0];
if (!["confirmed", "paid"].includes(travelOrder.status)) {
throw buildBusinessValidationError("reimbursement.travel_invoice_bind_status_invalid", { travelOrder: "invalid_status_for_bind" }, 409);
}
await tx.invoicePool.update({
where: { id: invoiceId },
data: {
status: "bound",
linked_reimbursement_id: null,
linked_item_id: null,
travel_order_id: travelOrderId,
updated_by: request.employee.id,
},
});
});
} catch (error) {
if (isInvoiceDedupConflict(error)) {
throw new AppError({
code: "VALID_003",
message: "发票判重命中",
statusCode: 409,
});
}
if (error?.code === "P2002") {
fastify.log.warn(
{ meta: error?.meta ?? {} },
"Unexpected P2002 not from invoice dedup constraints",
);
}
throw error;
}
const invoice = await fastify.prisma.invoicePool.findUnique({
where: { id: invoiceId },
select: {
id: true,
status: true,
linked_reimbursement_id: true,
linked_item_id: true,
travel_order_id: true,
total_amount: true,
},
});
return sendData(reply, request, {
id: invoice.id,
status: invoice.status,
linkedReimbursementId: invoice.linked_reimbursement_id,
linkedItemId: invoice.linked_item_id,
linkedTravelOrderId: invoice.travel_order_id,
totalAmount: toMoneyString(invoice.total_amount),
});
},
);
}
module.exports = invoicePoolRoutes;