646 行
18 KiB
JavaScript
646 行
18 KiB
JavaScript
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 { toMoneyString } = require("../../utils/workflow");
|
|
const { resolveInvoiceAvailability } = require("../../services/invoiceGovernance");
|
|
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(),
|
|
});
|
|
const bindableInvoiceQuerySchema = z.object({
|
|
reimbursementId: z.string().uuid(),
|
|
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";
|
|
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 parseBindableInvoiceQuery(query) {
|
|
const parsed = bindableInvoiceQuerySchema.safeParse(query ?? {});
|
|
if (!parsed.success) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: parsed.error.flatten(),
|
|
});
|
|
}
|
|
|
|
return {
|
|
reimbursementId: parsed.data.reimbursementId,
|
|
search: parsed.data.search?.trim() || null,
|
|
limit: parsed.data.limit ?? 20,
|
|
};
|
|
}
|
|
|
|
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) {
|
|
return resolveInvoiceAvailability(invoice);
|
|
}
|
|
|
|
function ensureInvoiceReadableByUser(request, invoice) {
|
|
const roleCodes = request.employee.roleCodes;
|
|
const permissionCodes = request.employee.permissionCodes ?? [];
|
|
if (
|
|
roleCodes.includes("admin") ||
|
|
roleCodes.includes("finance") ||
|
|
roleCodes.includes("finance_mgr") ||
|
|
permissionCodes.includes("travel.payment.manage")
|
|
) {
|
|
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/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,
|
|
availabilityReason: statusReason.reason,
|
|
availabilityMessage: 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",
|
|
{
|
|
schema: { tags: ["Invoice Pool"], summary: "List Bindable Invoices For Reimbursement" },
|
|
preHandler: fastify.requirePermission("invoice.view_own"),
|
|
},
|
|
async (request, reply) => {
|
|
const query = parseBindableInvoiceQuery(request.query);
|
|
|
|
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
|
|
where: { id: query.reimbursementId },
|
|
select: {
|
|
id: true,
|
|
owner_id: true,
|
|
submitter_id: true,
|
|
status: true,
|
|
},
|
|
});
|
|
|
|
if (!reimbursement) {
|
|
throw new AppError({
|
|
code: "SYS_404",
|
|
message: "Not Found",
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
const isOwn =
|
|
reimbursement.owner_id === request.employee.id ||
|
|
reimbursement.submitter_id === request.employee.id;
|
|
if (!isOwn) {
|
|
throw new AppError({
|
|
code: "PERM_001",
|
|
message: "无权限",
|
|
statusCode: 403,
|
|
});
|
|
}
|
|
|
|
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: { reimbursement: "invalid_status_for_bind" },
|
|
});
|
|
}
|
|
|
|
const where = {
|
|
owner_employee_id: reimbursement.owner_id,
|
|
status: "available",
|
|
linked_reimbursement_id: null,
|
|
...(query.search
|
|
? {
|
|
OR: [
|
|
{ invoice_code: { contains: query.search, mode: "insensitive" } },
|
|
{ invoice_number: { contains: query.search, mode: "insensitive" } },
|
|
{ seller_name: { contains: query.search, mode: "insensitive" } },
|
|
],
|
|
}
|
|
: {}),
|
|
};
|
|
|
|
const items = await fastify.prisma.invoicePool.findMany({
|
|
where,
|
|
orderBy: { created_at: "desc" },
|
|
take: query.limit,
|
|
select: {
|
|
id: true,
|
|
invoice_code: true,
|
|
invoice_number: true,
|
|
invoice_date: true,
|
|
seller_name: true,
|
|
total_amount: true,
|
|
original_name: true,
|
|
created_at: true,
|
|
},
|
|
});
|
|
|
|
return sendData(reply, request, {
|
|
items: items.map((item) => ({
|
|
id: item.id,
|
|
invoiceCode: item.invoice_code ?? null,
|
|
invoiceNumber: item.invoice_number ?? null,
|
|
invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null,
|
|
sellerName: item.seller_name ?? null,
|
|
totalAmount: toMoneyString(item.total_amount),
|
|
originalName: item.original_name ?? null,
|
|
createdAt: item.created_at?.toISOString?.() ?? null,
|
|
})),
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.get(
|
|
"/invoice-pool",
|
|
{
|
|
schema: { tags: ["Invoice Pool"], summary: "List Invoices" },
|
|
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") ||
|
|
fastify.hasPermission(request.employee, "travel.payment.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",
|
|
{
|
|
schema: { tags: ["Invoice Pool"], summary: "Get Invoice Detail" },
|
|
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,
|
|
};
|