feat: 完成P10生产硬化与矩阵收口(P10E1-P10G1)

这个提交包含在:
2026-02-10 11:25:18 +08:00
父节点 1b6e801b12
当前提交 bec21e1b38
共修改 15 个文件,包含 1718 行新增和 793 行删除
+14 -790
查看文件
@@ -7,11 +7,14 @@ 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 { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
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 MIME_ALLOWLIST = new Set([
"application/pdf",
@@ -52,13 +55,6 @@ const overrideDuplicateBodySchema = z
})
.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",
@@ -148,36 +144,6 @@ function isInvoiceDedupConflict(error) {
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;
@@ -218,205 +184,9 @@ function parseDateOnlyString(value) {
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),
});
},
);
registerInvoicePoolReadRoutes(fastify);
fastify.post(
"/invoice-pool/upload",
@@ -626,107 +396,7 @@ async function invoicePoolRoutes(fastify) {
},
);
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));
},
);
registerInvoiceVerificationRoutes(fastify);
fastify.patch(
"/invoice-pool/:id",
@@ -783,41 +453,7 @@ async function invoicePoolRoutes(fastify) {
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,
},
select: buildInvoiceDetailSelect(),
});
if (!current) {
@@ -955,41 +591,7 @@ async function invoicePoolRoutes(fastify) {
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,
},
select: buildInvoiceDetailSelect(),
});
const changes = {};
@@ -1128,41 +730,7 @@ async function invoicePoolRoutes(fastify) {
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,
},
select: buildInvoiceDetailSelect(),
});
}
@@ -1184,41 +752,7 @@ async function invoicePoolRoutes(fastify) {
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,
},
select: buildInvoiceDetailSelect(),
});
await tx.auditLog.create({
@@ -1330,41 +864,7 @@ async function invoicePoolRoutes(fastify) {
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,
},
select: buildInvoiceDetailSelect(),
});
});
@@ -1491,41 +991,7 @@ async function invoicePoolRoutes(fastify) {
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,
},
select: buildInvoiceDetailSelect(),
});
await tx.auditLog.create({
@@ -1702,41 +1168,7 @@ async function invoicePoolRoutes(fastify) {
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,
},
select: buildInvoiceDetailSelect(),
});
await tx.auditLog.create({
@@ -1760,214 +1192,6 @@ async function invoicePoolRoutes(fastify) {
},
);
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,
});
}
let stream = null;
try {
const opened = await openReadStream({
config: fastify.config,
storagePath: invoice.storage_path,
});
stream = opened.stream;
} catch (error) {
if (error instanceof StorageNotFoundError) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
throw new AppError({
code: "SYS_001",
message: "服务器内部错误",
statusCode: 500,
details: {},
});
}
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(stream);
});
fastify.post(
"/invoice-pool/:id/bind",
{
+413
查看文件
@@ -0,0 +1,413 @@
const { z } = require("zod");
const { AppError } = require("../../utils/AppError");
const { sendData } = require("../../utils/reply");
const { buildAuditLogData } = require("../../utils/audit");
const { signPayload, verifyToken } = require("../../utils/signedUrl");
const { openReadStream, StorageNotFoundError } = require("../../services/storage");
const {
mapInvoiceSummary,
normalizeInvoiceResponse,
} = require("./shared/mappers");
const {
buildInvoiceDetailSelect,
buildInvoiceSummarySelect,
} = require("./shared/selects");
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(),
});
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 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;
}
async function hasPendingTaskAccess(fastify, request, linkedReimbursementId) {
if (!linkedReimbursementId) {
return false;
}
const pendingTask = await fastify.prisma.workflowTask.findFirst({
where: {
status: "pending",
workflow_instance: {
entity_type: "reimbursement",
entity_id: linkedReimbursementId,
current_status: "pending",
},
OR: [
{ assignee_id: request.employee.id },
{ assignee_role: { in: request.employee.roleCodes } },
],
},
select: { id: true },
});
return Boolean(pendingTask);
}
function registerInvoicePoolReadRoutes(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: buildInvoiceSummarySelect(),
}),
fastify.prisma.invoicePool.count({ where }),
]);
return sendData(reply, request, {
page: query.page,
pageSize: query.pageSize,
total,
ownerScope,
items: items.map(mapInvoiceSummary),
});
},
);
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: buildInvoiceDetailSelect({ includeLinkedReimbursement: true }),
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
let canAccess = ensureInvoiceReadableByUser(request, invoice);
if (!canAccess) {
canAccess = await hasPendingTaskAccess(fastify, request, invoice.linked_reimbursement_id);
}
if (!canAccess) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
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) {
canAccess = await hasPendingTaskAccess(fastify, request, invoice.linked_reimbursement_id);
}
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,
});
}
let stream = null;
try {
const opened = await openReadStream({
config: fastify.config,
storagePath: invoice.storage_path,
});
stream = opened.stream;
} catch (error) {
if (error instanceof StorageNotFoundError) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
throw new AppError({
code: "SYS_001",
message: "服务器内部错误",
statusCode: 500,
details: {},
});
}
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(stream);
});
}
module.exports = {
registerInvoicePoolReadRoutes,
};