文件
hyb-oa/server/src/routes/invoicePool.js
T
wangsli 8bd068f2a3 feat(server): 票据增强闭环(F9/F10/F11/F12/F13/F28)
- /invoice-pool 新增 unbind、PATCH 修正、manual archive、OCR retry、判重例外 override(含 VALID_004/006/007)

- signed-url 签发/下载写入审计日志(脱敏 extra)

- 新增未绑定票据自动归档作业 + 一次性执行脚本

- 回归脚本增强:workflow-actions/dedup/audit/ocr 覆盖上述场景;v3 CSV 状态推进至 DONE
2026-02-09 17:09:34 +08:00

2153 行
63 KiB
JavaScript

const crypto = require("node:crypto");
const fs = require("node:fs/promises");
const fsSync = require("node:fs");
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 { signPayload, verifyToken } = require("../utils/signedUrl");
const { toMoneyString } = require("../utils/workflow");
const { findActiveDelegationForDelegate } = require("../utils/delegation");
const { isOcrEnabledForType } = require("../services/ocr");
const MIME_ALLOWLIST = new Set([
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
]);
const OCR_TYPE_ALLOWLIST = new Set(["vat", "train", "flight", "taxi", "general"]);
const bindBodySchema = z.object({
reimbursementId: z.string().uuid(),
linkedItemId: z.string().uuid().optional(),
});
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 invoiceListQuerySchema = z.object({
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
status: z.enum(["pending_ocr", "ocr_failed", "available", "bound", "archived"]).optional(),
ownerScope: z.enum(["own", "all"]).optional(),
});
const DEDUP_CONSTRAINT_NAMES = new Set([
"invoice_pool_file_sha256_dedup_ux",
"invoice_pool_invoice_code_number_dedup_ux",
]);
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 ensureInvoiceReadableByUser(request, invoice) {
const roleCodes = request.employee.roleCodes;
if (
roleCodes.includes("admin") ||
roleCodes.includes("finance") ||
roleCodes.includes("finance_mgr")
) {
return true;
}
if (
invoice.owner_employee_id === request.employee.id ||
invoice.uploaded_by_employee_id === request.employee.id
) {
return true;
}
if (invoice.linked_reimbursement) {
const reimbursement = invoice.linked_reimbursement;
if (
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id
) {
return true;
}
}
return false;
}
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 sanitizeFilename(fileName) {
if (!fileName) return "file";
return fileName.replace(/[\r\n"]/g, "_");
}
function parseInvoiceListQuery(query) {
const parsed = invoiceListQuerySchema.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return {
page: parsed.data.page ?? 1,
pageSize: parsed.data.pageSize ?? 20,
status: parsed.data.status,
ownerScope: parsed.data.ownerScope,
};
}
function mapInvoiceSummary(item) {
return {
id: item.id,
status: item.status,
ocrType: item.ocr_type,
ocrProvider: item.ocr_provider,
ocrAttempts: item.ocr_attempts,
ocrLastError: item.ocr_last_error,
ocrCompletedAt: item.ocr_completed_at?.toISOString?.() ?? null,
invoiceCode: item.invoice_code,
invoiceNumber: item.invoice_number,
invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null,
amount: toMoneyString(item.amount),
taxAmount: toMoneyString(item.tax_amount),
totalAmount: toMoneyString(item.total_amount),
linkedReimbursementId: item.linked_reimbursement_id,
linkedItemId: item.linked_item_id,
originalName: item.original_name,
mimeType: item.mime_type,
fileSize: item.file_size,
owner: item.owner_employee
? {
id: item.owner_employee.id,
employeeNo: item.owner_employee.employee_no,
name: item.owner_employee.name,
}
: null,
uploadedBy: item.uploaded_by_employee
? {
id: item.uploaded_by_employee.id,
employeeNo: item.uploaded_by_employee.employee_no,
name: item.uploaded_by_employee.name,
}
: null,
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
};
}
function normalizeInvoiceResponse(invoice) {
return {
id: invoice.id,
status: invoice.status,
archivedReason: invoice.archived_reason ?? null,
archivedAt: invoice.archived_at?.toISOString?.() ?? null,
ownerEmployeeId: invoice.owner_employee_id,
uploadedByEmployeeId: invoice.uploaded_by_employee_id,
originalName: invoice.original_name ?? null,
mimeType: invoice.mime_type ?? null,
fileSize: invoice.file_size ?? null,
fileSha256: invoice.file_sha256 ?? null,
remarks: invoice.remarks ?? null,
ocrType: invoice.ocr_type,
ocrProvider: invoice.ocr_provider,
ocrAttempts: invoice.ocr_attempts,
ocrLastRequestedAt: invoice.ocr_last_requested_at?.toISOString?.() ?? null,
ocrLastError: invoice.ocr_last_error,
ocrCompletedAt: invoice.ocr_completed_at?.toISOString?.() ?? null,
invoiceCode: invoice.invoice_code,
invoiceNumber: invoice.invoice_number,
invoiceDate: invoice.invoice_date ? invoice.invoice_date.toISOString().slice(0, 10) : null,
sellerName: invoice.seller_name ?? null,
buyerName: invoice.buyer_name ?? null,
amount: toMoneyString(invoice.amount),
taxAmount: toMoneyString(invoice.tax_amount),
totalAmount: toMoneyString(invoice.total_amount),
linkedReimbursementId: invoice.linked_reimbursement_id,
linkedItemId: invoice.linked_item_id,
isDuplicateOverride: invoice.is_duplicate_override ?? false,
overrideRefFormNumber: invoice.override_ref_form_number ?? null,
overrideReason: invoice.override_reason ?? null,
overriddenBy: invoice.overridden_by ?? null,
createdAt: invoice.created_at?.toISOString?.() ?? null,
updatedAt: invoice.updated_at?.toISOString?.() ?? null,
};
}
async function invoicePoolRoutes(fastify) {
fastify.get(
"/invoice-pool",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const query = parseInvoiceListQuery(request.query);
const canViewOwn = fastify.hasPermission(request.employee, "invoice.view_own");
const canManage = fastify.hasPermission(request.employee, "invoice.manage");
if (!canViewOwn && !canManage) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const ownerScope = query.ownerScope ?? (canManage ? "all" : "own");
if (ownerScope === "all" && !canManage) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const skip = (query.page - 1) * query.pageSize;
const where = {
...(query.status ? { status: query.status } : {}),
...(ownerScope === "all"
? {}
: {
OR: [
{ owner_employee_id: request.employee.id },
{ uploaded_by_employee_id: request.employee.id },
{
linked_reimbursement: {
OR: [
{ owner_id: request.employee.id },
{ submitter_id: request.employee.id },
],
},
},
],
}),
};
const [items, total] = await Promise.all([
fastify.prisma.invoicePool.findMany({
where,
orderBy: { created_at: "desc" },
skip,
take: query.pageSize,
select: {
id: true,
status: true,
ocr_type: true,
ocr_provider: true,
ocr_attempts: true,
ocr_last_error: true,
ocr_completed_at: true,
invoice_code: true,
invoice_number: true,
invoice_date: true,
amount: true,
tax_amount: true,
total_amount: true,
linked_reimbursement_id: true,
linked_item_id: true,
original_name: true,
mime_type: true,
file_size: true,
created_at: true,
updated_at: true,
owner_employee: {
select: { id: true, employee_no: true, name: true },
},
uploaded_by_employee: {
select: { id: true, employee_no: true, name: true },
},
},
}),
fastify.prisma.invoicePool.count({ where }),
]);
return sendData(reply, request, {
page: query.page,
pageSize: query.pageSize,
total,
ownerScope,
items: items.map(mapInvoiceSummary),
});
},
);
fastify.post(
"/invoice-pool/upload",
{
preHandler: fastify.requirePermission("invoice.upload"),
},
async (request, reply) => {
let filePart = null;
const fields = {};
try {
for await (const part of request.parts()) {
if (part.type === "file") {
if (filePart) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "only_single_file_supported" },
});
}
filePart = part;
} else {
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 (!filePart) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "missing" },
});
}
if (!MIME_ALLOWLIST.has(filePart.mimetype)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "unsupported_mime_type" },
});
}
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 chunks = [];
let fileSize = 0;
for await (const chunk of filePart.file) {
fileSize += chunk.length;
if (fileSize > fastify.config.maxUploadSizeBytes) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "file_too_large" },
});
}
chunks.push(chunk);
}
if (fileSize === 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "empty_file" },
});
}
const fileBuffer = Buffer.concat(chunks);
const fileSha256 = sha256Hex(fileBuffer);
const datePrefix = compactDateString(fastify.config.appTimezone);
const extension = path.extname(filePart.filename ?? "").toLowerCase();
const storagePath = path.join(datePrefix, `${crypto.randomUUID()}${extension}`);
const absoluteStoragePath = path.resolve(fastify.config.storageDir, storagePath);
await fs.mkdir(path.dirname(absoluteStoragePath), { recursive: true });
await fs.writeFile(absoluteStoragePath, fileBuffer);
const status = isOcrEnabledForType(fastify.config, ocrType) ? "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: filePart.filename ?? "upload",
mime_type: filePart.mimetype,
file_size: fileSize,
file_sha256: fileSha256,
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;
});
return sendData(reply, request, {
invoicePoolId: invoice.id,
status: invoice.status,
fileSha256: invoice.file_sha256,
});
},
);
fastify.get(
"/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 invoice = await fastify.prisma.invoicePool.findUnique({
where: { id: parsedInvoiceId.data },
select: {
id: true,
status: true,
archived_reason: true,
archived_at: true,
owner_employee_id: true,
uploaded_by_employee_id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
remarks: true,
ocr_type: true,
ocr_provider: true,
ocr_attempts: true,
ocr_last_requested_at: true,
ocr_last_error: true,
ocr_completed_at: true,
invoice_code: true,
invoice_number: true,
invoice_date: true,
seller_name: true,
buyer_name: true,
amount: true,
tax_amount: true,
total_amount: true,
linked_reimbursement_id: true,
linked_item_id: true,
is_duplicate_override: true,
override_ref_form_number: true,
override_reason: true,
overridden_by: true,
created_at: true,
updated_at: true,
linked_reimbursement: {
select: {
owner_id: true,
submitter_id: true,
},
},
},
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
let canAccess = ensureInvoiceReadableByUser(request, invoice);
if (!canAccess && invoice.linked_reimbursement_id) {
const pendingTask = await fastify.prisma.workflowTask.findFirst({
where: {
status: "pending",
workflow_instance: {
entity_type: "reimbursement",
entity_id: invoice.linked_reimbursement_id,
current_status: "pending",
},
OR: [
{ assignee_id: request.employee.id },
{ assignee_role: { in: request.employee.roleCodes } },
],
},
select: { id: true },
});
canAccess = Boolean(pendingTask);
}
if (!canAccess) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
return sendData(reply, request, normalizeInvoiceResponse(invoice));
},
);
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: {
id: true,
status: true,
archived_reason: true,
archived_at: true,
owner_employee_id: true,
uploaded_by_employee_id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
remarks: true,
ocr_type: true,
ocr_provider: true,
ocr_attempts: true,
ocr_last_requested_at: true,
ocr_last_error: true,
ocr_completed_at: true,
invoice_code: true,
invoice_number: true,
invoice_date: true,
seller_name: true,
buyer_name: true,
amount: true,
tax_amount: true,
total_amount: true,
linked_reimbursement_id: true,
linked_item_id: true,
is_duplicate_override: true,
override_ref_form_number: true,
override_reason: true,
overridden_by: true,
created_at: true,
updated_at: true,
},
});
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") {
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: {
id: true,
status: true,
archived_reason: true,
archived_at: true,
owner_employee_id: true,
uploaded_by_employee_id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
remarks: true,
ocr_type: true,
ocr_provider: true,
ocr_attempts: true,
ocr_last_requested_at: true,
ocr_last_error: true,
ocr_completed_at: true,
invoice_code: true,
invoice_number: true,
invoice_date: true,
seller_name: true,
buyer_name: true,
amount: true,
tax_amount: true,
total_amount: true,
linked_reimbursement_id: true,
linked_item_id: true,
is_duplicate_override: true,
override_ref_form_number: true,
override_reason: true,
overridden_by: true,
created_at: true,
updated_at: true,
},
});
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,
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) {
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: {
id: true,
status: true,
archived_reason: true,
archived_at: true,
owner_employee_id: true,
uploaded_by_employee_id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
remarks: true,
ocr_type: true,
ocr_provider: true,
ocr_attempts: true,
ocr_last_requested_at: true,
ocr_last_error: true,
ocr_completed_at: true,
invoice_code: true,
invoice_number: true,
invoice_date: true,
seller_name: true,
buyer_name: true,
amount: true,
tax_amount: true,
total_amount: true,
linked_reimbursement_id: true,
linked_item_id: true,
is_duplicate_override: true,
override_ref_form_number: true,
override_reason: true,
overridden_by: true,
created_at: true,
updated_at: true,
},
});
}
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: {
id: true,
status: true,
archived_reason: true,
archived_at: true,
owner_employee_id: true,
uploaded_by_employee_id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
remarks: true,
ocr_type: true,
ocr_provider: true,
ocr_attempts: true,
ocr_last_requested_at: true,
ocr_last_error: true,
ocr_completed_at: true,
invoice_code: true,
invoice_number: true,
invoice_date: true,
seller_name: true,
buyer_name: true,
amount: true,
tax_amount: true,
total_amount: true,
linked_reimbursement_id: true,
linked_item_id: true,
is_duplicate_override: true,
override_ref_form_number: true,
override_reason: true,
overridden_by: true,
created_at: true,
updated_at: true,
},
});
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.requirePermission("invoice.upload"),
},
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;
let responseInvoice = null;
await fastify.prisma.$transaction(async (tx) => {
const invoiceRows = await tx.$queryRaw`
SELECT id, status, linked_reimbursement_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.status !== "bound" || !invoice.linked_reimbursement_id) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { invoice: "not_bound" },
});
}
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;
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
if (!isOwn && !canViewAll) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { reimbursement: "invalid_status_for_unbind" },
});
}
responseInvoice = await tx.invoicePool.update({
where: { id: invoiceId },
data: {
status: "available",
linked_reimbursement_id: null,
linked_item_id: null,
updated_by: request.employee.id,
},
select: {
id: true,
status: true,
archived_reason: true,
archived_at: true,
owner_employee_id: true,
uploaded_by_employee_id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
remarks: true,
ocr_type: true,
ocr_provider: true,
ocr_attempts: true,
ocr_last_requested_at: true,
ocr_last_error: true,
ocr_completed_at: true,
invoice_code: true,
invoice_number: true,
invoice_date: true,
seller_name: true,
buyer_name: true,
amount: true,
tax_amount: true,
total_amount: true,
linked_reimbursement_id: true,
linked_item_id: true,
is_duplicate_override: true,
override_ref_form_number: true,
override_reason: true,
overridden_by: true,
created_at: true,
updated_at: true,
},
});
});
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_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: {
id: true,
status: true,
archived_reason: true,
archived_at: true,
owner_employee_id: true,
uploaded_by_employee_id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
remarks: true,
ocr_type: true,
ocr_provider: true,
ocr_attempts: true,
ocr_last_requested_at: true,
ocr_last_error: true,
ocr_completed_at: true,
invoice_code: true,
invoice_number: true,
invoice_date: true,
seller_name: true,
buyer_name: true,
amount: true,
tax_amount: true,
total_amount: true,
linked_reimbursement_id: true,
linked_item_id: true,
is_duplicate_override: true,
override_ref_form_number: true,
override_reason: true,
overridden_by: true,
created_at: true,
updated_at: true,
},
});
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;
});
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,
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) {
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: {
id: true,
status: true,
archived_reason: true,
archived_at: true,
owner_employee_id: true,
uploaded_by_employee_id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
remarks: true,
ocr_type: true,
ocr_provider: true,
ocr_attempts: true,
ocr_last_requested_at: true,
ocr_last_error: true,
ocr_completed_at: true,
invoice_code: true,
invoice_number: true,
invoice_date: true,
seller_name: true,
buyer_name: true,
amount: true,
tax_amount: true,
total_amount: true,
linked_reimbursement_id: true,
linked_item_id: true,
is_duplicate_override: true,
override_ref_form_number: true,
override_reason: true,
overridden_by: true,
created_at: true,
updated_at: true,
},
});
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/signed-url",
{
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 invoice = await fastify.prisma.invoicePool.findUnique({
where: { id: parsedInvoiceId.data },
select: {
id: true,
owner_employee_id: true,
uploaded_by_employee_id: true,
linked_reimbursement_id: true,
linked_reimbursement: {
select: {
owner_id: true,
submitter_id: true,
},
},
},
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
let canAccess = ensureInvoiceReadableByUser(request, invoice);
if (!canAccess && invoice.linked_reimbursement_id) {
const pendingTask = await fastify.prisma.workflowTask.findFirst({
where: {
status: "pending",
workflow_instance: {
entity_type: "reimbursement",
entity_id: invoice.linked_reimbursement_id,
current_status: "pending",
},
OR: [
{ assignee_id: request.employee.id },
{ assignee_role: { in: request.employee.roleCodes } },
],
},
select: { id: true },
});
canAccess = Boolean(pendingTask);
}
if (!canAccess) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const expiresAt = Date.now() + fastify.config.signedUrlTtlSeconds * 1000;
const token = signPayload(
{
invoiceId: invoice.id,
actorId: request.employee.id,
exp: expiresAt,
},
fastify.config.jwtSecret,
);
const origin =
fastify.config.appOrigin ||
`${request.protocol}://${request.headers.host ?? "localhost:3000"}`;
const signedUrl = `${origin}/api/invoice-pool/${invoice.id}/file?token=${encodeURIComponent(token)}`;
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "INVOICE_SIGNED_URL_ISSUED",
targetType: "invoice_pool",
targetId: invoice.id,
extra: {
invoiceId: invoice.id,
expiresAt,
},
}),
});
return sendData(reply, request, {
signedUrl,
expiresAt: new Date(expiresAt).toISOString(),
});
},
);
fastify.get("/invoice-pool/:id/file", async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
const token = request.query?.token;
if (!parsedInvoiceId.success || typeof token !== "string") {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const verified = verifyToken(token, fastify.config.jwtSecret);
if (!verified.ok) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const payload = verified.payload;
const parsedActorId = z.string().uuid().safeParse(payload?.actorId);
if (
payload?.invoiceId !== parsedInvoiceId.data ||
typeof payload?.exp !== "number" ||
payload.exp <= Date.now() ||
!parsedActorId.success
) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const actorId = parsedActorId.data;
const invoice = await fastify.prisma.invoicePool.findUnique({
where: { id: parsedInvoiceId.data },
select: {
storage_path: true,
mime_type: true,
original_name: true,
},
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const storageRoot = path.resolve(fastify.config.storageDir);
const absolutePath = path.resolve(storageRoot, invoice.storage_path);
if (!absolutePath.startsWith(`${storageRoot}${path.sep}`)) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
try {
await fs.access(absolutePath);
} catch {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
try {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId,
action: "INVOICE_FILE_DOWNLOADED",
targetType: "invoice_pool",
targetId: parsedInvoiceId.data,
extra: {
invoiceId: parsedInvoiceId.data,
exp: payload.exp,
},
}),
});
} catch (error) {
fastify.log.error({ err: error, invoiceId: parsedInvoiceId.data }, "Audit log write failed");
}
reply.type(invoice.mime_type || "application/octet-stream");
reply.header(
"content-disposition",
`inline; filename=\"${sanitizeFilename(invoice.original_name)}\"`,
);
return reply.send(fsSync.createReadStream(absolutePath));
});
fastify.post(
"/invoice-pool/:id/bind",
{
preHandler: fastify.requirePermission("invoice.upload"),
},
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 = bindBodySchema.safeParse(request.body ?? {});
if (!parsedBody.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsedBody.error.flatten(),
});
}
const invoiceId = parsedInvoiceId.data;
const { reimbursementId, linkedItemId } = parsedBody.data;
try {
await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, status
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;
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
if (!isOwn && !canViewAll) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
if (![
"draft",
"rejected",
].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { reimbursement: "invalid_status_for_bind" },
});
}
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" },
});
}
}
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,
linked_reimbursement_id: true,
},
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
if (invoice.status === "bound" || invoice.linked_reimbursement_id) {
throw new AppError({
code: "VALID_005",
message: "发票已被其他报销单占用/绑定",
statusCode: 409,
});
}
if (invoice.status !== "available") {
throw new AppError({
code: "VALID_005",
message: "发票已被其他报销单占用/绑定",
statusCode: 409,
details: { status: invoice.status },
});
}
await tx.invoicePool.update({
where: { id: invoiceId },
data: {
status: "bound",
linked_reimbursement_id: reimbursementId,
linked_item_id: linkedItemId ?? null,
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,
total_amount: true,
},
});
return sendData(reply, request, {
id: invoice.id,
status: invoice.status,
linkedReimbursementId: invoice.linked_reimbursement_id,
linkedItemId: invoice.linked_item_id,
totalAmount: toMoneyString(invoice.total_amount),
});
},
);
}
module.exports = invoicePoolRoutes;