const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { compactDateString, currentDateString, parseDateOnlyString } = require("../utils/date"); const { buildWorkflowStepsFromApprovalRule, resolveActionableStep, toMoneyString, } = require("../utils/workflow"); const UUID_SCHEMA = z.string().uuid(); const PAY_BODY_SCHEMA = z.object({ paymentVoucherNo: z.string().trim().min(1).max(50).optional(), paymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), paymentChannel: z.enum(["bank", "alipay", "wechat", "cash", "other"]).optional(), }); const REIMBURSEMENT_LIST_QUERY_SCHEMA = z.object({ page: z.coerce.number().int().min(1).optional(), pageSize: z.coerce.number().int().min(1).max(100).optional(), status: z .enum(["draft", "reviewing", "approved", "paid", "rejected", "cancelled"]) .optional(), scope: z.enum(["own", "all"]).optional(), }); function ensureOwnOrCanViewAll(fastify, request, reimbursement) { const canViewAll = fastify.hasPermission(request.employee, "expense.view_all"); if (canViewAll) { return; } if (!fastify.hasPermission(request.employee, "expense.view_own")) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } 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, }); } } function formatFormNumber(sequence, timezone) { const datePart = compactDateString(timezone); const serial = String(sequence.current_number).padStart(sequence.number_length, "0"); return `${sequence.prefix}${sequence.separator}${datePart}${sequence.separator}${serial}`; } function parseWithdrawBody(body) { const bodySchema = z.object({ comment: z.string().trim().min(1).max(500).optional(), }); const parsed = bodySchema.safeParse(body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } return parsed.data; } function parseReimbursementId(id) { const parsed = UUID_SCHEMA.safeParse(id); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { id: "invalid_uuid" }, }); } return parsed.data; } function parsePayBody(body) { const parsed = PAY_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } return parsed.data; } function parseReimbursementListQuery(query) { const parsed = REIMBURSEMENT_LIST_QUERY_SCHEMA.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, scope: parsed.data.scope, }; } function buildOwnScopeWhere(employeeId) { return { OR: [{ owner_id: employeeId }, { submitter_id: employeeId }], }; } function isGlobalViewRole(roleCodes) { return roleCodes.some((code) => ["admin", "ceo", "finance", "finance_mgr", "hr"].includes(code), ); } function buildViewAllScopeWhere(request) { if (isGlobalViewRole(request.employee.roleCodes ?? [])) { return {}; } return { OR: [ { owner_id: request.employee.id }, { submitter_id: request.employee.id }, { owner: { direct_manager_id: request.employee.id } }, ], }; } function mapReimbursementSummary(item) { return { id: item.id, formNumber: item.form_number, status: item.status, reimbursementDate: item.reimbursement_date?.toISOString?.()?.slice(0, 10) ?? null, totalAmount: toMoneyString(item.total_amount), description: item.description, payment: { paymentVoucherNo: item.payment_voucher_no, paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? null, paymentChannel: item.payment_channel, paidAt: item.paid_at?.toISOString?.() ?? null, paidBy: item.paid_by, }, owner: item.owner ? { id: item.owner.id, employeeNo: item.owner.employee_no, name: item.owner.name, } : null, submitter: item.submitter ? { id: item.submitter.id, employeeNo: item.submitter.employee_no, name: item.submitter.name, } : null, createdAt: item.created_at?.toISOString?.() ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null, }; } function toDateOnlyString(dateValue) { if (!dateValue) { return null; } if (typeof dateValue === "string") { return dateValue.slice(0, 10); } return new Date(dateValue).toISOString().slice(0, 10); } function ensureNonNegativeAmount(totalAmount) { const asNumber = Number(totalAmount); if (!Number.isFinite(asNumber)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { total_amount: "invalid" }, }); } if (asNumber < 0) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { total_amount: "negative_not_allowed" }, }); } } async function loadApprovalRuleForAmount(tx, totalAmount) { const amountString = toMoneyString(totalAmount); const approvalRule = await tx.approvalRule.findFirst({ where: { enabled: true, min_amount: { lte: amountString }, OR: [{ max_amount: null }, { max_amount: { gt: amountString } }], }, orderBy: { priority: "asc" }, select: { id: true, level1_role: true, level2_role: true, level3_role: true, priority: true, }, }); if (!approvalRule) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { workflow: "missing_approval_rule" }, }); } return approvalRule; } async function reimbursementRoutes(fastify) { const createBodySchema = z.object({ reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), description: z.string().max(500).optional(), }); fastify.post( "/reimbursements", { preHandler: fastify.requirePermission("expense.create"), }, async (request, reply) => { const parsed = createBodySchema.safeParse(request.body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } const reimbursementDate = parsed.data.reimbursementDate ?? currentDateString(fastify.config.appTimezone); const reimbursement = await fastify.prisma.reimbursementForm.create({ data: { submitter_id: request.employee.id, owner_id: request.employee.id, department_id: request.employee.department_id, reimbursement_date: parseDateOnlyString(reimbursementDate), description: parsed.data.description ?? null, created_by: request.employee.id, updated_by: request.employee.id, }, select: { id: true, status: true, reimbursement_date: true, description: true, total_amount: true, form_number: true, }, }); return sendData(reply, request, { id: reimbursement.id, formNumber: reimbursement.form_number, reimbursementDate, status: reimbursement.status, description: reimbursement.description, totalAmount: toMoneyString(reimbursement.total_amount), }); }, ); fastify.get( "/reimbursements", { preHandler: fastify.authenticate, }, async (request, reply) => { const query = parseReimbursementListQuery(request.query); const canViewOwn = fastify.hasPermission(request.employee, "expense.view_own"); const canViewAll = fastify.hasPermission(request.employee, "expense.view_all"); if (!canViewOwn && !canViewAll) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } const scope = query.scope ?? (canViewAll ? "all" : "own"); if (scope === "all" && !canViewAll) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } const skip = (query.page - 1) * query.pageSize; const where = { ...(query.status ? { status: query.status } : {}), ...(scope === "all" ? buildViewAllScopeWhere(request) : buildOwnScopeWhere(request.employee.id)), }; const [items, total] = await Promise.all([ fastify.prisma.reimbursementForm.findMany({ where, orderBy: { created_at: "desc" }, skip, take: query.pageSize, select: { id: true, form_number: true, status: true, reimbursement_date: true, total_amount: true, description: true, payment_voucher_no: true, payment_date: true, payment_channel: true, paid_at: true, paid_by: true, created_at: true, updated_at: true, owner: { select: { id: true, employee_no: true, name: true }, }, submitter: { select: { id: true, employee_no: true, name: true }, }, }, }), fastify.prisma.reimbursementForm.count({ where }), ]); return sendData(reply, request, { page: query.page, pageSize: query.pageSize, total, scope, items: items.map(mapReimbursementSummary), }); }, ); fastify.get( "/reimbursements/:id", { preHandler: fastify.authenticate, }, async (request, reply) => { const reimbursementId = parseReimbursementId(request.params?.id); const reimbursement = await fastify.prisma.reimbursementForm.findUnique({ where: { id: reimbursementId }, select: { id: true, form_number: true, submitter_id: true, owner_id: true, department_id: true, reimbursement_date: true, total_amount: true, status: true, description: true, owner: { select: { id: true, employee_no: true, name: true }, }, submitter: { select: { id: true, employee_no: true, name: true }, }, invoice_pool_items: { select: { id: true, status: true, invoice_code: true, invoice_number: true, total_amount: true, }, }, workflow_instances: { where: { current_status: "pending" }, orderBy: { created_at: "desc" }, take: 1, select: { id: true, current_step_id: true, workflow_tasks: { where: { status: "pending" }, take: 1, select: { id: true, step_name: true, assignee_id: true, assignee_role: true, }, }, }, }, }, }); if (!reimbursement) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } ensureOwnOrCanViewAll(fastify, request, reimbursement); const pendingInstance = reimbursement.workflow_instances[0] ?? null; const pendingTask = pendingInstance?.workflow_tasks?.[0] ?? null; return sendData(reply, request, { id: reimbursement.id, formNumber: reimbursement.form_number, submitterId: reimbursement.submitter_id, ownerId: reimbursement.owner_id, departmentId: reimbursement.department_id, reimbursementDate: reimbursement.reimbursement_date.toISOString().slice(0, 10), totalAmount: toMoneyString(reimbursement.total_amount), status: reimbursement.status, description: reimbursement.description, owner: reimbursement.owner, submitter: reimbursement.submitter, invoices: reimbursement.invoice_pool_items.map((item) => ({ id: item.id, status: item.status, invoiceCode: item.invoice_code, invoiceNumber: item.invoice_number, totalAmount: toMoneyString(item.total_amount), })), pendingWorkflow: pendingInstance ? { instanceId: pendingInstance.id, stepId: pendingInstance.current_step_id, task: pendingTask, } : null, }); }, ); fastify.post( "/reimbursements/:id/submit", { preHandler: fastify.requirePermission("expense.submit"), }, async (request, reply) => { const reimbursementId = parseReimbursementId(request.params?.id); let submitResult = null; await fastify.prisma.$transaction(async (tx) => { const reimbursementRows = await tx.$queryRaw` SELECT id, owner_id, submitter_id, department_id, status, form_number, total_amount 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]; ensureOwnOrCanViewAll(fastify, request, reimbursement); if (!["draft", "rejected"].includes(reimbursement.status)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_submit_status" }, }); } ensureNonNegativeAmount(reimbursement.total_amount); const activeInstance = await tx.workflowInstance.findFirst({ where: { entity_type: "reimbursement", entity_id: reimbursementId, current_status: "pending", }, select: { id: true }, }); if (activeInstance) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { submit: "already_in_review" }, }); } const boundInvoiceCount = await tx.invoicePool.count({ where: { linked_reimbursement_id: reimbursementId, status: "bound", }, }); if (boundInvoiceCount < 1) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { invoices: "bound_invoice_required" }, }); } const workflowDefinition = await tx.workflowDefinition.findFirst({ where: { entity_type: "reimbursement", is_active: true, }, orderBy: { version: "desc" }, select: { id: true, }, }); if (!workflowDefinition) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { workflow: "missing_definition" }, }); } const approvalRule = await loadApprovalRuleForAmount(tx, reimbursement.total_amount); const workflowSteps = buildWorkflowStepsFromApprovalRule(approvalRule); const { actionable, skipped } = await resolveActionableStep({ tx, steps: workflowSteps, startIndex: 0, ownerId: reimbursement.owner_id, logger: fastify.log, }); if (!actionable) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { workflow: "no_actionable_approver", }, }); } let formNumber = reimbursement.form_number; if (!formNumber) { const sequence = await tx.documentSequence.upsert({ where: { document_type: "reimbursement" }, update: { current_number: { increment: 1 }, updated_by: request.employee.id, }, create: { document_type: "reimbursement", prefix: "BX", date_format: "YYYYMMDD", separator: "-", number_length: 4, reset_period: "none", current_number: 1, updated_by: request.employee.id, }, select: { prefix: true, separator: true, number_length: true, current_number: true, }, }); formNumber = formatFormNumber(sequence, fastify.config.appTimezone); } await tx.reimbursementForm.update({ where: { id: reimbursementId }, data: { form_number: formNumber, status: "reviewing", updated_by: request.employee.id, }, }); const workflowInstance = await tx.workflowInstance.create({ data: { workflow_id: workflowDefinition.id, entity_type: "reimbursement", entity_id: reimbursementId, submitter_id: request.employee.id, current_status: "pending", current_step_id: actionable.step.id, context_data: { owner_id: reimbursement.owner_id, department_id: reimbursement.department_id, workflow_steps_snapshot: workflowSteps, approval_rule_id: approvalRule.id, }, context_department_id: reimbursement.department_id, created_by: request.employee.id, updated_by: request.employee.id, }, select: { id: true, current_step_id: true, }, }); for (const skippedStep of skipped) { await tx.workflowHistory.create({ data: { instance_id: workflowInstance.id, actor_id: request.employee.id, action: "skip", payload: { step_id: skippedStep.step.id, reason: skippedStep.reason, owner_id: reimbursement.owner_id, depth_checked: skippedStep.depthChecked, }, }, }); } const workflowTask = await tx.workflowTask.create({ data: { instance_id: workflowInstance.id, step_id: actionable.step.id, step_name: actionable.step.name ?? actionable.step.id, assignee_id: actionable.assigneeId, assignee_role: actionable.assigneeRole, }, select: { id: true, assignee_id: true, assignee_role: true, }, }); await tx.workflowHistory.create({ data: { instance_id: workflowInstance.id, task_id: workflowTask.id, actor_id: request.employee.id, action: "start", payload: { step_id: actionable.step.id, approval_rule_id: approvalRule.id, skipped_step_count: skipped.length, }, }, }); submitResult = { reimbursementId, formNumber, workflowInstanceId: workflowInstance.id, currentTaskId: workflowTask.id, status: "reviewing", }; }); return sendData(reply, request, submitResult); }, ); fastify.post( "/reimbursements/:id/withdraw", { preHandler: fastify.requirePermission("expense.withdraw"), }, async (request, reply) => { const reimbursementId = parseReimbursementId(request.params?.id); const { comment } = parseWithdrawBody(request.body); let response = null; 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 isApplicant = reimbursement.owner_id === request.employee.id || reimbursement.submitter_id === request.employee.id; if (!isApplicant) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { withdraw: "only_owner_or_submitter" }, }); } if (reimbursement.status !== "reviewing") { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_withdraw_status" }, }); } const activeInstance = await tx.workflowInstance.findFirst({ where: { entity_type: "reimbursement", entity_id: reimbursementId, current_status: "pending", }, orderBy: { created_at: "desc" }, select: { id: true }, }); if (!activeInstance) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { withdraw: "no_pending_instance" }, }); } const processedTaskCount = await tx.workflowTask.count({ where: { instance_id: activeInstance.id, status: { in: ["completed", "rejected"] }, }, }); if (processedTaskCount > 0) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { withdraw: "already_processed_by_approver" }, }); } const now = new Date(); await tx.workflowTask.updateMany({ where: { instance_id: activeInstance.id, status: "pending", }, data: { status: "cancelled", completed_at: now, completed_by: request.employee.id, }, }); await tx.workflowInstance.update({ where: { id: activeInstance.id }, data: { current_status: "cancelled", completed_at: now, updated_by: request.employee.id, }, }); await tx.reimbursementForm.update({ where: { id: reimbursementId }, data: { status: "draft", updated_by: request.employee.id, }, }); await tx.workflowHistory.create({ data: { instance_id: activeInstance.id, actor_id: request.employee.id, action: "withdraw", comment: comment ?? null, payload: { reimbursement_id: reimbursementId, }, }, }); response = { reimbursementId, outcome: "withdrawn", workflowInstanceId: activeInstance.id, reimbursementStatus: "draft", }; }); return sendData(reply, request, response); }, ); fastify.post( "/reimbursements/:id/pay", { preHandler: fastify.requirePermission("expense.finance.pay"), }, async (request, reply) => { const reimbursementId = parseReimbursementId(request.params?.id); const payInput = parsePayBody(request.body); let response = null; await fastify.prisma.$transaction(async (tx) => { const reimbursementRows = await tx.$queryRaw` SELECT id, status, payment_voucher_no, payment_date, payment_channel, paid_at, paid_by 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]; if (!["approved", "paid"].includes(reimbursement.status)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_pay_status" }, }); } const existingPaymentDate = toDateOnlyString(reimbursement.payment_date); if (reimbursement.status === "paid") { const hasVoucherConflict = payInput.paymentVoucherNo != null && payInput.paymentVoucherNo !== (reimbursement.payment_voucher_no ?? null); const hasPaymentDateConflict = payInput.paymentDate != null && payInput.paymentDate !== existingPaymentDate; const hasChannelConflict = payInput.paymentChannel != null && payInput.paymentChannel !== (reimbursement.payment_channel ?? null); if (hasVoucherConflict || hasPaymentDateConflict || hasChannelConflict) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { payment: "already_paid_conflict" }, }); } } const now = new Date(); const archivedResult = await tx.invoicePool.updateMany({ where: { linked_reimbursement_id: reimbursementId, status: "bound", }, data: { status: "archived", archived_reason: "used_complete", archived_at: now, updated_by: request.employee.id, }, }); if (reimbursement.status === "approved") { await tx.reimbursementForm.update({ where: { id: reimbursementId }, data: { status: "paid", payment_voucher_no: payInput.paymentVoucherNo ?? null, payment_date: payInput.paymentDate ? parseDateOnlyString(payInput.paymentDate) : null, payment_channel: payInput.paymentChannel ?? null, paid_at: now, paid_by: request.employee.id, updated_by: request.employee.id, }, }); } response = { reimbursementId, outcome: "paid", reimbursementStatus: "paid", // archivedInvoiceCount=0 is valid for idempotent pay or when no bound invoices remain. archivedInvoiceCount: archivedResult.count, payment: { paymentVoucherNo: reimbursement.status === "paid" ? reimbursement.payment_voucher_no ?? null : payInput.paymentVoucherNo ?? null, paymentDate: reimbursement.status === "paid" ? existingPaymentDate : payInput.paymentDate ?? null, paymentChannel: reimbursement.status === "paid" ? reimbursement.payment_channel ?? null : payInput.paymentChannel ?? null, paidAt: reimbursement.status === "paid" ? reimbursement.paid_at?.toISOString?.() ?? null : now.toISOString(), paidBy: reimbursement.status === "paid" ? reimbursement.paid_by ?? null : request.employee.id, idempotent: reimbursement.status === "paid", }, }; }); return sendData(reply, request, response); }, ); } module.exports = reimbursementRoutes;