1615 行
53 KiB
JavaScript
1615 行
53 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;
|
|
}
|
|
|
|
const parsed = new Date(`${trimmed}T00:00:00.000Z`);
|
|
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== trimmed) {
|
|
return undefined;
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
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 buildBusinessValidationError("invoice_pool.single_file_required", { file: "only_single_file_supported" }, 400);
|
|
}
|
|
fileName = part.filename ?? "upload";
|
|
fileMimeType = part.mimetype;
|
|
|
|
if (!isPdfInvoiceUpload(fileName, fileMimeType)) {
|
|
const error = buildBusinessValidationError("invoice_pool.pdf_required", { file: "pdf_invoice_required" });
|
|
error.message = "请上传 PDF 发票文件";
|
|
throw error;
|
|
}
|
|
|
|
const chunks = [];
|
|
for await (const chunk of part.file) {
|
|
fileSize += chunk.length;
|
|
if (fileSize > invoiceUploadMaxBytes) {
|
|
if (invoiceUploadMaxBytes === OCR_PDF_MAX_BYTES) {
|
|
const error = buildBusinessValidationError("invoice_pool.ocr_pdf_too_large", {
|
|
file: "ocr_pdf_too_large", maxBytes: invoiceUploadMaxBytes,
|
|
});
|
|
error.message = "PDF 发票不能超过 7 MiB";
|
|
throw error;
|
|
}
|
|
throw buildBusinessValidationError("invoice_pool.upload_too_large", {
|
|
file: "file_too_large", maxBytes: invoiceUploadMaxBytes,
|
|
});
|
|
}
|
|
chunks.push(chunk);
|
|
}
|
|
|
|
if (fileSize === 0) {
|
|
throw buildBusinessValidationError("invoice_pool.empty_file", { file: "empty_file" }, 400);
|
|
}
|
|
|
|
fileBuffer = Buffer.concat(chunks);
|
|
continue;
|
|
}
|
|
|
|
fields[part.fieldname] = part.value;
|
|
}
|
|
} catch (error) {
|
|
if (error?.code === "FST_REQ_FILE_TOO_LARGE") {
|
|
throw buildBusinessValidationError("invoice_pool.upload_too_large", { file: "file_too_large" }, 400);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
if (!fileBuffer) {
|
|
throw buildBusinessValidationError("invoice_pool.file_required", { file: "missing" }, 400);
|
|
}
|
|
|
|
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 buildBusinessValidationError("invoice_pool.owner_unavailable", { owner_employee_id: "invalid_owner" }, 409);
|
|
}
|
|
}
|
|
|
|
const ocrType = fields.ocr_type ?? "general";
|
|
if (!OCR_TYPE_ALLOWLIST.has(ocrType)) {
|
|
throw buildBusinessValidationError("invoice_pool.ocr_type_invalid", { ocr_type: "invalid" }, 400);
|
|
}
|
|
|
|
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 buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
|
|
}
|
|
|
|
const parsedBody = invoiceEditBodySchema.safeParse(request.body ?? {});
|
|
if (!parsedBody.success) {
|
|
throw buildBusinessValidationError("invoice_pool.edit_input_invalid", parsedBody.error.flatten());
|
|
}
|
|
|
|
if (Object.keys(parsedBody.data).length === 0) {
|
|
throw buildBusinessValidationError("invoice_pool.edit_empty", { body: "empty_patch" }, 400);
|
|
}
|
|
|
|
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 buildBusinessValidationError("invoice_pool.not_editable", { status: "invalid_status_for_edit" }, 409);
|
|
}
|
|
|
|
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 buildBusinessValidationError("invoice_pool.date_invalid", { invoiceDate: "invalid_date" }, 400);
|
|
}
|
|
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 buildBusinessValidationError("invoice_pool.amount_invalid", { amount: "invalid_money" }, 400);
|
|
}
|
|
updateData.amount = parsedAmount;
|
|
}
|
|
|
|
if ("taxAmount" in input) {
|
|
const parsedAmount = normalizeMoneyInput(input.taxAmount);
|
|
if (parsedAmount === undefined) {
|
|
throw buildBusinessValidationError("invoice_pool.tax_amount_invalid", { taxAmount: "invalid_money" }, 400);
|
|
}
|
|
updateData.tax_amount = parsedAmount;
|
|
}
|
|
|
|
if ("totalAmount" in input) {
|
|
const parsedAmount = normalizeMoneyInput(input.totalAmount);
|
|
if (parsedAmount === undefined) {
|
|
throw buildBusinessValidationError("invoice_pool.total_amount_invalid", { totalAmount: "invalid_money" }, 400);
|
|
}
|
|
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 buildBusinessValidationError("invoice_pool.ocr_fields_missing", {
|
|
ocr: "missing_required_fields",
|
|
missingFields,
|
|
}, 409);
|
|
}
|
|
|
|
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 buildBusinessValidationError("invoice_pool.status_transition_invalid", { status: "invalid_status_transition" }, 409);
|
|
}
|
|
}
|
|
|
|
if (Object.keys(updateData).length === 0) {
|
|
throw buildBusinessValidationError("invoice_pool.edit_empty", { body: "empty_patch" }, 400);
|
|
}
|
|
|
|
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 buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
|
|
}
|
|
|
|
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 buildBusinessValidationError("invoice_pool.archive_bound", { invoice: "cannot_archive_bound_invoice" }, 409);
|
|
}
|
|
|
|
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 buildBusinessValidationError("invoice_pool.archive_status_invalid", { status: "invalid_status_for_archive" }, 409);
|
|
}
|
|
|
|
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 buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
|
|
}
|
|
|
|
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 buildBusinessValidationError("invoice_pool.ocr_retry_status_invalid", { status: "invalid_status_for_retry" }, 409);
|
|
}
|
|
|
|
if (!isOcrEnabledForType(fastify.config, current.ocr_type)) {
|
|
throw buildBusinessValidationError("invoice_pool.ocr_unavailable", { ocr: "disabled" }, 409);
|
|
}
|
|
|
|
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 buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
|
|
}
|
|
|
|
const parsedBody = overrideDuplicateBodySchema.safeParse(request.body ?? {});
|
|
if (!parsedBody.success) {
|
|
throw buildBusinessValidationError("invoice_pool.override_input_invalid", 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 buildBusinessValidationError("invoice_pool.override_bound", { invoice: "already_bound" }, 409);
|
|
}
|
|
|
|
if (!["available", "ocr_failed"].includes(current.status)) {
|
|
throw buildBusinessValidationError("invoice_pool.override_status_invalid", { status: "invalid_status_for_override" }, 409);
|
|
}
|
|
|
|
if (!current.file_sha256 && !(current.invoice_code && current.invoice_number)) {
|
|
throw buildBusinessValidationError("invoice_pool.dedup_identity_missing", { invoice: "missing_dedup_key" }, 409);
|
|
}
|
|
|
|
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;
|