From 0ab0740d1ab524fe24b2c0e8b1064615f1b86282 Mon Sep 17 00:00:00 2001 From: shunli Date: Fri, 8 May 2026 10:05:27 +0800 Subject: [PATCH] =?UTF-8?q?R111/A3-A6=20=E5=BC=BA=E5=8C=96=E5=8F=91?= =?UTF-8?q?=E7=A5=A8=E6=9F=A5=E9=87=8D=E5=92=8C=E7=8A=B6=E6=80=81=E6=B2=BB?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/jobs/ocrJob.js | 43 +--- server/src/routes/invoicePool.js | 239 ++++++++++++++++-- server/src/routes/invoicePool/read.js | 148 +++++++++++ server/src/services/invoiceGovernance.js | 179 +++++++++++++ server/tests/unit/jobs/ocrJob.test.js | 1 + .../routes/invoicePool.travel-bind.test.js | 130 +++++++++- .../unit/routes/invoicePool.upload.test.js | 191 +++++++++++++- .../routes/reimbursements.withdraw.test.js | 10 + 8 files changed, 888 insertions(+), 53 deletions(-) create mode 100644 server/src/services/invoiceGovernance.js diff --git a/server/src/jobs/ocrJob.js b/server/src/jobs/ocrJob.js index 25b6e513..be2825f5 100644 --- a/server/src/jobs/ocrJob.js +++ b/server/src/jobs/ocrJob.js @@ -1,6 +1,7 @@ const cron = require("node-cron"); const { buildMissingRequiredOcrError, runOcrForInvoice } = require("../services/ocr"); +const { resolveInvoiceDedupBlocker } = require("../services/invoiceGovernance"); const { readFileBuffer } = require("../services/storage"); const OCR_DUPLICATE_ERROR = "duplicate_invoice_code_number"; @@ -69,35 +70,10 @@ async function claimPendingInvoices(prisma, config, workerId) { ocr_lock_owner = ${workerId} FROM candidates WHERE i.id = candidates.id - RETURNING i.id, i.file_sha256, i.ocr_provider, i.ocr_type, i.storage_path, i.mime_type, i.ocr_attempts + RETURNING i.id, i.owner_employee_id, i.file_sha256, i.ocr_provider, i.ocr_type, i.storage_path, i.mime_type, i.ocr_attempts `; } -async function findDuplicateInvoice(prisma, invoiceId, invoiceCode, invoiceNumber) { - if (!invoiceCode || !invoiceNumber) { - return null; - } - - return prisma.invoicePool.findFirst({ - where: { - id: { not: invoiceId }, - invoice_code: invoiceCode, - invoice_number: invoiceNumber, - is_duplicate_override: false, - OR: [ - { status: "bound" }, - { - status: "archived", - archived_reason: "used_complete", - }, - ], - }, - select: { - id: true, - }, - }); -} - async function getOrCreateOcrResult(prisma, config, claimedInvoice, imageBase64) { const ocrProvider = getEffectiveOcrProvider(config); const where = { @@ -303,11 +279,15 @@ async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, l return { outcome: "failed" }; } - const duplicate = await findDuplicateInvoice( + const duplicate = await resolveInvoiceDedupBlocker( prisma, - claimedInvoice.id, - normalized.invoiceCode, - normalized.invoiceNumber, + { + excludeInvoiceId: claimedInvoice.id, + fileSha256: claimedInvoice.file_sha256, + invoiceCode: normalized.invoiceCode, + invoiceNumber: normalized.invoiceNumber, + ownerEmployeeId: claimedInvoice.owner_employee_id, + }, ); if (duplicate) { @@ -315,7 +295,8 @@ async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, l logger.warn( { invoiceId: claimedInvoice.id, - duplicateInvoiceId: duplicate.id, + duplicateInvoiceId: duplicate.conflict.id, + duplicateReason: duplicate.reason, mimeType: claimedInvoice.mime_type, transportMode: normalized?.transportMode ?? normalized?.raw?.transportMode ?? null, }, diff --git a/server/src/routes/invoicePool.js b/server/src/routes/invoicePool.js index 47a75778..b04b460c 100644 --- a/server/src/routes/invoicePool.js +++ b/server/src/routes/invoicePool.js @@ -15,6 +15,11 @@ const { registerInvoicePoolReadRoutes } = require("./invoicePool/read"); const { normalizeInvoiceResponse } = require("./invoicePool/shared/mappers"); const { buildInvoiceDetailSelect } = require("./invoicePool/shared/selects"); const { writeBuffer } = require("../services/storage"); +const { + mapInvoiceUnavailableReason, + resolveInvoiceDedupBlocker, + resolveUploadDedupDecision, +} = require("../services/invoiceGovernance"); const MIME_ALLOWLIST = new Set([ "application/pdf", @@ -193,6 +198,145 @@ function parseDateOnlyString(value) { return new Date(`${trimmed}T00:00:00.000Z`); } +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 }) { + if (linkedItemId) { + const linkedItem = await tx.reimbursementItem.findFirst({ + where: { + id: linkedItemId, + reimbursement_id: reimbursementId, + }, + select: { id: true }, + }); + if (!linkedItem) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { linkedItemId: "item_not_in_reimbursement" }, + }); + } + 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 new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { invoice: "missing_total_amount_for_auto_item" }, + }); + } + + const expenseType = await resolveDefaultExpenseType(tx); + if (!expenseType) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { expenseTypeId: "missing_enabled_expense_type" }, + }); + } + + 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); @@ -346,6 +490,28 @@ async function invoicePoolRoutes(fastify) { } 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, + }); + } + const datePrefix = compactDateString(fastify.config.appTimezone); const extension = path.extname(fileName ?? "").toLowerCase(); const objectKey = path.posix.join(datePrefix, `${crypto.randomUUID()}${extension}`); @@ -1296,8 +1462,20 @@ async function invoicePoolRoutes(fastify) { 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, }, }); @@ -1326,6 +1504,30 @@ async function invoicePoolRoutes(fastify) { }); } + if (!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({ @@ -1337,7 +1539,7 @@ async function invoicePoolRoutes(fastify) { const { reimbursementId, linkedItemId } = parsedBody.data; const reimbursementRows = await tx.$queryRaw` - SELECT id, owner_id, submitter_id, status + SELECT id, owner_id, submitter_id, status, reimbursement_date FROM "reimbursement_forms" WHERE id = ${reimbursementId}::uuid FOR UPDATE @@ -1372,34 +1574,37 @@ async function invoicePoolRoutes(fastify) { }); } - if (linkedItemId) { - const linkedItem = await tx.reimbursementItem.findFirst({ - where: { - id: linkedItemId, - reimbursement_id: reimbursementId, - }, - select: { id: true }, + if (invoice.owner_employee_id !== reimbursement.owner_id) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { invoice: "owner_mismatch" }, }); - if (!linkedItem) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { linkedItemId: "item_not_in_reimbursement" }, - }); - } } + const resolvedLink = await resolveInvoiceLinkedItem(tx, { + reimbursementId, + reimbursementDate: reimbursement.reimbursement_date, + invoice, + linkedItemId, + actorId: request.employee.id, + }); + await tx.invoicePool.update({ where: { id: invoiceId }, data: { status: "bound", linked_reimbursement_id: reimbursementId, - linked_item_id: linkedItemId ?? null, + 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; } diff --git a/server/src/routes/invoicePool/read.js b/server/src/routes/invoicePool/read.js index ab3ad736..71bb7cea 100644 --- a/server/src/routes/invoicePool/read.js +++ b/server/src/routes/invoicePool/read.js @@ -6,6 +6,7 @@ const { buildAuditLogData } = require("../../utils/audit"); const { signPayload, verifyToken } = require("../../utils/signedUrl"); const { openReadStream, StorageNotFoundError } = require("../../services/storage"); const { toMoneyString } = require("../../utils/workflow"); +const { mapInvoiceUnavailableReason } = require("../../services/invoiceGovernance"); const { mapInvoiceSummary, normalizeInvoiceResponse, @@ -26,6 +27,9 @@ const bindableInvoiceQuerySchema = z.object({ search: z.string().trim().max(100).optional(), limit: z.coerce.number().int().min(1).max(50).optional(), }); +const invoiceStatusQuerySchema = z.object({ + ids: z.string().trim().min(1).max(2000), +}); function sanitizeFilename(fileName) { if (!fileName) return "file"; @@ -69,6 +73,83 @@ function parseBindableInvoiceQuery(query) { }; } +function parseInvoiceStatusQuery(query) { + const parsed = invoiceStatusQuerySchema.safeParse(query ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + const ids = Array.from( + new Set( + parsed.data.ids + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + ), + ); + + if (!ids.length || ids.length > 50 || ids.some((id) => !z.string().uuid().safeParse(id).success)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { ids: "invalid_uuid_list" }, + }); + } + + return { ids }; +} + +function resolveInvoiceStatusReason(invoice) { + if (invoice.status === "pending_ocr") { + return { + reason: "pending_ocr", + message: "发票正在识别,请稍后刷新", + }; + } + + if (invoice.status === "ocr_failed") { + const rawError = String(invoice.ocr_last_error ?? ""); + if (rawError === "duplicate_invoice_code_number" || rawError.includes("duplicate_invoice")) { + return { + reason: "duplicate_invoice_code_number", + message: mapInvoiceUnavailableReason("used_by_company") ?? "发票重复,不能添加", + }; + } + return { + reason: "ocr_failed", + message: rawError || "发票识别失败,请重新识别或手动处理", + }; + } + + if (invoice.status === "bound") { + return { + reason: "already_added", + message: "发票已添加到报销单", + }; + } + + if (invoice.status === "archived") { + return { + reason: invoice.archived_reason === "used_complete" ? "used_by_company" : "archived", + message: + invoice.archived_reason === "used_complete" + ? "该发票已用于报销,不能重复添加" + : "发票已归档,不可添加", + }; + } + + return { + reason: "available", + message: "发票可添加", + }; +} + function ensureInvoiceReadableByUser(request, invoice) { const roleCodes = request.employee.roleCodes; const permissionCodes = request.employee.permissionCodes ?? []; @@ -126,6 +207,73 @@ async function hasPendingTaskAccess(fastify, request, linkedReimbursementId) { } function registerInvoicePoolReadRoutes(fastify) { + fastify.get( + "/invoice-pool/status", + { + schema: { tags: ["Invoice Pool"], summary: "Get Invoice Status Batch" }, + preHandler: fastify.requirePermission("invoice.view_own"), + }, + async (request, reply) => { + const query = parseInvoiceStatusQuery(request.query); + const rows = await fastify.prisma.invoicePool.findMany({ + where: { id: { in: query.ids } }, + select: { + id: true, + status: true, + archived_reason: true, + owner_employee_id: true, + uploaded_by_employee_id: true, + linked_reimbursement_id: true, + linked_item_id: true, + travel_order_id: true, + invoice_code: true, + invoice_number: true, + invoice_date: true, + seller_name: true, + total_amount: true, + original_name: true, + ocr_attempts: true, + ocr_last_error: true, + ocr_completed_at: true, + updated_at: true, + linked_reimbursement: { + select: { + owner_id: true, + submitter_id: true, + }, + }, + }, + }); + + const readableRows = rows.filter((invoice) => ensureInvoiceReadableByUser(request, invoice)); + return sendData(reply, request, { + items: readableRows.map((invoice) => { + const statusReason = resolveInvoiceStatusReason(invoice); + return { + id: invoice.id, + status: invoice.status, + reason: statusReason.reason, + message: statusReason.message, + invoiceCode: invoice.invoice_code ?? null, + invoiceNumber: invoice.invoice_number ?? null, + invoiceDate: invoice.invoice_date?.toISOString?.()?.slice(0, 10) ?? null, + sellerName: invoice.seller_name ?? null, + totalAmount: toMoneyString(invoice.total_amount), + originalName: invoice.original_name ?? null, + linkedReimbursementId: invoice.linked_reimbursement_id ?? null, + linkedItemId: invoice.linked_item_id ?? null, + linkedTravelOrderId: invoice.travel_order_id ?? null, + ocrAttempts: invoice.ocr_attempts ?? 0, + ocrLastError: invoice.ocr_last_error ?? null, + ocrCompletedAt: invoice.ocr_completed_at?.toISOString?.() ?? null, + updatedAt: invoice.updated_at?.toISOString?.() ?? null, + }; + }), + missingIds: query.ids.filter((id) => !readableRows.some((invoice) => invoice.id === id)), + }); + }, + ); + fastify.get( "/invoice-pool/bindable", { diff --git a/server/src/services/invoiceGovernance.js b/server/src/services/invoiceGovernance.js new file mode 100644 index 00000000..5bb0f4ef --- /dev/null +++ b/server/src/services/invoiceGovernance.js @@ -0,0 +1,179 @@ +const USED_COMPLETE_ARCHIVED_REASON = "used_complete"; + +const INVOICE_UNAVAILABLE_REASONS = { + duplicateAvailable: "duplicate_available_in_company", + duplicatePending: "duplicate_pending_in_company", + duplicateFailed: "duplicate_failed_in_company", + duplicateOtherOwner: "duplicate_other_owner_in_company", + usedByCompany: "used_by_company", +}; + +function buildInvoiceDedupFilters({ fileSha256 = null, invoiceCode = null, invoiceNumber = null }) { + const filters = []; + if (fileSha256) { + filters.push({ file_sha256: fileSha256 }); + } + if (invoiceCode && invoiceNumber) { + filters.push({ + invoice_code: invoiceCode, + invoice_number: invoiceNumber, + }); + } + return filters; +} + +function isUsedInvoice(invoice) { + return Boolean( + invoice?.status === "bound" || + invoice?.linked_reimbursement_id || + invoice?.travel_order_id || + (invoice?.status === "archived" && invoice?.archived_reason === USED_COMPLETE_ARCHIVED_REASON), + ); +} + +function classifyInvoiceConflict(conflict, ownerEmployeeId) { + if (!conflict) { + return null; + } + + if (isUsedInvoice(conflict)) { + return INVOICE_UNAVAILABLE_REASONS.usedByCompany; + } + + if (conflict.owner_employee_id && conflict.owner_employee_id !== ownerEmployeeId) { + return INVOICE_UNAVAILABLE_REASONS.duplicateOtherOwner; + } + + if (conflict.status === "pending_ocr") { + return INVOICE_UNAVAILABLE_REASONS.duplicatePending; + } + + if (conflict.status === "ocr_failed") { + return INVOICE_UNAVAILABLE_REASONS.duplicateFailed; + } + + return INVOICE_UNAVAILABLE_REASONS.duplicateAvailable; +} + +function rankInvoiceConflict(reason) { + switch (reason) { + case INVOICE_UNAVAILABLE_REASONS.usedByCompany: + return 10; + case INVOICE_UNAVAILABLE_REASONS.duplicateOtherOwner: + return 20; + case INVOICE_UNAVAILABLE_REASONS.duplicateAvailable: + return 30; + case INVOICE_UNAVAILABLE_REASONS.duplicatePending: + return 40; + case INVOICE_UNAVAILABLE_REASONS.duplicateFailed: + return 50; + default: + return 100; + } +} + +function pickBlockingInvoiceConflict(conflicts, ownerEmployeeId) { + const ranked = conflicts + .map((conflict) => ({ + conflict, + reason: classifyInvoiceConflict(conflict, ownerEmployeeId), + })) + .filter((item) => item.reason) + .sort((a, b) => rankInvoiceConflict(a.reason) - rankInvoiceConflict(b.reason)); + + return ranked[0] ?? null; +} + +async function findInvoiceDedupConflicts(tx, input) { + const filters = buildInvoiceDedupFilters(input); + if (!filters.length) { + return []; + } + + return tx.invoicePool.findMany({ + where: { + ...(input.excludeInvoiceId ? { id: { not: input.excludeInvoiceId } } : {}), + is_duplicate_override: false, + OR: filters, + status: { in: ["pending_ocr", "ocr_failed", "available", "bound", "archived"] }, + }, + orderBy: [{ created_at: "asc" }, { id: "asc" }], + select: { + id: true, + owner_employee_id: true, + uploaded_by_employee_id: true, + status: true, + archived_reason: true, + linked_reimbursement_id: true, + linked_item_id: true, + travel_order_id: true, + invoice_code: true, + invoice_number: true, + file_sha256: true, + linked_reimbursement: { + select: { + form_number: true, + status: true, + owner_id: true, + submitter_id: true, + }, + }, + }, + }); +} + +async function resolveInvoiceDedupBlocker(tx, input) { + const conflicts = await findInvoiceDedupConflicts(tx, input); + return pickBlockingInvoiceConflict(conflicts, input.ownerEmployeeId); +} + +async function resolveUploadDedupDecision(tx, { fileSha256, ownerEmployeeId }) { + const conflicts = await findInvoiceDedupConflicts(tx, { fileSha256, ownerEmployeeId }); + const blocker = pickBlockingInvoiceConflict(conflicts, ownerEmployeeId); + if (!blocker) { + return { action: "create" }; + } + + const conflict = blocker.conflict; + if (!isUsedInvoice(conflict) && conflict.owner_employee_id === ownerEmployeeId) { + return { + action: "reuse_existing", + reason: blocker.reason, + invoice: conflict, + }; + } + + return { + action: "blocked", + reason: blocker.reason, + invoice: conflict, + }; +} + +function mapInvoiceUnavailableReason(reason) { + switch (reason) { + case INVOICE_UNAVAILABLE_REASONS.usedByCompany: + return "该发票已用于报销,不能重复添加"; + case INVOICE_UNAVAILABLE_REASONS.duplicateOtherOwner: + return "公司内已有相同发票,需管理员处理"; + case INVOICE_UNAVAILABLE_REASONS.duplicatePending: + return "相同发票正在识别,请稍后刷新"; + case INVOICE_UNAVAILABLE_REASONS.duplicateFailed: + return "相同发票已存在但识别失败,请先处理已有记录"; + case INVOICE_UNAVAILABLE_REASONS.duplicateAvailable: + return "公司内已有相同可用发票,不能重复添加"; + default: + return null; + } +} + +module.exports = { + INVOICE_UNAVAILABLE_REASONS, + buildInvoiceDedupFilters, + classifyInvoiceConflict, + findInvoiceDedupConflicts, + isUsedInvoice, + mapInvoiceUnavailableReason, + resolveInvoiceDedupBlocker, + resolveUploadDedupDecision, +}; diff --git a/server/tests/unit/jobs/ocrJob.test.js b/server/tests/unit/jobs/ocrJob.test.js index c9c314ac..264ffb88 100644 --- a/server/tests/unit/jobs/ocrJob.test.js +++ b/server/tests/unit/jobs/ocrJob.test.js @@ -25,6 +25,7 @@ function buildPrisma(claimedInvoices) { create: vi.fn(async () => ({ id: "ocr-result-1" })), }, invoicePool: { + findMany: vi.fn(async () => []), findFirst: vi.fn(async () => null), update: vi.fn(async () => ({ id: "invoice-1" })), }, diff --git a/server/tests/unit/routes/invoicePool.travel-bind.test.js b/server/tests/unit/routes/invoicePool.travel-bind.test.js index 2bbd8bda..8c3d1497 100644 --- a/server/tests/unit/routes/invoicePool.travel-bind.test.js +++ b/server/tests/unit/routes/invoicePool.travel-bind.test.js @@ -24,9 +24,28 @@ function buildQueryRawHandler(state) { }); } -function buildPrisma({ invoice, travelOrder, reimbursement = null }) { +function buildPrisma({ + invoice, + travelOrder, + reimbursement = null, + reimbursementItems = [{ id: "item-1", amount: "500.00" }], + linkedItem = { id: "item-1" }, + expenseType = { id: "expense-type-1" }, +}) { const state = { - invoice: { ...invoice }, + invoice: { + owner_employee_id: "actor-1", + uploaded_by_employee_id: "actor-1", + file_sha256: "sha256-1", + invoice_code: null, + invoice_number: null, + invoice_date: new Date("2026-05-08T00:00:00.000Z"), + seller_name: "测试商户", + original_name: "invoice.pdf", + archived_reason: null, + is_duplicate_override: false, + ...invoice, + }, travelOrder: travelOrder ? { ...travelOrder } : null, reimbursement, }; @@ -34,6 +53,7 @@ function buildPrisma({ invoice, travelOrder, reimbursement = null }) { const prisma = { invoicePool: { findUnique: vi.fn().mockImplementation(async () => state.invoice), + findMany: vi.fn().mockResolvedValue([]), update: vi.fn().mockImplementation(async ({ data, select }) => { state.invoice = { ...state.invoice, ...data }; if (!select) { @@ -46,7 +66,16 @@ function buildPrisma({ invoice, travelOrder, reimbursement = null }) { }), }, reimbursementItem: { - findFirst: vi.fn().mockResolvedValue({ id: "item-1" }), + findFirst: vi.fn().mockResolvedValue(linkedItem), + findMany: vi.fn().mockResolvedValue(reimbursementItems), + create: vi.fn().mockResolvedValue({ id: "item-auto-1" }), + aggregate: vi.fn().mockResolvedValue({ _sum: { amount: state.invoice.total_amount ?? "0.00" } }), + }, + reimbursementForm: { + update: vi.fn().mockResolvedValue({ id: reimbursement?.id ?? "reim-1" }), + }, + expenseType: { + findFirst: vi.fn().mockResolvedValue(expenseType), }, auditLog: { create: vi.fn().mockResolvedValue({ id: "audit-1" }), @@ -104,6 +133,101 @@ async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"] }) } describe("invoicePool travel-order bind routes", () => { + it("adds invoice to reimbursement and auto-creates an item when no detail matches", async () => { + const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const { prisma, state } = buildPrisma({ + invoice: { + id: "11111111-1111-4111-8111-111111111111", + status: "available", + linked_reimbursement_id: null, + linked_item_id: null, + travel_order_id: null, + total_amount: "1683.00", + }, + travelOrder: null, + reimbursement: { + id: reimbursementId, + owner_id: "actor-1", + submitter_id: "actor-1", + status: "draft", + reimbursement_date: new Date("2026-05-08T00:00:00.000Z"), + }, + reimbursementItems: [], + }); + const app = await buildApp({ prisma, permissionCodes: ["invoice.upload"] }); + + const response = await app.inject({ + method: "POST", + url: "/api/invoice-pool/11111111-1111-4111-8111-111111111111/bind", + headers: { authorization: "Bearer test" }, + payload: { reimbursementId }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.linkedReimbursementId).toBe(reimbursementId); + expect(response.json().data.linkedItemId).toBe("item-auto-1"); + expect(state.invoice.linked_item_id).toBe("item-auto-1"); + expect(prisma.reimbursementItem.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + reimbursement_id: reimbursementId, + expense_type_id: "expense-type-1", + amount: "1683.00", + }), + }), + ); + expect(prisma.reimbursementForm.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: reimbursementId }, + data: expect.objectContaining({ + total_amount: "1683.00", + updated_by: "actor-1", + }), + }), + ); + + await app.close(); + }); + + it("rejects reimbursement binding when linked item does not belong to the form", async () => { + const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const { prisma } = buildPrisma({ + invoice: { + id: "11111111-1111-4111-8111-111111111111", + status: "available", + linked_reimbursement_id: null, + linked_item_id: null, + travel_order_id: null, + total_amount: "1683.00", + }, + travelOrder: null, + reimbursement: { + id: reimbursementId, + owner_id: "actor-1", + submitter_id: "actor-1", + status: "draft", + reimbursement_date: new Date("2026-05-08T00:00:00.000Z"), + }, + linkedItem: null, + }); + const app = await buildApp({ prisma, permissionCodes: ["invoice.upload"] }); + + const response = await app.inject({ + method: "POST", + url: "/api/invoice-pool/11111111-1111-4111-8111-111111111111/bind", + headers: { authorization: "Bearer test" }, + payload: { + reimbursementId, + linkedItemId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error.details).toEqual({ linkedItemId: "item_not_in_reimbursement" }); + + await app.close(); + }); + it("binds invoice to confirmed travel order", async () => { const { prisma, state } = buildPrisma({ invoice: { diff --git a/server/tests/unit/routes/invoicePool.upload.test.js b/server/tests/unit/routes/invoicePool.upload.test.js index 2b6ca877..67634e1d 100644 --- a/server/tests/unit/routes/invoicePool.upload.test.js +++ b/server/tests/unit/routes/invoicePool.upload.test.js @@ -36,17 +36,29 @@ function buildMultipartRequest({ filename, contentType, content, fields = [] }) }; } -async function buildApp({ ocrProvider = "off" } = {}) { +async function buildApp({ + ocrProvider = "off", + invoiceConflicts = [], + statusRows = [], + permissionCodes = ["invoice.upload"], +} = {}) { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-r71-invoice-upload-")); const invoicePoolCreate = vi.fn(async ({ data }) => ({ id: "11111111-1111-4111-8111-111111111111", status: data.status, file_sha256: data.file_sha256, })); + const invoicePoolFindMany = vi.fn(async (args) => { + if (args?.where?.id?.in) { + return statusRows; + } + return invoiceConflicts; + }); const auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" }); const prisma = { invoicePool: { + findMany: invoicePoolFindMany, create: invoicePoolCreate, }, auditLog: { @@ -71,7 +83,7 @@ async function buildApp({ ocrProvider = "off" } = {}) { request.employee = { id: "actor-1", roleCodes: ["employee"], - permissionCodes: ["invoice.upload"], + permissionCodes, }; }); app.decorate("hasPermission", (actor, permissionCode) => { @@ -207,4 +219,179 @@ describe("invoicePool upload route", () => { await fs.rm(tempDir, { recursive: true, force: true }); } }); + + it("reuses same-owner existing invoice instead of creating a duplicate file record", async () => { + const existingInvoiceId = "22222222-2222-4222-8222-222222222222"; + const { app, prisma, tempDir } = await buildApp({ + invoiceConflicts: [ + { + id: existingInvoiceId, + owner_employee_id: "actor-1", + uploaded_by_employee_id: "actor-1", + status: "available", + archived_reason: null, + linked_reimbursement_id: null, + linked_item_id: null, + travel_order_id: null, + invoice_code: null, + invoice_number: null, + file_sha256: "unused-in-test", + linked_reimbursement: null, + }, + ], + }); + + try { + const requestPayload = buildMultipartRequest({ + filename: "same.pdf", + contentType: "application/pdf", + content: "%PDF-1.4 same invoice payload", + fields: [["ocr_type", "vat"]], + }); + + const response = await app.inject({ + method: "POST", + url: "/api/invoice-pool/upload", + headers: { + authorization: "Bearer test", + ...requestPayload.headers, + }, + payload: requestPayload.payload, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual( + expect.objectContaining({ + invoicePoolId: existingInvoiceId, + status: "available", + uploadAction: "reuse_existing", + unavailableReason: "duplicate_available_in_company", + }), + ); + expect(prisma.invoicePool.create).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + } finally { + await app.close(); + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("blocks upload when the same company invoice has already been used", async () => { + const { app, prisma, tempDir } = await buildApp({ + invoiceConflicts: [ + { + id: "33333333-3333-4333-8333-333333333333", + owner_employee_id: "actor-1", + uploaded_by_employee_id: "actor-1", + status: "bound", + archived_reason: null, + linked_reimbursement_id: "44444444-4444-4444-8444-444444444444", + linked_item_id: "55555555-5555-4555-8555-555555555555", + travel_order_id: null, + invoice_code: null, + invoice_number: null, + file_sha256: "unused-in-test", + linked_reimbursement: { + form_number: "BX20260508001", + status: "reviewing", + owner_id: "actor-1", + submitter_id: "actor-1", + }, + }, + ], + }); + + try { + const requestPayload = buildMultipartRequest({ + filename: "used.pdf", + contentType: "application/pdf", + content: "%PDF-1.4 used invoice payload", + fields: [["ocr_type", "vat"]], + }); + + const response = await app.inject({ + method: "POST", + url: "/api/invoice-pool/upload", + headers: { + authorization: "Bearer test", + ...requestPayload.headers, + }, + payload: requestPayload.payload, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual( + expect.objectContaining({ + invoicePoolId: null, + status: "bound", + uploadAction: "blocked", + unavailableReason: "used_by_company", + unavailableMessage: "该发票已用于报销,不能重复添加", + }), + ); + expect(prisma.invoicePool.create).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + } finally { + await app.close(); + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("returns readable invoice statuses for upload polling", async () => { + const invoiceId = "66666666-6666-4666-8666-666666666666"; + const { app, tempDir } = await buildApp({ + permissionCodes: ["invoice.view_own"], + statusRows: [ + { + id: invoiceId, + status: "archived", + archived_reason: "used_complete", + owner_employee_id: "actor-1", + uploaded_by_employee_id: "actor-1", + linked_reimbursement_id: "77777777-7777-4777-8777-777777777777", + linked_item_id: null, + travel_order_id: null, + invoice_code: "044001900111", + invoice_number: "12345678", + invoice_date: new Date("2026-05-08T00:00:00.000Z"), + seller_name: "北京测试有限公司", + total_amount: "1683.00", + original_name: "invoice.pdf", + ocr_attempts: 1, + ocr_last_error: null, + ocr_completed_at: new Date("2026-05-08T01:00:00.000Z"), + updated_at: new Date("2026-05-08T01:01:00.000Z"), + linked_reimbursement: { + owner_id: "actor-1", + submitter_id: "actor-1", + }, + }, + ], + }); + + try { + const response = await app.inject({ + method: "GET", + url: `/api/invoice-pool/status?ids=${invoiceId}`, + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual({ + items: [ + expect.objectContaining({ + id: invoiceId, + status: "archived", + reason: "used_by_company", + message: "该发票已用于报销,不能重复添加", + totalAmount: "1683.00", + }), + ], + missingIds: [], + }); + } finally { + await app.close(); + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/server/tests/unit/routes/reimbursements.withdraw.test.js b/server/tests/unit/routes/reimbursements.withdraw.test.js index 0bec1899..67f53ade 100644 --- a/server/tests/unit/routes/reimbursements.withdraw.test.js +++ b/server/tests/unit/routes/reimbursements.withdraw.test.js @@ -62,11 +62,20 @@ function buildPrisma({ processedTaskCount = 0 } = {}) { }, invoice: { id: INVOICE_ID, + owner_employee_id: OWNER_ID, + uploaded_by_employee_id: OWNER_ID, status: "bound", linked_reimbursement_id: REIMBURSEMENT_ID, linked_item_id: ITEM_ID, travel_order_id: null, + archived_reason: null, + file_sha256: "sha256-invoice", + invoice_code: "044001", + invoice_number: "12345678", + invoice_date: new Date("2026-05-01T00:00:00.000Z"), + seller_name: "测试销售方", total_amount: "1683.00", + is_duplicate_override: false, }, processedTaskCount, }; @@ -111,6 +120,7 @@ function buildPrisma({ processedTaskCount = 0 } = {}) { create: vi.fn(async () => ({ id: "history-1" })), }, invoicePool: { + findMany: vi.fn(async () => []), findUnique: vi.fn(async ({ where, select }) => { if (!state.invoice || where.id !== state.invoice.id) { return null;