文件
hyb-oa/server/src/routes/reimbursements.js
T

4841 行
155 KiB
JavaScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
const { Prisma } = require("@prisma/client");
const crypto = require("node:crypto");
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 { sha256Hex } = require("../utils/crypto");
const { buildAttachmentContentDisposition } = require("../utils/contentDisposition");
const { signPayload, verifyToken } = require("../utils/signedUrl");
const { compactDateString, currentDateString, parseDateOnlyString } = require("../utils/date");
const { findActiveDelegationForDelegate, listActiveDelegationPrincipals } = require("../utils/delegation");
const { hasPermission, resolveEntityPermission } = require("../constants/permissions");
const {
createNotificationsWithRecords,
resolveRecipientsForAssignee,
} = require("../services/notifications");
const {
buildPendingTaskCopy,
buildWorkflowEntityMeta,
} = require("../services/notifications/workflowCopy");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const { sendNotificationEmailSafe } = require("../services/email");
const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal");
const { mapInvoiceDocumentMeta } = require("../services/invoiceDocumentMeta");
const { dispatchNotificationBatch, flushNotificationDispatchTasks } = require("../services/notificationDispatcher");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { ensureCutoverAllowed } = require("../services/cutover");
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
const {
buildPersistedRiskFlag,
createRiskFlagsFromPrecheck,
resolveEntityPrecheck,
} = require("../services/feeControl");
const { issueDocumentNumber } = require("../services/documentSequence");
const {
PAYMENT_PROCESSING_STAGE_DEFINITIONS,
buildPaymentProcessingSemantics,
requiresBankReceipt,
} = require("../services/paymentProcessingProgress");
const { openReadStream, readFileBuffer, StorageNotFoundError, writeBuffer } = require("../services/storage");
const {
MaterialPreviewError,
createReimbursementMaterialPreviewStore,
} = require("../services/reimbursementMaterialPreview");
const {
ATTACHMENT_TYPE,
DOWNLOAD_POLICY,
SENSITIVITY_LEVEL,
governanceCreateData,
mapGovernanceFields,
parseMultipartGovernanceField,
requiresDownloadReason,
sanitizeDownloadReason,
} = require("../services/businessAttachmentGovernance");
const {
buildApprovalWorkflowSnapshot,
getWorkflowStepsSnapshot,
resolveActionableStep,
resolveWorkflowStepDisplayName,
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(),
paymentNote: z.string().trim().max(200).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(),
});
const REIMBURSEMENT_UPDATE_BODY_SCHEMA = z.object({
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
description: z.string().trim().max(500).nullable().optional(),
travelPurpose: z.string().trim().max(200).nullable().optional(),
travelLocation: z.string().trim().max(200).nullable().optional(),
travelStartDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
travelEndDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
}).superRefine((value, ctx) => {
if (value.travelStartDate && value.travelEndDate && value.travelStartDate > value.travelEndDate) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "invalid_date_range", path: ["travelEndDate"] });
}
});
const REIMBURSEMENT_ATTACHMENT_DELETE_BODY_SCHEMA = z
.object({
reason: z.string().trim().min(2).max(200),
})
.strict();
const REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA = z
.object({ reason: z.string().trim().max(200).optional() })
.strict();
const REIMBURSEMENT_MATERIAL_PREVIEW_BODY_SCHEMA = REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA;
function resolveSignedFileOrigin(config, request) {
const configured = String(config.appOrigin ?? "")
.split(",")
.map((item) => item.trim())
.find(Boolean);
return configured || `${request.protocol}://${request.headers.host ?? "localhost:3000"}`;
}
function resolvePreviewUrlTtlMs(config) {
const configuredSeconds = Math.max(30, Number(config.signedUrlTtlSeconds ?? 120));
return Math.min(configuredSeconds, 120) * 1000;
}
function throwMaterialPreviewAppError(error) {
if (error instanceof StorageNotFoundError) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
if (!(error instanceof MaterialPreviewError)) {
throw error;
}
const limitError = /_limit$/.test(error.code);
throw new AppError({
code: limitError ? "VALID_001" : "MATERIAL_PREVIEW_001",
message: limitError ? "报销材料较多,暂时无法连续预览" : "部分材料暂时无法生成连续预览",
statusCode: limitError ? 409 : 422,
});
}
const REIMBURSEMENT_CREATE_BODY_SCHEMA = z
.object({
ownerEmployeeId: z.string().uuid().optional(),
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
description: z.string().max(500).optional(),
templateId: z.string().uuid().optional(),
linkedTravelRequestId: z.string().uuid().optional(),
travelPurpose: z.string().trim().max(200).optional(),
travelLocation: z.string().trim().max(200).optional(),
travelStartDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
travelEndDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
})
.strict()
.superRefine((value, ctx) => {
if (value.travelStartDate && value.travelEndDate && value.travelStartDate > value.travelEndDate) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "invalid_date_range", path: ["travelEndDate"] });
}
if (value.templateId && value.linkedTravelRequestId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "mutually_exclusive",
path: ["templateId"],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "mutually_exclusive",
path: ["linkedTravelRequestId"],
});
}
});
const REIMBURSEMENT_ITEM_BODY_SCHEMA = z.object({
expenseTypeId: z.string().uuid(),
expenseDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
amount: z.string().regex(/^\d+(\.\d{1,2})?$/),
description: z.string().trim().min(1).max(500),
});
const DATE_ONLY_QUERY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const REIMBURSEMENT_STATS_QUERY_SCHEMA = z.object({
dateFrom: DATE_ONLY_QUERY_SCHEMA.optional(),
dateTo: DATE_ONLY_QUERY_SCHEMA.optional(),
departmentId: z.string().uuid().optional(),
});
const REIMBURSEMENT_TREND_QUERY_SCHEMA = REIMBURSEMENT_STATS_QUERY_SCHEMA.extend({
granularity: z.enum(["month", "week"]).optional(),
});
const REIMBURSEMENT_TOP_EXPENSE_TYPES_QUERY_SCHEMA = REIMBURSEMENT_STATS_QUERY_SCHEMA.extend({
limit: z.coerce.number().int().min(1).max(50).optional(),
});
const LINKABLE_TRAVEL_REQUEST_QUERY_SCHEMA = z
.object({
ownerEmployeeId: UUID_SCHEMA.optional(),
})
.strict();
const REIMBURSEMENT_STATUS_VALUES = ["draft", "reviewing", "approved", "paid", "rejected", "cancelled"];
const ROLE_LABELS = {
manager: "主管",
ceo: "CEO",
finance: "财务",
finance_mgr: "财务经理",
administration: "行政",
admin: "管理员",
hr: "HR",
};
const TRAVEL_SEGMENT_ROLE_LABELS = {
outbound: "去程",
return: "返程",
connection: "中转",
};
const TRAVEL_TRANSPORT_LABELS = {
flight: "航班",
train: "火车",
bus: "大巴",
car: "汽车",
other: "其他",
};
const TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES = {
flight: "travel_airfare",
train: "travel_train",
bus: "travel_bus",
car: "travel_taxi",
other: "transport",
};
const PRINTABLE_WORKFLOW_ACTIONS = new Set([
"start",
"approve",
"reject",
"return",
"transfer",
"withdraw",
"remind",
"overdue_remind",
]);
const ATTACHMENT_MIME_ALLOWLIST = new Set([
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
]);
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 buildWorkflowProgress(workflowInstance, historyItems = []) {
if (!workflowInstance?.current_step_id) {
return null;
}
const steps = getWorkflowStepsSnapshot(workflowInstance);
const currentStepIndex = steps.findIndex((step) => step.id === workflowInstance.current_step_id);
if (currentStepIndex < 0) {
return null;
}
const skippedStepIds = new Set(
historyItems
.filter((item) => item.action === "skip" && item.payload?.step_id)
.map((item) => String(item.payload.step_id)),
);
return {
currentStepIndex,
totalSteps: steps.length,
steps: steps.map((step, index) => ({
id: step.id,
name: step.name ?? step.id,
status: skippedStepIds.has(step.id) || index < currentStepIndex
? "completed"
: index === currentStepIndex
? "current"
: "pending",
})),
};
}
function buildWorkflowStepNameMap(workflowInstance) {
const steps = getWorkflowStepsSnapshot(workflowInstance);
return new Map(steps.map((step) => [step.id, step.name ?? step.id]));
}
function resolveApprovalHistoryStepName(historyItem, stepNameMap, fallbackStepId = null) {
if (historyItem.workflow_task?.step_name) {
return historyItem.workflow_task.step_name;
}
const payload = historyItem.payload ?? {};
const stepId =
payload.from_step_id ??
payload.step_id ??
payload.to_step_id ??
fallbackStepId ??
null;
if (!stepId) {
return null;
}
return stepNameMap.get(String(stepId)) ?? String(stepId);
}
function buildApprovalHistory(workflowInstance, historyItems = []) {
if (!workflowInstance) {
return [];
}
const stepNameMap = buildWorkflowStepNameMap(workflowInstance);
return historyItems
.filter((item) => PRINTABLE_WORKFLOW_ACTIONS.has(item.action))
.map((item) => ({
id: item.id,
stepName: resolveApprovalHistoryStepName(item, stepNameMap, workflowInstance.current_step_id),
action: item.action,
actorName: item.actor?.name ?? "",
comment: item.comment ?? null,
createdAt: item.created_at?.toISOString?.() ?? null,
}));
}
const PROCESSING_STAGE_DEFINITIONS = Object.freeze([
{ key: "submitted", label: "申请提交" },
{ key: "manager_approval", label: "主管审批" },
{ key: "finance_review", label: "财务复核" },
PAYMENT_PROCESSING_STAGE_DEFINITIONS.review,
PAYMENT_PROCESSING_STAGE_DEFINITIONS.payment,
]);
function isoDateTimeOrNull(value) {
return value?.toISOString?.() ?? null;
}
function isOrdinalApprovalLabel(value) {
const label = String(value ?? "").trim();
if (!label) return true;
const ordinal = "(?:\\d+|[一二三四五六七八九十百]+)";
return new RegExp(`^(?:第\\s*)?${ordinal}\\s*(?:步|步骤|节点)$`, "i").test(label)
|| new RegExp(`^(?:审批)?(?:步骤?|节点|step)\\s*[-_#::]?\\s*${ordinal}$`, "i").test(label);
}
function resolveApprovalStageLabel(task) {
const stepName = resolveWorkflowStepDisplayName(task);
const stepId = String(task?.step_id ?? "").trim();
const isTechnicalIdentifier = /^[a-z][a-z0-9]*(?:[_-][a-z0-9]+)+$/i.test(stepName);
const isGenericLabel = ["审批", "审核", "复核"].includes(stepName);
if (stepName && stepName !== stepId && !isOrdinalApprovalLabel(stepName)
&& !isTechnicalIdentifier && !isGenericLabel) {
return stepName;
}
const role = String(task?.assignee_role ?? "").toLowerCase();
if (role === "manager") return "主管审批";
if (["finance", "finance_mgr"].includes(role)) return "财务复核";
if (role === "ceo") return "负责人复核";
return "审批";
}
function resolveSnapshotApprovalRole(step) {
if (step?.assignee_rule?.type === "department_manager") return "manager";
if (step?.assignee_rule?.type === "role") return step.assignee_rule.value ?? null;
return null;
}
function buildPendingSnapshotApprovalStage(step, index) {
return {
key: `approval_pending_${index + 1}`,
label: resolveApprovalStageLabel({
step_id: step?.id,
step_name: step?.name,
assignee_role: resolveSnapshotApprovalRole(step),
}),
state: "pending",
actorName: null,
occurredAt: null,
result: null,
comment: null,
detail: "待处理",
targetHref: null,
};
}
function buildApprovalProcessingStage(task, fallback) {
if (!task) {
return fallback;
}
const actorName = task.completer_employee?.name ?? task.assignee_employee?.name ?? null;
if (task.outcome === "rejected" || task.status === "rejected") {
return {
...fallback,
state: "exception",
actorName,
occurredAt: isoDateTimeOrNull(task.completed_at),
result: "驳回",
comment: task.comment ?? null,
detail: "申请已驳回,待申请人处理",
};
}
if (task.outcome === "returned") {
return {
...fallback,
state: "exception",
actorName,
occurredAt: isoDateTimeOrNull(task.completed_at),
result: "退回",
comment: task.comment ?? null,
detail: "申请已退回,待申请人补充后重新提交",
};
}
if (task.status === "completed" || task.outcome === "approved") {
return {
...fallback,
state: "completed",
actorName,
occurredAt: isoDateTimeOrNull(task.completed_at),
result: "通过",
comment: task.comment ?? null,
detail: null,
};
}
if (task.status === "pending") {
return {
...fallback,
state: "current",
actorName: task.assignee_employee?.name ?? null,
occurredAt: isoDateTimeOrNull(task.created_at),
result: "处理中",
comment: null,
detail: `待${fallback.label}处理`,
};
}
return fallback;
}
function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatchItem, paymentActorNames, canReadPaymentBatch }) {
const stageByKey = new Map(PROCESSING_STAGE_DEFINITIONS.map((stage) => [stage.key, {
...stage,
state: "pending",
actorName: null,
occurredAt: null,
result: null,
comment: null,
detail: "待处理",
targetHref: null,
}]));
const submittedStage = stageByKey.get("submitted");
if (workflowInstance) {
Object.assign(submittedStage, {
state: "completed",
actorName: reimbursement.submitter?.name ?? reimbursement.owner?.name ?? null,
occurredAt: isoDateTimeOrNull(workflowInstance.started_at ?? workflowInstance.created_at),
result: "已提交",
detail: null,
});
} else if (["draft", "rejected"].includes(reimbursement.status)) {
Object.assign(submittedStage, {
state: "current",
actorName: reimbursement.owner?.name ?? null,
result: "待提交",
detail: "待申请人提交",
});
}
const approvalStages = [];
const workflowTasks = workflowInstance?.workflow_tasks ?? [];
for (const [index, task] of workflowTasks.entries()) {
const fallback = {
key: `approval_${task.id ?? index + 1}`,
label: resolveApprovalStageLabel(task),
state: "pending",
actorName: null,
occurredAt: null,
result: null,
comment: null,
detail: "待处理",
targetHref: null,
};
approvalStages.push(buildApprovalProcessingStage(task, fallback));
}
if (workflowInstance) {
const snapshotSteps = getWorkflowStepsSnapshot(workflowInstance);
const currentStepIndex = snapshotSteps.findIndex((step) => step.id === workflowInstance.current_step_id);
const materializedStepIds = new Set(workflowTasks.map((task) => task.step_id).filter(Boolean));
const pendingStartIndex = workflowTasks.length === 0 ? 0 : currentStepIndex + 1;
for (let index = Math.max(pendingStartIndex, 0); index < snapshotSteps.length; index += 1) {
const step = snapshotSteps[index];
if (!materializedStepIds.has(step.id)) {
approvalStages.push(buildPendingSnapshotApprovalStage(step, index));
}
}
}
const batch = paymentBatchItem?.payment_batch ?? null;
const batchTargetHref = batch && canReadPaymentBatch
? `/payment-batches?id=${encodeURIComponent(batch.id)}`
: null;
const batchReviewStage = stageByKey.get("payment_batch_review");
const paymentStage = stageByKey.get("payment_and_receipt");
if (!batch) {
Object.assign(batchReviewStage, reimbursement.status === "approved"
? { state: "current", result: "待安排", detail: "待财务安排付款" }
: { detail: "待财务安排付款" });
paymentStage.detail = "待付款复核后由财务登记,非审批环节";
} else {
const semantics = buildPaymentProcessingSemantics({
status: batch.status,
createdAt: batch.created_at,
reviewedAt: batch.reviewed_at,
executedAt: batch.executed_at,
cancelledAt: batch.cancelled_at,
cancelledReason: batch.cancelled_reason,
requiresBankReceipt: requiresBankReceipt(batch),
receiptCount: batch._count?.receipt_attachments ?? 0,
paymentOrderExportCount: batch.payment_order_export_count ?? 0,
});
const actorIds = {
reviewer: batch.reviewed_by,
executor: batch.executed_by,
canceller: batch.cancelled_by,
};
for (const [target, source] of [
[batchReviewStage, semantics.review],
[paymentStage, semantics.payment],
]) {
Object.assign(target, {
state: source.state,
actorName: source.actorSource
? paymentActorNames.get(actorIds[source.actorSource]) ?? null
: null,
occurredAt: isoDateTimeOrNull(source.occurredAt),
result: source.result,
comment: source.comment,
detail: source.detail,
targetHref: null,
});
}
if (batchTargetHref) {
const reviewOwnsBatchLink = ["current", "exception"].includes(batchReviewStage.state) &&
["pending", "terminated"].includes(paymentStage.state);
(reviewOwnsBatchLink ? batchReviewStage : paymentStage).targetHref = batchTargetHref;
}
}
if (!batch && reimbursement.status === "paid") {
Object.assign(paymentStage, {
state: "completed",
actorName: reimbursement.paid_by_employee?.name ?? null,
occurredAt: isoDateTimeOrNull(reimbursement.paid_at),
result: "已付款",
detail: "付款已完成,回单待留存",
});
}
return {
stages: [
stageByKey.get("submitted"),
...(approvalStages.length > 0
? approvalStages
: [stageByKey.get("manager_approval"), stageByKey.get("finance_review")]),
stageByKey.get("payment_batch_review"),
stageByKey.get("payment_and_receipt"),
],
};
}
function ensureApplicantOrAdmin(request, reimbursement) {
if (request.employee?.roleCodes?.includes("admin")) {
return;
}
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,
});
}
}
function mapReimbursementAttachment(item) {
return {
id: item.id,
originalName: item.original_name,
mimeType: item.mime_type,
fileSize: item.file_size,
fileSha256: item.file_sha256,
...mapGovernanceFields(item, {
governanceStatus: "approved",
sensitivityLevel: SENSITIVITY_LEVEL.internal,
attachmentType: ATTACHMENT_TYPE.supportingMaterial,
downloadPolicy: DOWNLOAD_POLICY.standard,
}),
uploadedBy: item.uploader
? {
id: item.uploader.id,
name: item.uploader.name,
employeeNo: item.uploader.employee_no,
}
: null,
createdAt: item.created_at?.toISOString?.() ?? null,
};
}
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 parseReimbursementUpdateBody(body) {
const parsed = REIMBURSEMENT_UPDATE_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
if (!Object.keys(parsed.data).length) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { body: "empty" },
});
}
return parsed.data;
}
function parseReimbursementAttachmentDeleteBody(body) {
const parsed = REIMBURSEMENT_ATTACHMENT_DELETE_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return parsed.data;
}
function parseQuery(schema, query) {
const parsed = schema.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return parsed.data;
}
function parseStatsQuery(query) {
const parsed = parseQuery(REIMBURSEMENT_STATS_QUERY_SCHEMA, query);
const dateFrom = parsed.dateFrom ? parseDateOnlyString(parsed.dateFrom) : null;
const dateTo = parsed.dateTo ? parseDateOnlyString(parsed.dateTo) : null;
if (dateFrom && dateTo && dateFrom > dateTo) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { dateTo: "must_be_after_dateFrom" },
});
}
return {
dateFrom,
dateTo,
dateFromRaw: parsed.dateFrom ?? null,
dateToRaw: parsed.dateTo ?? null,
departmentId: parsed.departmentId ?? null,
};
}
function parseCreateBody(body) {
const parsed = REIMBURSEMENT_CREATE_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return parsed.data;
}
function parseLinkableTravelRequestsQuery(query) {
const parsed = LINKABLE_TRAVEL_REQUEST_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return {
ownerEmployeeId: parsed.data.ownerEmployeeId ?? null,
};
}
function parseTrendQuery(query) {
const parsed = parseQuery(REIMBURSEMENT_TREND_QUERY_SCHEMA, query);
const base = parseStatsQuery(parsed);
return {
...base,
granularity: parsed.granularity ?? "month",
};
}
function parseTopExpenseTypesQuery(query) {
const parsed = parseQuery(REIMBURSEMENT_TOP_EXPENSE_TYPES_QUERY_SCHEMA, query);
const base = parseStatsQuery(parsed);
return {
...base,
limit: parsed.limit ?? 10,
};
}
function sqlIdentifier(alias, column) {
return Prisma.raw(`"${alias}"."${column}"`);
}
function buildReimbursementStatsConditions({
request,
dateFrom,
dateTo,
departmentId,
formAlias = "rf",
ownerAlias = "owner_employee",
}) {
const conditions = [Prisma.sql`1 = 1`];
if (dateFrom) {
conditions.push(Prisma.sql`${sqlIdentifier(formAlias, "reimbursement_date")} >= ${dateFrom}`);
}
if (dateTo) {
conditions.push(Prisma.sql`${sqlIdentifier(formAlias, "reimbursement_date")} <= ${dateTo}`);
}
if (departmentId) {
conditions.push(Prisma.sql`${sqlIdentifier(formAlias, "department_id")} = ${departmentId}`);
}
if (!isGlobalViewRole(request.employee.roleCodes ?? [])) {
conditions.push(
Prisma.sql`(
${sqlIdentifier(formAlias, "owner_id")} = ${request.employee.id}
OR ${sqlIdentifier(formAlias, "submitter_id")} = ${request.employee.id}
OR ${sqlIdentifier(ownerAlias, "direct_manager_id")} = ${request.employee.id}
)`,
);
}
return conditions;
}
function toNumericValue(value) {
const numeric = Number(value ?? 0);
return Number.isFinite(numeric) ? numeric : 0;
}
function toNullableNumber(value) {
if (value == null) {
return null;
}
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function toFixedMoneyString(value) {
return toNumericValue(value).toFixed(2);
}
function formatTrendPeriod(value, granularity) {
if (!value) {
return null;
}
const dateValue = value instanceof Date ? value : new Date(value);
if (Number.isNaN(dateValue.getTime())) {
return String(value);
}
return granularity === "month"
? dateValue.toISOString().slice(0, 7)
: dateValue.toISOString().slice(0, 10);
}
function formatRoleLabel(roleCode) {
if (!roleCode) {
return "未分配";
}
return ROLE_LABELS[roleCode] ?? roleCode;
}
function buildAllowedActionsForPendingWorkflow(request, entityType) {
const approvePermission = resolveEntityPermission(entityType, "approve");
const canApprove =
approvePermission && hasPermission(request.employee.permissionCodes ?? [], approvePermission);
if (!canApprove) {
return [];
}
const actions = ["approve", "reject", "return"];
if (hasPermission(request.employee.permissionCodes ?? [], "expense.transfer")) {
actions.push("transfer");
}
return actions;
}
function isPendingWorkflowTaskActionable(request, task, delegatedPrincipalIds) {
if (!task || task.status !== "pending") {
return false;
}
const roleCodes = request.employee.roleCodes ?? [];
if (!task.assignee_id && !task.assignee_role) {
return true;
}
if (task.assignee_id && task.assignee_id === request.employee.id) {
return true;
}
if (task.assignee_role && roleCodes.includes(task.assignee_role)) {
return true;
}
return Boolean(task.assignee_id && delegatedPrincipalIds.includes(task.assignee_id));
}
function buildPendingWorkflowSummary(item, request, delegatedPrincipalIds) {
const pendingInstance = item.workflow_instances?.[0] ?? null;
const pendingTask = pendingInstance?.workflow_tasks?.[0] ?? null;
if (!pendingInstance || !pendingTask) {
return null;
}
if (!isPendingWorkflowTaskActionable(request, pendingTask, delegatedPrincipalIds)) {
return null;
}
const allowedActions = buildAllowedActionsForPendingWorkflow(request, "reimbursement");
if (!allowedActions.length) {
return null;
}
return {
instanceId: pendingInstance.id,
stepId: pendingInstance.current_step_id,
allowedActions,
task: {
id: pendingTask.id,
stepName: pendingTask.step_name,
assigneeId: pendingTask.assignee_id,
assigneeRole: pendingTask.assignee_role,
},
};
}
function parseItemBody(body) {
const parsed = REIMBURSEMENT_ITEM_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const amountNumber = Number(parsed.data.amount);
if (!Number.isFinite(amountNumber) || amountNumber <= 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { amount: "must_be_positive" },
});
}
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,
project: item.project
? {
id: item.project.id,
projectNo: item.project.project_no,
name: item.project.name,
}
: null,
pendingWorkflow: item.pending_workflow ?? 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 mapLinkedTravelRequestSummary(travelRequest) {
if (!travelRequest) {
return null;
}
return {
id: travelRequest.id,
requestNo: travelRequest.request_no ?? null,
purpose: travelRequest.purpose ?? null,
startDate: toDateOnlyString(travelRequest.start_date),
endDate: toDateOnlyString(travelRequest.end_date),
estimatedAmount: toMoneyString(travelRequest.estimated_amount ?? 0),
};
}
function mapCreatedReimbursementItem(item) {
return {
id: item.id,
expenseTypeId: item.expense_type_id,
expenseDate: toDateOnlyString(item.expense_date),
amount: toMoneyString(item.amount),
description: item.description ?? null,
expenseType: item.expense_type
? {
id: item.expense_type.id,
code: item.expense_type.code,
name: item.expense_type.name,
category: item.expense_type.category,
enabled: item.expense_type.enabled,
}
: null,
};
}
function normalizeTemplateSeedItems(rawItems) {
if (!Array.isArray(rawItems)) {
return [];
}
return rawItems.map((item, index) => {
if (!item || typeof item !== "object") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { template: { itemIndex: index, reason: "invalid_item_shape" } },
});
}
if (typeof item.expenseTypeId !== "string" || typeof item.description !== "string") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { template: { itemIndex: index, reason: "invalid_item_shape" } },
});
}
return {
expenseTypeId: item.expenseTypeId,
description: item.description,
defaultAmount: typeof item.defaultAmount === "string" ? item.defaultAmount : null,
};
});
}
async function resolveEnabledExpenseTypesByIds(tx, expenseTypeIds) {
if (!expenseTypeIds.length) {
return new Map();
}
const items = await tx.expenseType.findMany({
where: {
id: { in: expenseTypeIds },
enabled: true,
},
select: {
id: true,
code: true,
name: true,
category: true,
enabled: true,
},
});
return new Map(items.map((item) => [item.id, item]));
}
async function resolveTravelExpenseTypeMap(tx) {
const codes = Array.from(new Set(Object.values(TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES)));
const items = await tx.expenseType.findMany({
where: {
code: { in: codes },
enabled: true,
},
select: {
id: true,
code: true,
name: true,
category: true,
enabled: true,
},
});
return new Map(items.map((item) => [item.code, item]));
}
function buildTravelSegmentDescription(segment) {
const segmentRoleLabel = TRAVEL_SEGMENT_ROLE_LABELS[segment.segment_role] ?? segment.segment_role;
const transportLabel = TRAVEL_TRANSPORT_LABELS[segment.transport_type] ?? segment.transport_type;
return `${segmentRoleLabel} ${transportLabel} ${segment.from_city}→${segment.to_city}`;
}
async function resolveReimbursementOwnerContext(tx, fastify, request, ownerEmployeeId) {
const isProxyCreate = ownerEmployeeId !== request.employee.id;
if (!isProxyCreate) {
return {
ownerEmployeeId,
ownerEmployee: {
id: request.employee.id,
department_id: request.employee.department_id ?? null,
status: "active",
},
departmentId: request.employee.department_id ?? null,
delegationGrantId: null,
isProxyCreate: false,
};
}
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,
});
}
const ownerEmployee = await tx.employee.findUnique({
where: { id: ownerEmployeeId },
select: { id: true, status: true, department_id: true },
});
if (!ownerEmployee || ownerEmployee.status !== "active") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { ownerEmployeeId: "invalid_owner" },
});
}
const delegation = await findActiveDelegationForDelegate(tx, {
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" },
});
}
return {
ownerEmployeeId,
ownerEmployee,
departmentId: ownerEmployee.department_id,
delegationGrantId: delegation.id,
isProxyCreate: true,
};
}
async function loadLinkableTravelRequests(tx, ownerEmployeeId) {
return tx.travelRequest.findMany({
where: {
applicant_employee_id: ownerEmployeeId,
status: "approved",
linked_reimbursements: {
none: {},
},
travel_orders: {
none: {
linked_reimbursement_id: {
not: null,
},
},
},
},
orderBy: [{ start_date: "desc" }, { created_at: "desc" }],
select: {
id: true,
request_no: true,
purpose: true,
start_date: true,
end_date: true,
estimated_amount: true,
},
});
}
async function loadTravelRequestForLinking(tx, linkedTravelRequestId) {
return tx.travelRequest.findUnique({
where: { id: linkedTravelRequestId },
select: {
id: true,
request_no: true,
purpose: true,
start_date: true,
end_date: true,
estimated_amount: true,
applicant_employee_id: true,
status: true,
segments: {
orderBy: [{ sort_order: "asc" }, { created_at: "asc" }],
select: {
id: true,
segment_role: true,
transport_type: true,
from_city: true,
to_city: true,
},
},
linked_reimbursements: {
take: 1,
select: {
id: true,
},
},
travel_orders: {
where: {
linked_reimbursement_id: {
not: null,
},
},
take: 1,
select: {
id: true,
linked_reimbursement_id: true,
},
},
},
});
}
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 recomputeTotalAmount(tx, reimbursementId, actorId) {
const aggregate = await tx.reimbursementItem.aggregate({
where: { reimbursement_id: reimbursementId },
_sum: { amount: true },
});
const nextTotal = aggregate._sum.amount ?? "0";
await tx.reimbursementForm.update({
where: { id: reimbursementId },
data: {
total_amount: nextTotal,
updated_by: actorId,
},
});
return nextTotal;
}
async function 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,
owner_self_fallback_role: true,
priority: true,
},
});
if (!approvalRule) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { workflow: "missing_approval_rule" },
});
}
return approvalRule;
}
async function resolveApplicableBudgetPool(tx, reimbursement) {
return resolveApplicableBudgetPoolByContext(tx, {
departmentId: reimbursement.department_id,
effectiveDate: reimbursement.reimbursement_date,
});
}
function normalizeBudgetControlMode(config) {
const mode = String(config.budgetControlMode ?? "warn").toLowerCase();
if (mode === "block") {
return "block";
}
return "warn";
}
function buildBudgetHint(budgetPool, reimbursementAmount, controlMode) {
const allocatedAmount = Number(budgetPool.allocated_amount ?? 0);
const usedAmount = Number(budgetPool.used_amount ?? 0);
const currentAmount = Number(reimbursementAmount ?? 0);
const nextUsed = usedAmount + currentAmount;
const nextUsagePercent = allocatedAmount > 0 ? (nextUsed / allocatedAmount) * 100 : 0;
return {
budgetId: budgetPool.id,
budgetCode: budgetPool.code,
budgetName: budgetPool.name,
controlMode,
warningThresholdPercent: budgetPool.warning_threshold_percent,
allocatedAmount: allocatedAmount.toFixed(2),
usedAmount: usedAmount.toFixed(2),
remainingAmount: Math.max(0, allocatedAmount - usedAmount).toFixed(2),
currentAmount: currentAmount.toFixed(2),
nextUsedAmount: nextUsed.toFixed(2),
nextUsagePercent: Number(nextUsagePercent.toFixed(2)),
exceedsThreshold: nextUsagePercent >= budgetPool.warning_threshold_percent,
exceedsBudget: nextUsed > allocatedAmount,
};
}
function buildPrecheckBlockedError(precheck) {
const standardViolations = Array.isArray(precheck?.standardChecks)
? precheck.standardChecks.filter((item) => item.exceeded && item.controlMode === "block")
: [];
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: standardViolations.length ? 400 : 409,
details: {
precheck: precheck.blockingReasons,
...(standardViolations.length ? { standardViolations } : {}),
},
});
}
function listExceededStandardViolations(precheck, controlMode = null) {
if (!Array.isArray(precheck?.standardChecks)) {
return [];
}
return precheck.standardChecks.filter((item) => {
if (!item.exceeded) {
return false;
}
if (!controlMode) {
return true;
}
return item.controlMode === controlMode;
});
}
function buildSkippedNotificationResult(reason) {
return { skipped: true, reason };
}
function buildSkippedDispatchResult(reason) {
return { deliveredChannels: [], skippedReason: reason, notificationCount: 0, eligibleRecipientCount: 0, matchedChannelCount: 0 };
}
function buildSkippedEmailResult(reason, failed = 0) {
return { skipped: true, reason, sent: 0, failed };
}
async function loadReimbursementSubmitSummary(prisma, reimbursementId) {
return prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: {
id: true,
form_number: true,
total_amount: true,
owner: {
select: {
id: true,
name: true,
},
},
},
});
}
async function finalizeReimbursementSubmitSideEffects({
prisma,
config,
logger,
requestId,
reimbursementId,
formNumber,
workflowTaskId,
assigneeId,
assigneeRole,
actorId,
loadSummary = loadReimbursementSubmitSummary,
resolveRecipients = resolveRecipientsForAssignee,
createAppNotifications = createNotificationsWithRecords,
dispatchNotifications = dispatchNotificationBatch,
sendEmailSafe = sendNotificationEmailSafe,
}) {
let copy = buildPendingTaskCopy(
buildWorkflowEntityMeta("reimbursement", { form_number: formNumber }, reimbursementId),
);
try {
const summary = await loadSummary(prisma, reimbursementId);
copy = buildPendingTaskCopy(
buildWorkflowEntityMeta(
"reimbursement",
{
...summary,
form_number: summary?.form_number ?? formNumber,
},
reimbursementId,
),
);
} catch (error) {
logger?.error?.(
{
err: error,
requestId,
reimbursementId,
},
"Failed to load reimbursement submit notification summary",
);
}
const { title, body } = copy;
const targetPath = `/workflow/tasks/${workflowTaskId}`;
let recipientEmployeeIds = [];
try {
recipientEmployeeIds = await resolveRecipients(prisma, {
assigneeId,
assigneeRole,
});
} catch (error) {
logger?.error?.(
{
err: error,
requestId,
reimbursementId,
workflowTaskId,
},
"Failed to resolve reimbursement submit notification recipients",
);
return {
recipientEmployeeIds: [],
notification: buildSkippedNotificationResult("recipient_resolution_failed"),
dispatch: buildSkippedDispatchResult("recipient_resolution_failed"),
email: buildSkippedEmailResult("recipient_resolution_failed"),
};
}
if (!recipientEmployeeIds.length) {
return {
recipientEmployeeIds: [],
notification: buildSkippedNotificationResult("no_recipients"),
dispatch: buildSkippedDispatchResult("no_recipients"),
email: buildSkippedEmailResult("no_recipients"),
};
}
let notification = buildSkippedNotificationResult("not_attempted");
let notificationRecords = [];
try {
notificationRecords = await createAppNotifications(prisma, {
recipientEmployeeIds,
type: NOTIFICATION_TYPES.workflowTaskAssigned,
title,
body,
targetType: "workflow_task",
targetId: workflowTaskId,
});
notification = { count: notificationRecords.length };
} catch (error) {
logger?.error?.(
{
err: error,
requestId,
reimbursementId,
workflowTaskId,
recipients: recipientEmployeeIds.length,
},
"Failed to create reimbursement submit notifications",
);
notification = buildSkippedNotificationResult("notification_failed");
}
let dispatch = buildSkippedDispatchResult("not_attempted");
try {
dispatch = await dispatchNotifications({
prisma,
config,
logger,
requestId,
notifications: notificationRecords,
type: NOTIFICATION_TYPES.workflowTaskAssigned,
title,
body,
targetType: "workflow_task",
targetId: workflowTaskId,
targetPath,
actorId,
});
} catch (error) {
logger?.error?.(
{
err: error,
requestId,
reimbursementId,
workflowTaskId,
recipients: recipientEmployeeIds.length,
},
"Failed to dispatch reimbursement submit notifications",
);
dispatch = buildSkippedDispatchResult("dispatch_failed");
}
let email = buildSkippedEmailResult("not_attempted");
try {
const emailResult = await sendEmailSafe({
prisma,
config,
logger,
requestId,
recipientEmployeeIds,
subject: title,
body,
type: NOTIFICATION_TYPES.workflowTaskAssigned,
targetPath,
});
email = emailResult?.email ?? emailResult ?? buildSkippedEmailResult("empty_email_result");
} catch (error) {
logger?.error?.(
{
err: error,
requestId,
reimbursementId,
workflowTaskId,
recipients: recipientEmployeeIds.length,
},
"Failed to send reimbursement submit notification email",
);
email = buildSkippedEmailResult("email_failed", recipientEmployeeIds.length);
}
return {
recipientEmployeeIds,
notification,
dispatch,
email,
};
}
async function reimbursementRoutes(fastify) {
const materialPreviewStore = fastify.reimbursementMaterialPreviewStore
?? createReimbursementMaterialPreviewStore();
fastify.post(
"/reimbursements",
{
schema: { tags: ["Reimbursements"], summary: "Create Reimbursement" },
preHandler: fastify.requirePermission("expense.create"),
},
async (request, reply) => {
await ensureCutoverAllowed({
prisma: fastify.prisma,
config: fastify.config,
request,
domain: "expense",
departmentId: request.employee.department_id ?? null,
});
const body = parseCreateBody(request.body);
const reimbursementDate =
body.reimbursementDate ?? currentDateString(fastify.config.appTimezone);
const ownerEmployeeId = body.ownerEmployeeId ?? request.employee.id;
const reimbursement = await fastify.prisma.$transaction(async (tx) => {
const ownerContext = await resolveReimbursementOwnerContext(tx, fastify, request, ownerEmployeeId);
let linkedTravelRequestRecord = null;
let generatedItemSeeds = [];
let resolvedDescription = body.description ?? null;
if (body.templateId) {
const template = await tx.reimbursementTemplate.findFirst({
where: {
id: body.templateId,
enabled: true,
},
select: {
id: true,
name: true,
items: true,
},
});
if (!template) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { templateId: "invalid_template" },
});
}
const templateItems = normalizeTemplateSeedItems(template.items);
const expenseTypeMap = await resolveEnabledExpenseTypesByIds(
tx,
Array.from(new Set(templateItems.map((item) => item.expenseTypeId))),
);
const invalidItem = templateItems.find((item) => !expenseTypeMap.has(item.expenseTypeId));
if (invalidItem) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { templateId: "contains_disabled_expense_type" },
});
}
generatedItemSeeds = templateItems.map((item) => ({
expense_type_id: item.expenseTypeId,
expense_date: parseDateOnlyString(reimbursementDate),
amount: item.defaultAmount ?? "0.00",
description: item.description,
created_by: request.employee.id,
updated_by: request.employee.id,
}));
}
if (body.linkedTravelRequestId) {
linkedTravelRequestRecord = await loadTravelRequestForLinking(tx, body.linkedTravelRequestId);
if (!linkedTravelRequestRecord) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { linkedTravelRequestId: "not_found" },
});
}
if (linkedTravelRequestRecord.status !== "approved") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { linkedTravelRequestId: "not_approved" },
});
}
if (linkedTravelRequestRecord.applicant_employee_id !== ownerContext.ownerEmployeeId) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { linkedTravelRequestId: "owner_mismatch" },
});
}
if (
linkedTravelRequestRecord.linked_reimbursements?.length ||
linkedTravelRequestRecord.travel_orders?.length
) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { linkedTravelRequestId: "already_linked" },
});
}
if (body.description == null) {
resolvedDescription = `差旅报销 - ${linkedTravelRequestRecord.request_no ?? linkedTravelRequestRecord.id} ${linkedTravelRequestRecord.purpose}`;
}
const expenseTypeMap = await resolveTravelExpenseTypeMap(tx);
generatedItemSeeds = linkedTravelRequestRecord.segments.map((segment) => {
const expenseTypeCode = TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES[segment.transport_type];
const expenseType = expenseTypeMap.get(expenseTypeCode);
if (!expenseType) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
linkedTravelRequestId: "missing_expense_type_mapping",
transportType: segment.transport_type,
expenseTypeCode,
},
});
}
return {
expense_type_id: expenseType.id,
expense_date: parseDateOnlyString(reimbursementDate),
amount: "0.00",
description: buildTravelSegmentDescription(segment),
created_by: request.employee.id,
updated_by: request.employee.id,
};
});
}
const issued = await issueDocumentNumber(tx, {
documentType: "reimbursement",
actorId: request.employee.id,
timeZone: fastify.config.appTimezone,
});
const linkedTravelCities = linkedTravelRequestRecord
? [...new Set(linkedTravelRequestRecord.segments.flatMap((segment) => [segment.from_city, segment.to_city]).filter(Boolean))]
: [];
const travelPurpose = body.travelPurpose?.trim() || linkedTravelRequestRecord?.purpose || null;
const travelLocation = body.travelLocation?.trim() || linkedTravelCities.join("、") || null;
const travelStartDate = body.travelStartDate
? parseDateOnlyString(body.travelStartDate)
: linkedTravelRequestRecord?.start_date ?? null;
const travelEndDate = body.travelEndDate
? parseDateOnlyString(body.travelEndDate)
: linkedTravelRequestRecord?.end_date ?? null;
const created = await tx.reimbursementForm.create({
data: {
form_number: issued.documentNumber,
submitter_id: request.employee.id,
owner_id: ownerEmployeeId,
department_id: ownerContext.departmentId,
reimbursement_date: parseDateOnlyString(reimbursementDate),
description: resolvedDescription,
linked_travel_request_id: linkedTravelRequestRecord?.id ?? null,
travel_purpose: travelPurpose,
travel_location: travelLocation,
travel_start_date: travelStartDate,
travel_end_date: travelEndDate,
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,
owner_id: true,
submitter_id: true,
linked_travel_request_id: true,
travel_purpose: true,
travel_location: true,
travel_start_date: true,
travel_end_date: true,
travel_request: {
select: {
id: true,
request_no: true,
purpose: true,
start_date: true,
end_date: true,
estimated_amount: true,
},
},
},
});
if (generatedItemSeeds.length) {
await tx.reimbursementItem.createMany({
data: generatedItemSeeds.map((item) => ({
reimbursement_id: created.id,
...item,
})),
});
created.total_amount = await recomputeTotalAmount(tx, created.id, request.employee.id);
}
const createdItems = generatedItemSeeds.length
? await tx.reimbursementItem.findMany({
where: { reimbursement_id: created.id },
orderBy: [{ created_at: "asc" }, { id: "asc" }],
select: {
id: true,
expense_type_id: true,
expense_date: true,
amount: true,
description: true,
expense_type: {
select: {
id: true,
code: true,
name: true,
category: true,
enabled: true,
},
},
},
})
: [];
if (ownerContext.isProxyCreate) {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "REIMBURSEMENT_PROXY_CREATED",
targetType: "reimbursement_form",
targetId: created.id,
extra: {
ownerEmployeeId,
submitterEmployeeId: request.employee.id,
delegationGrantId: ownerContext.delegationGrantId,
...(body.templateId ? { templateId: body.templateId } : {}),
...(linkedTravelRequestRecord ? { linkedTravelRequestId: linkedTravelRequestRecord.id } : {}),
},
}),
});
}
return {
...created,
items: createdItems,
};
});
return sendData(reply, request, {
id: reimbursement.id,
formNumber: reimbursement.form_number,
submitterId: reimbursement.submitter_id,
ownerId: reimbursement.owner_id,
reimbursementDate,
status: reimbursement.status,
description: reimbursement.description,
payment: {
paymentVoucherNo: reimbursement.payment_voucher_no ?? null,
paymentDate: toDateOnlyString(reimbursement.payment_date),
paymentChannel: reimbursement.payment_channel ?? null,
paidAt: reimbursement.paid_at?.toISOString?.() ?? null,
paidBy: reimbursement.paid_by_employee
? {
id: reimbursement.paid_by_employee.id,
employeeNo: reimbursement.paid_by_employee.employee_no,
name: reimbursement.paid_by_employee.name,
}
: null,
},
projectId: reimbursement.project_id ?? null,
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
totalAmount: toMoneyString(reimbursement.total_amount),
linkedTravelRequestId: reimbursement.linked_travel_request_id ?? null,
linkedTravelRequest: mapLinkedTravelRequestSummary(reimbursement.travel_request),
travelSummary: {
purpose: reimbursement.travel_purpose ?? null,
location: reimbursement.travel_location ?? null,
startDate: toDateOnlyString(reimbursement.travel_start_date),
endDate: toDateOnlyString(reimbursement.travel_end_date),
},
items: Array.isArray(reimbursement.items) ? reimbursement.items.map(mapCreatedReimbursementItem) : [],
});
},
);
fastify.get(
"/reimbursements",
{
schema: { tags: ["Reimbursements"], summary: "List 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 delegatedPrincipalIds = await listActiveDelegationPrincipals(fastify.prisma, {
delegateEmployeeId: request.employee.id,
allowedScopes: ["all"],
});
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,
project: { select: { id: true, project_no: true, name: true } },
owner: {
select: { id: true, employee_no: true, name: true },
},
submitter: {
select: { id: true, employee_no: true, name: 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,
status: true,
},
},
},
},
},
}),
fastify.prisma.reimbursementForm.count({ where }),
]);
return sendData(reply, request, {
page: query.page,
pageSize: query.pageSize,
total,
scope,
items: items.map((item) =>
mapReimbursementSummary({
...item,
pending_workflow: buildPendingWorkflowSummary(item, request, delegatedPrincipalIds),
}),
),
});
},
);
fastify.get(
"/reimbursements/stats/summary",
{
schema: { tags: ["Reimbursements"], summary: "Get reimbursement stats summary" },
preHandler: fastify.requirePermission("expense.view_all"),
},
async (request, reply) => {
const query = parseStatsQuery(request.query);
const conditions = buildReimbursementStatsConditions({
request,
dateFrom: query.dateFrom,
dateTo: query.dateTo,
departmentId: query.departmentId,
});
const [statusRows, avgRow] = await Promise.all([
fastify.prisma.$queryRaw(
Prisma.sql`
SELECT
rf.status::text AS status,
COUNT(*)::int AS count,
COALESCE(SUM(rf.total_amount), 0)::text AS total_amount
FROM "reimbursement_forms" rf
LEFT JOIN "employees" owner_employee ON owner_employee.id = rf.owner_id
WHERE ${Prisma.join(conditions, " AND ")}
GROUP BY rf.status
`,
),
fastify.prisma.$queryRaw(
Prisma.sql`
SELECT
ROUND(AVG(EXTRACT(EPOCH FROM (rf.paid_at - submit_info.submitted_at)) / 86400.0)::numeric, 2)::float8
AS avg_processing_days
FROM "reimbursement_forms" rf
LEFT JOIN "employees" owner_employee ON owner_employee.id = rf.owner_id
JOIN (
SELECT reimbursement_id, MAX(created_at) AS submitted_at
FROM "workflow_instances"
WHERE entity_type = 'reimbursement' AND reimbursement_id IS NOT NULL
GROUP BY reimbursement_id
) submit_info ON submit_info.reimbursement_id = rf.id
WHERE ${Prisma.join(
[
...conditions,
Prisma.sql`rf.status = 'paid'`,
Prisma.sql`rf.paid_at IS NOT NULL`,
],
" AND ",
)}
`,
),
]);
const byStatus = Object.fromEntries(
REIMBURSEMENT_STATUS_VALUES.map((status) => [
status,
{
count: 0,
totalAmount: toFixedMoneyString(0),
},
]),
);
for (const row of statusRows) {
byStatus[row.status] = {
count: Number(row.count ?? 0),
totalAmount: toFixedMoneyString(row.total_amount ?? 0),
};
}
const totalCount = REIMBURSEMENT_STATUS_VALUES.reduce(
(sum, status) => sum + Number(byStatus[status]?.count ?? 0),
0,
);
const totalAmountNumber = REIMBURSEMENT_STATUS_VALUES.reduce(
(sum, status) => sum + toNumericValue(byStatus[status]?.totalAmount),
0,
);
return sendData(reply, request, {
totalCount,
totalAmount: toFixedMoneyString(totalAmountNumber),
byStatus,
avgProcessingDays: toNullableNumber(avgRow?.[0]?.avg_processing_days),
});
},
);
fastify.get(
"/reimbursements/stats/trend",
{
schema: { tags: ["Reimbursements"], summary: "Get reimbursement stats trend" },
preHandler: fastify.requirePermission("expense.view_all"),
},
async (request, reply) => {
const query = parseTrendQuery(request.query);
const conditions = buildReimbursementStatsConditions({
request,
dateFrom: query.dateFrom,
dateTo: query.dateTo,
departmentId: query.departmentId,
});
const periodExpression =
query.granularity === "week"
? Prisma.sql`date_trunc('week', rf.reimbursement_date)`
: Prisma.sql`date_trunc('month', rf.reimbursement_date)`;
const rows = await fastify.prisma.$queryRaw(
Prisma.sql`
SELECT
${periodExpression} AS period_start,
COUNT(*)::int AS count,
COALESCE(SUM(rf.total_amount), 0)::text AS amount,
COUNT(*) FILTER (WHERE rf.status = 'paid')::int AS paid_count,
COALESCE(SUM(rf.total_amount) FILTER (WHERE rf.status = 'paid'), 0)::text AS paid_amount
FROM "reimbursement_forms" rf
LEFT JOIN "employees" owner_employee ON owner_employee.id = rf.owner_id
WHERE ${Prisma.join(conditions, " AND ")}
GROUP BY period_start
ORDER BY period_start ASC
`,
);
return sendData(reply, request, {
items: rows.map((row) => ({
period: formatTrendPeriod(row.period_start, query.granularity),
count: Number(row.count ?? 0),
amount: toFixedMoneyString(row.amount ?? 0),
paidCount: Number(row.paid_count ?? 0),
paidAmount: toFixedMoneyString(row.paid_amount ?? 0),
})),
});
},
);
fastify.get(
"/reimbursements/stats/top-expense-types",
{
schema: { tags: ["Reimbursements"], summary: "Get reimbursement top expense types" },
preHandler: fastify.requirePermission("expense.view_all"),
},
async (request, reply) => {
const query = parseTopExpenseTypesQuery(request.query);
const conditions = buildReimbursementStatsConditions({
request,
dateFrom: query.dateFrom,
dateTo: query.dateTo,
departmentId: query.departmentId,
});
const rows = await fastify.prisma.$queryRaw(
Prisma.sql`
SELECT
et.id::text AS expense_type_id,
et.name::text AS expense_type_name,
COUNT(ri.id)::int AS count,
COALESCE(SUM(ri.amount), 0)::text AS total_amount
FROM "reimbursement_items" ri
INNER JOIN "reimbursement_forms" rf ON rf.id = ri.reimbursement_id
LEFT JOIN "employees" owner_employee ON owner_employee.id = rf.owner_id
INNER JOIN "expense_types" et ON et.id = ri.expense_type_id
WHERE ${Prisma.join(conditions, " AND ")}
GROUP BY et.id, et.name
ORDER BY SUM(ri.amount) DESC, COUNT(ri.id) DESC, et.name ASC
LIMIT ${query.limit}
`,
);
return sendData(reply, request, {
items: rows.map((row) => ({
expenseTypeId: row.expense_type_id,
expenseTypeName: row.expense_type_name,
count: Number(row.count ?? 0),
totalAmount: toFixedMoneyString(row.total_amount ?? 0),
})),
});
},
);
fastify.get(
"/reimbursements/stats/approval-bottleneck",
{
schema: { tags: ["Reimbursements"], summary: "Get reimbursement approval bottleneck" },
preHandler: fastify.requirePermission("expense.view_all"),
},
async (request, reply) => {
const query = parseStatsQuery(request.query);
const conditions = buildReimbursementStatsConditions({
request,
dateFrom: query.dateFrom,
dateTo: query.dateTo,
departmentId: query.departmentId,
});
const rows = await fastify.prisma.$queryRaw(
Prisma.sql`
SELECT
wt.assignee_id::text AS assignee_id,
wt.assignee_role::text AS assignee_role,
approver.name::text AS assignee_name,
COUNT(*)::int AS pending_count,
FLOOR(MAX(EXTRACT(EPOCH FROM (NOW() - wt.created_at)) / 86400.0))::int AS oldest_pending_days
FROM "workflow_tasks" wt
INNER JOIN "workflow_instances" wi ON wi.id = wt.instance_id AND wi.entity_type = 'reimbursement'
INNER JOIN "reimbursement_forms" rf ON rf.id = wi.reimbursement_id
LEFT JOIN "employees" owner_employee ON owner_employee.id = rf.owner_id
LEFT JOIN "employees" approver ON approver.id = wt.assignee_id
WHERE ${Prisma.join(
[
...conditions,
Prisma.sql`wt.status = 'pending'`,
Prisma.sql`rf.status = 'reviewing'`,
],
" AND ",
)}
GROUP BY wt.assignee_id, wt.assignee_role, approver.name
ORDER BY pending_count DESC, oldest_pending_days DESC, approver.name ASC NULLS LAST, wt.assignee_role ASC NULLS LAST
`,
);
return sendData(reply, request, {
items: rows.map((row) => ({
assigneeId: row.assignee_id,
assigneeName: row.assignee_name ?? formatRoleLabel(row.assignee_role),
pendingCount: Number(row.pending_count ?? 0),
oldestPendingDays: Number(row.oldest_pending_days ?? 0),
})),
});
},
);
fastify.get(
"/reimbursements/linkable-travel-requests",
{
schema: { tags: ["Reimbursements"], summary: "List approved travel requests that can be linked to reimbursement" },
preHandler: fastify.requirePermission("expense.create"),
},
async (request, reply) => {
const query = parseLinkableTravelRequestsQuery(request.query);
const ownerEmployeeId = query.ownerEmployeeId ?? request.employee.id;
const ownerContext = await resolveReimbursementOwnerContext(
fastify.prisma,
fastify,
request,
ownerEmployeeId,
);
const items = await loadLinkableTravelRequests(fastify.prisma, ownerContext.ownerEmployeeId);
return sendData(reply, request, {
ownerEmployeeId: ownerContext.ownerEmployeeId,
items: items.map((item) => ({
id: item.id,
requestNo: item.request_no ?? null,
purpose: item.purpose,
startDate: toDateOnlyString(item.start_date),
endDate: toDateOnlyString(item.end_date),
estimatedAmount: toMoneyString(item.estimated_amount ?? 0),
})),
});
},
);
fastify.get(
"/reimbursements/:id",
{
schema: { tags: ["Reimbursements"], summary: "Get Reimbursement Detail" },
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,
created_at: true,
reimbursement_date: true,
total_amount: true,
status: true,
description: true,
payment_voucher_no: true,
payment_date: true,
payment_channel: true,
paid_at: true,
project_id: true,
linked_travel_request_id: true,
travel_purpose: true,
travel_location: true,
travel_start_date: true,
travel_end_date: true,
paid_by_employee: {
select: {
id: true,
employee_no: true,
name: true,
},
},
department: {
select: {
id: true,
name: true,
},
},
project: { select: { id: true, project_no: true, name: true } },
travel_request: {
select: {
id: true,
request_no: true,
purpose: true,
start_date: true,
end_date: true,
estimated_amount: true,
},
},
owner: {
select: { id: true, employee_no: true, name: true },
},
submitter: {
select: { id: true, employee_no: true, name: true },
},
items: {
orderBy: { expense_date: "desc" },
select: {
id: true,
expense_type_id: true,
expense_date: true,
amount: true,
description: true,
expense_type: {
select: {
id: true,
code: true,
name: true,
category: true,
enabled: true,
},
},
},
},
attachments: {
orderBy: { created_at: "asc" },
select: {
id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
reviewed_by: true,
reviewed_at: true,
review_note: true,
archived_by: true,
archived_at: true,
archive_note: true,
download_policy: true,
created_at: true,
uploader: {
select: {
id: true,
employee_no: true,
name: true,
},
},
},
},
invoice_pool_items: {
select: {
id: true,
status: true,
invoice_code: true,
invoice_number: true,
invoice_date: true,
seller_name: true,
buyer_name: true,
total_amount: true,
original_name: true,
mime_type: true,
file_size: true,
linked_item_id: true,
ocr_result: { select: { result_json: true } },
},
},
workflow_instances: {
where: { current_status: "pending" },
orderBy: { created_at: "desc" },
take: 1,
select: {
id: true,
current_step_id: true,
current_status: true,
context_data: true,
workflow_definition: {
select: {
definition_json: 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 controlMode = normalizeBudgetControlMode(fastify.config);
const budgetPool = await resolveApplicableBudgetPool(fastify.prisma, reimbursement);
const budgetHint = budgetPool
? buildBudgetHint(budgetPool, reimbursement.total_amount, controlMode)
: null;
const pendingInstance = reimbursement.workflow_instances[0] ?? null;
const pendingTask = pendingInstance?.workflow_tasks?.[0] ?? null;
const latestWorkflowInstance = await fastify.prisma.workflowInstance.findFirst({
where: {
entity_type: "reimbursement",
entity_id: reimbursementId,
},
orderBy: { created_at: "desc" },
select: {
id: true,
current_step_id: true,
current_status: true,
started_at: true,
completed_at: true,
created_at: true,
context_data: true,
workflow_definition: {
select: {
definition_json: true,
},
},
workflow_tasks: {
orderBy: [{ created_at: "asc" }, { id: "asc" }],
select: {
id: true,
step_id: true,
step_name: true,
assignee_id: true,
assignee_role: true,
status: true,
outcome: true,
comment: true,
created_at: true,
completed_at: true,
assignee_employee: { select: { name: true } },
completer_employee: { select: { name: true } },
},
},
},
});
const workflowHistory = pendingInstance
? await fastify.prisma.workflowHistory.findMany({
where: { instance_id: pendingInstance.id },
orderBy: { created_at: "asc" },
select: {
action: true,
payload: true,
},
})
: [];
const approvalHistoryRows = latestWorkflowInstance
? await fastify.prisma.workflowHistory.findMany({
where: { instance_id: latestWorkflowInstance.id },
orderBy: { created_at: "asc" },
select: {
id: true,
action: true,
comment: true,
payload: true,
created_at: true,
actor: {
select: {
name: true,
},
},
workflow_task: {
select: {
step_name: true,
},
},
},
})
: [];
const paymentBatchItem = await fastify.prisma.paymentBatchItem.findFirst({
where: {
entity_id: reimbursementId,
payment_batch: { entity_type: "reimbursement" },
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
select: {
id: true,
payment_batch: {
select: {
id: true,
status: true,
entity_type: true,
default_payment_channel: true,
payment_order_export_count: true,
created_at: true,
created_by: true,
reviewed_at: true,
reviewed_by: true,
executed_at: true,
executed_by: true,
cancelled_at: true,
cancelled_by: true,
cancelled_reason: true,
items: {
select: {
status: true,
payment_channel_override: true,
},
},
_count: {
select: { receipt_attachments: { where: { archived_at: null } } },
},
},
},
},
});
const paymentActorIds = Array.from(new Set([
paymentBatchItem?.payment_batch?.created_by,
paymentBatchItem?.payment_batch?.reviewed_by,
paymentBatchItem?.payment_batch?.executed_by,
paymentBatchItem?.payment_batch?.cancelled_by,
].filter(Boolean)));
const paymentActors = paymentActorIds.length
? await fastify.prisma.employee.findMany({
where: { id: { in: paymentActorIds } },
select: { id: true, name: true },
})
: [];
const paymentActorNames = new Map(paymentActors.map((actor) => [actor.id, actor.name]));
const canReadPaymentBatch = ["payment.batch.manage", "risk.review.manage", "payment.batch.review"]
.some((permissionCode) => fastify.hasPermission(request.employee, permissionCode));
const riskFlags = await fastify.prisma.entityRiskFlag.findMany({
where: {
entity_type: "reimbursement",
entity_id: reimbursementId,
status: "open",
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
const travelItems = reimbursement.items.filter((item) => item.expense_type?.category === "travel");
const isTravelReimbursement = Boolean(reimbursement.linked_travel_request_id || travelItems.length);
return sendData(reply, request, {
id: reimbursement.id,
formNumber: reimbursement.form_number,
submitterId: reimbursement.submitter_id,
ownerId: reimbursement.owner_id,
departmentId: reimbursement.department_id,
department: reimbursement.department
? {
id: reimbursement.department.id,
name: reimbursement.department.name,
}
: null,
createdAt: reimbursement.created_at?.toISOString?.() ?? null,
submittedAt: latestWorkflowInstance?.created_at?.toISOString?.() ?? null,
reimbursementDate: reimbursement.reimbursement_date.toISOString().slice(0, 10),
totalAmount: toMoneyString(reimbursement.total_amount),
status: reimbursement.status,
description: reimbursement.description,
payment: {
paymentVoucherNo: reimbursement.payment_voucher_no ?? null,
paymentDate: toDateOnlyString(reimbursement.payment_date),
paymentChannel: reimbursement.payment_channel ?? null,
paidAt: reimbursement.paid_at?.toISOString?.() ?? null,
paidBy: reimbursement.paid_by_employee
? {
id: reimbursement.paid_by_employee.id,
employeeNo: reimbursement.paid_by_employee.employee_no,
name: reimbursement.paid_by_employee.name,
}
: null,
},
projectId: reimbursement.project_id ?? null,
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
linkedTravelRequestId: reimbursement.linked_travel_request_id ?? null,
linkedTravelRequest: mapLinkedTravelRequestSummary(reimbursement.travel_request),
travelSummary: {
isTravel: isTravelReimbursement,
purpose: reimbursement.travel_purpose ?? null,
location: reimbursement.travel_location ?? null,
startDate: toDateOnlyString(reimbursement.travel_start_date),
endDate: toDateOnlyString(reimbursement.travel_end_date),
},
owner: reimbursement.owner,
submitter: reimbursement.submitter,
items: reimbursement.items.map((item) => ({
id: item.id,
expenseTypeId: item.expense_type_id,
expenseDate: item.expense_date?.toISOString?.()?.slice(0, 10) ?? null,
amount: toMoneyString(item.amount),
description: item.description,
expenseType: item.expense_type
? {
id: item.expense_type.id,
code: item.expense_type.code,
name: item.expense_type.name,
category: item.expense_type.category,
enabled: item.expense_type.enabled,
}
: null,
})),
attachments: reimbursement.attachments.map(mapReimbursementAttachment),
budgetHint,
invoices: reimbursement.invoice_pool_items.map((item) => ({
...mapInvoiceDocumentMeta(item),
id: item.id,
status: item.status,
invoiceCode: item.invoice_code,
invoiceNumber: item.invoice_number,
invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null,
sellerName: item.seller_name ?? null,
buyerName: item.buyer_name ?? null,
totalAmount: toMoneyString(item.total_amount),
originalName: item.original_name ?? null,
mimeType: item.mime_type ?? null,
fileSize: item.file_size ?? null,
linkedItemId: item.linked_item_id ?? null,
})),
pendingWorkflow: pendingInstance
? {
instanceId: pendingInstance.id,
stepId: pendingInstance.current_step_id,
task: pendingTask,
}
: null,
workflowProgress: pendingInstance ? buildWorkflowProgress(pendingInstance, workflowHistory) : null,
processingProgress: buildProcessingProgress({
reimbursement,
workflowInstance: latestWorkflowInstance,
paymentBatchItem,
paymentActorNames,
canReadPaymentBatch,
}),
approvalHistory: buildApprovalHistory(latestWorkflowInstance, approvalHistoryRows),
riskFlags: riskFlags.map(buildPersistedRiskFlag),
});
},
);
fastify.patch(
"/reimbursements/:id",
{
preHandler: fastify.requirePermission("expense.edit_own"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const body = parseReimbursementUpdateBody(request.body);
const updated = await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, department_id, status, travel_start_date, travel_end_date
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];
ensureApplicantOrAdmin(request, reimbursement);
await ensureCutoverAllowed({
prisma: tx,
config: fastify.config,
request,
domain: "expense",
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
});
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_edit_status" },
});
}
const nextDescription =
body.description === undefined ? undefined : body.description?.trim() || null;
const nextTravelStartDate = body.travelStartDate === undefined
? reimbursement.travel_start_date
: body.travelStartDate ? parseDateOnlyString(body.travelStartDate) : null;
const nextTravelEndDate = body.travelEndDate === undefined
? reimbursement.travel_end_date
: body.travelEndDate ? parseDateOnlyString(body.travelEndDate) : null;
if (nextTravelStartDate && nextTravelEndDate && nextTravelStartDate > nextTravelEndDate) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { travelEndDate: "invalid_date_range" },
});
}
return tx.reimbursementForm.update({
where: { id: reimbursementId },
data: {
...(body.reimbursementDate
? { reimbursement_date: parseDateOnlyString(body.reimbursementDate) }
: {}),
...(body.description !== undefined ? { description: nextDescription } : {}),
...(body.travelPurpose !== undefined ? { travel_purpose: body.travelPurpose?.trim() || null } : {}),
...(body.travelLocation !== undefined ? { travel_location: body.travelLocation?.trim() || null } : {}),
...(body.travelStartDate !== undefined ? { travel_start_date: nextTravelStartDate } : {}),
...(body.travelEndDate !== undefined ? { travel_end_date: nextTravelEndDate } : {}),
updated_by: request.employee.id,
},
select: {
id: true,
status: true,
reimbursement_date: true,
description: true,
total_amount: true,
travel_purpose: true,
travel_location: true,
travel_start_date: true,
travel_end_date: true,
},
});
});
return sendData(reply, request, {
id: updated.id,
status: updated.status,
reimbursementDate: updated.reimbursement_date.toISOString().slice(0, 10),
description: updated.description,
totalAmount: toMoneyString(updated.total_amount),
travelSummary: {
purpose: updated.travel_purpose,
location: updated.travel_location,
startDate: toDateOnlyString(updated.travel_start_date),
endDate: toDateOnlyString(updated.travel_end_date),
},
});
},
);
fastify.delete(
"/reimbursements/:id",
{
preHandler: fastify.requirePermission("expense.delete_own"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const result = 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];
ensureApplicantOrAdmin(request, reimbursement);
if (reimbursement.status !== "draft") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_delete_status" },
});
}
const unbound = await tx.invoicePool.updateMany({
where: {
linked_reimbursement_id: reimbursementId,
status: "bound",
},
data: {
status: "available",
linked_reimbursement_id: null,
linked_item_id: null,
updated_by: request.employee.id,
},
});
await tx.reimbursementForm.delete({
where: { id: reimbursementId },
});
return {
reimbursementId,
unboundInvoiceCount: unbound.count,
};
});
return sendData(reply, request, {
reimbursementId: result.reimbursementId,
outcome: "deleted",
unboundInvoiceCount: result.unboundInvoiceCount,
});
},
);
fastify.post(
"/reimbursements/:id/attachments",
{
preHandler: fastify.requirePermission("expense.edit_own"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
where: { id: 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,
});
}
ensureApplicantOrAdmin(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_attachment_edit_status" },
});
}
const files = [];
let governanceFields = {};
try {
for await (const part of request.parts({
limits: {
files: 5,
fileSize: fastify.config.maxUploadSizeBytes,
},
})) {
if (part.type === "field") {
governanceFields = parseMultipartGovernanceField(part, governanceFields);
continue;
}
if (part.type !== "file") {
continue;
}
const originalName = part.filename ?? "attachment";
const mimeType = part.mimetype;
if (!ATTACHMENT_MIME_ALLOWLIST.has(mimeType)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "unsupported_mime_type" },
});
}
const chunks = [];
let fileSize = 0;
for await (const chunk of part.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" },
});
}
files.push({
buffer: Buffer.concat(chunks),
originalName,
mimeType,
fileSize,
});
}
} catch (error) {
if (error?.code === "FST_FILES_LIMIT") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "too_many_files", maxFiles: 5 },
});
}
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 (!files.length) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "missing" },
});
}
const datePrefix = compactDateString(fastify.config.appTimezone);
const createdItems = [];
const governanceDefaults = {
governanceStatus: "approved",
sensitivityLevel: SENSITIVITY_LEVEL.internal,
attachmentType: ATTACHMENT_TYPE.supportingMaterial,
downloadPolicy: DOWNLOAD_POLICY.standard,
};
for (const file of files) {
const fileSha256 = sha256Hex(file.buffer);
const extension = path.extname(file.originalName ?? "").toLowerCase();
const objectKey = path.posix.join("reimbursement-attachments", datePrefix, `${crypto.randomUUID()}${extension}`);
const storagePath = await writeBuffer({
config: fastify.config,
key: objectKey,
buffer: file.buffer,
contentType: file.mimeType,
});
const created = await fastify.prisma.reimbursementAttachment.create({
data: {
reimbursement_id: reimbursementId,
storage_path: storagePath,
original_name: file.originalName,
mime_type: file.mimeType,
file_size: file.fileSize,
file_sha256: fileSha256,
uploaded_by: request.employee.id,
...governanceCreateData(governanceFields, governanceDefaults),
},
select: {
id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
reviewed_by: true,
reviewed_at: true,
review_note: true,
archived_by: true,
archived_at: true,
archive_note: true,
download_policy: true,
created_at: true,
uploader: {
select: {
id: true,
employee_no: true,
name: true,
},
},
},
});
createdItems.push(created);
}
const uploadedGovernance = governanceCreateData(governanceFields, governanceDefaults);
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "REIMBURSEMENT_ATTACHMENTS_UPLOADED",
targetType: "reimbursement",
targetId: reimbursementId,
extra: {
attachmentIds: createdItems.map((item) => item.id),
attachmentType: uploadedGovernance.attachment_type,
sensitivityLevel: uploadedGovernance.sensitivity_level,
},
}),
});
return sendData(reply, request, {
items: createdItems.map(mapReimbursementAttachment),
});
},
);
fastify.delete(
"/reimbursements/:id/attachments/:attachmentId",
{
preHandler: fastify.requirePermission("expense.edit_own"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const { reason } = parseReimbursementAttachmentDeleteBody(request.body);
const parsedAttachmentId = UUID_SCHEMA.safeParse(request.params?.attachmentId);
if (!parsedAttachmentId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { attachmentId: "invalid_uuid" },
});
}
const result = 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];
ensureApplicantOrAdmin(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_attachment_edit_status" },
});
}
const deleted = await tx.reimbursementAttachment.deleteMany({
where: {
id: parsedAttachmentId.data,
reimbursement_id: reimbursementId,
},
});
if (deleted.count === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "REIMBURSEMENT_ATTACHMENT_DELETED",
targetType: "reimbursement_attachment",
targetId: parsedAttachmentId.data,
extra: {
reimbursementId,
reason,
},
}),
});
return { attachmentId: parsedAttachmentId.data };
});
return sendData(reply, request, {
attachmentId: result.attachmentId,
outcome: "deleted",
});
},
);
fastify.get(
"/reimbursements/:id/attachments/:attachmentId/file",
{},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const parsedAttachmentId = UUID_SCHEMA.safeParse(request.params?.attachmentId);
if (!parsedAttachmentId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { attachmentId: "invalid_uuid" },
});
}
const previewToken = typeof request.query?.previewToken === "string" ? request.query.previewToken : "";
let previewGrant = null;
if (previewToken) {
const verified = verifyToken(previewToken, fastify.config.jwtSecret);
const payload = verified.ok ? verified.payload : null;
const parsedActorId = UUID_SCHEMA.safeParse(payload?.actorId);
if (
!verified.ok ||
payload?.purpose !== "wecom_preview" ||
payload?.reimbursementId !== reimbursementId ||
payload?.attachmentId !== parsedAttachmentId.data ||
typeof payload?.exp !== "number" ||
payload.exp <= Date.now() ||
!parsedActorId.success
) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
previewGrant = { actorId: parsedActorId.data };
} else {
await fastify.authenticate(request);
}
const attachment = await fastify.prisma.reimbursementAttachment.findFirst({
where: {
id: parsedAttachmentId.data,
reimbursement_id: reimbursementId,
},
select: {
id: true,
storage_path: true,
original_name: true,
mime_type: true,
file_size: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
download_policy: true,
reimbursement_form: {
select: {
owner_id: true,
submitter_id: true,
},
},
},
});
if (!attachment) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
let actorId;
let downloadReason;
if (previewGrant) {
actorId = previewGrant.actorId;
downloadReason = "企业微信在线预览";
} else {
ensureOwnOrCanViewAll(fastify, request, attachment.reimbursement_form);
actorId = request.employee.id;
downloadReason = sanitizeDownloadReason(request.query?.reason);
if (requiresDownloadReason(attachment) && !downloadReason) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { reason: "required" },
});
}
}
let stream = null;
try {
const opened = await openReadStream({
config: fastify.config,
storagePath: attachment.storage_path,
});
stream = opened.stream;
} catch (error) {
if (error instanceof StorageNotFoundError) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
throw error;
}
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId,
action: "REIMBURSEMENT_ATTACHMENT_DOWNLOADED",
targetType: "reimbursement",
targetId: reimbursementId,
extra: {
attachmentId: attachment.id,
attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.supportingMaterial,
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.internal,
governanceStatus: attachment.governance_status ?? "approved",
reason: downloadReason,
},
}),
});
reply.type(attachment.mime_type || "application/octet-stream");
reply.header("cache-control", "private, no-store");
reply.header("referrer-policy", "no-referrer");
reply.header(
"content-disposition",
buildAttachmentContentDisposition(attachment.original_name, { disposition: "inline" }),
);
return reply.send(stream);
},
);
fastify.post(
"/reimbursements/:id/attachments/:attachmentId/preview-url",
{ preHandler: fastify.authenticate },
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const parsedAttachmentId = UUID_SCHEMA.safeParse(request.params?.attachmentId);
const parsedBody = REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA.safeParse(request.body ?? {});
if (!parsedAttachmentId.success || !parsedBody.success) {
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400 });
}
const attachment = await fastify.prisma.reimbursementAttachment.findFirst({
where: { id: parsedAttachmentId.data, reimbursement_id: reimbursementId },
select: {
id: true,
original_name: true,
mime_type: true,
file_size: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
download_policy: true,
reimbursement_form: { select: { owner_id: true, submitter_id: true } },
},
});
if (!attachment) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
ensureOwnOrCanViewAll(fastify, request, attachment.reimbursement_form);
const reason = sanitizeDownloadReason(parsedBody.data.reason);
if (requiresDownloadReason(attachment) && !reason) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { reason: "required" },
});
}
const expiresAt = Date.now() + resolvePreviewUrlTtlMs(fastify.config);
const previewToken = signPayload({
purpose: "wecom_preview",
reimbursementId,
attachmentId: attachment.id,
actorId: request.employee.id,
exp: expiresAt,
}, fastify.config.jwtSecret);
const origin = resolveSignedFileOrigin(fastify.config, request);
const signedUrl = `${origin}/api/reimbursements/${reimbursementId}/attachments/${attachment.id}/file?previewToken=${encodeURIComponent(previewToken)}`;
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "REIMBURSEMENT_ATTACHMENT_PREVIEW_URL_ISSUED",
targetType: "reimbursement",
targetId: reimbursementId,
extra: {
attachmentId: attachment.id,
attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.supportingMaterial,
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.internal,
reason,
expiresAt,
},
}),
});
return sendData(reply, request, {
signedUrl,
expiresAt: new Date(expiresAt).toISOString(),
originalName: attachment.original_name,
mimeType: attachment.mime_type,
fileSize: attachment.file_size,
});
},
);
fastify.post(
"/reimbursements/:id/materials/preview-bundle",
{ preHandler: fastify.authenticate },
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const parsedBody = REIMBURSEMENT_MATERIAL_PREVIEW_BODY_SCHEMA.safeParse(request.body ?? {});
if (!parsedBody.success) {
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400 });
}
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: {
id: true,
owner_id: true,
submitter_id: true,
invoice_pool_items: {
orderBy: [{ created_at: "asc" }, { id: "asc" }],
select: {
id: true,
storage_path: true,
mime_type: true,
file_size: true,
file_sha256: true,
},
},
attachments: {
orderBy: [{ created_at: "asc" }, { id: "asc" }],
select: {
id: true,
storage_path: true,
mime_type: true,
file_size: true,
file_sha256: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
download_policy: true,
},
},
},
});
if (!reimbursement) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
ensureOwnOrCanViewAll(fastify, request, reimbursement);
const reason = sanitizeDownloadReason(parsedBody.data.reason);
if (reimbursement.attachments.some((attachment) => requiresDownloadReason(attachment)) && !reason) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { reason: "required" },
});
}
const materials = [
...reimbursement.invoice_pool_items.map((invoice, index) => ({
id: invoice.id,
materialType: "invoice",
materialIndex: index + 1,
storagePath: invoice.storage_path,
mimeType: invoice.mime_type,
fileSize: invoice.file_size,
fileSha256: invoice.file_sha256,
})),
...reimbursement.attachments.map((attachment, index) => ({
id: attachment.id,
materialType: "attachment",
materialIndex: index + 1,
storagePath: attachment.storage_path,
mimeType: attachment.mime_type,
fileSize: attachment.file_size,
fileSha256: attachment.file_sha256,
})),
];
if (materials.some((material) => !ATTACHMENT_MIME_ALLOWLIST.has(material.mimeType))) {
throw new AppError({
code: "MATERIAL_PREVIEW_001",
message: "部分材料格式暂不支持连续预览",
statusCode: 422,
});
}
const expiresAt = Date.now() + resolvePreviewUrlTtlMs(fastify.config);
let created;
try {
created = await materialPreviewStore.createBundle({
actorId: request.employee.id,
reimbursementId,
materials,
expiresAt,
readSource: (material) => readFileBuffer({
config: fastify.config,
storagePath: material.storagePath,
}),
});
} catch (error) {
throwMaterialPreviewAppError(error);
}
const origin = resolveSignedFileOrigin(fastify.config, request);
const previewToken = signPayload({
purpose: "reimbursement_material_preview",
reimbursementId,
bundleId: created.bundleId,
actorId: request.employee.id,
exp: expiresAt,
}, fastify.config.jwtSecret);
const signedUrl = `${origin}/api/reimbursements/${reimbursementId}/materials/preview/${created.bundleId}?previewToken=${encodeURIComponent(previewToken)}`;
try {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "REIMBURSEMENT_MATERIAL_PREVIEW_BUNDLE_ISSUED",
targetType: "reimbursement",
targetId: reimbursementId,
extra: {
invoiceCount: reimbursement.invoice_pool_items.length,
attachmentCount: reimbursement.attachments.length,
pageCount: created.bundle.pageCount,
reason,
expiresAt,
},
}),
});
} catch (error) {
materialPreviewStore.deleteBundle?.(created.bundleId);
throw error;
}
return sendData(reply, request, {
expiresAt: new Date(expiresAt).toISOString(),
signedUrl,
originalName: "reimbursement-materials-preview.pdf",
mimeType: "application/pdf",
fileSize: created.fileSize,
pageCount: created.bundle.pageCount,
});
},
);
fastify.get(
"/reimbursements/:id/materials/preview/:bundleId",
{},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const parsedBundleId = UUID_SCHEMA.safeParse(request.params?.bundleId);
const previewToken = typeof request.query?.previewToken === "string" ? request.query.previewToken : "";
const verified = previewToken ? verifyToken(previewToken, fastify.config.jwtSecret) : { ok: false };
const payload = verified.ok ? verified.payload : null;
const parsedActorId = UUID_SCHEMA.safeParse(payload?.actorId);
if (
!parsedBundleId.success ||
!verified.ok ||
payload?.purpose !== "reimbursement_material_preview" ||
payload?.reimbursementId !== reimbursementId ||
payload?.bundleId !== parsedBundleId.data ||
typeof payload?.exp !== "number" ||
payload.exp <= Date.now() ||
!parsedActorId.success
) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
const resolved = materialPreviewStore.getBundle({ bundleId: parsedBundleId.data });
if (
!resolved ||
resolved.bundle.reimbursementId !== reimbursementId ||
resolved.bundle.actorId !== parsedActorId.data ||
resolved.bundle.expiresAt !== payload.exp
) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: parsedActorId.data,
action: "REIMBURSEMENT_MATERIAL_PREVIEWED",
targetType: "reimbursement",
targetId: reimbursementId,
extra: {
pageCount: resolved.bundle.pageCount,
},
}),
});
reply.type("application/pdf");
reply.header("cache-control", "private, no-store");
reply.header("referrer-policy", "no-referrer");
reply.header("content-disposition", 'inline; filename="reimbursement-materials-preview.pdf"');
reply.header("content-length", String(resolved.artifact.buffer.length));
return reply.send(resolved.artifact.buffer);
},
);
fastify.post(
"/reimbursements/:id/items",
{
preHandler: fastify.requirePermission("expense.edit_own"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const body = parseItemBody(request.body);
const result = 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];
ensureApplicantOrAdmin(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_item_edit_status" },
});
}
const expenseType = await tx.expenseType.findUnique({
where: { id: body.expenseTypeId },
select: { id: true, enabled: true },
});
if (!expenseType || !expenseType.enabled) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { expenseTypeId: "invalid_or_disabled" },
});
}
const created = await tx.reimbursementItem.create({
data: {
reimbursement_id: reimbursementId,
expense_type_id: body.expenseTypeId,
expense_date: parseDateOnlyString(body.expenseDate),
amount: body.amount,
description: body.description,
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: {
id: true,
expense_type_id: true,
expense_date: true,
amount: true,
description: true,
},
});
const nextTotal = await recomputeTotalAmount(tx, reimbursementId, request.employee.id);
return {
item: created,
totalAmount: nextTotal,
};
});
return sendData(reply, request, {
item: {
id: result.item.id,
expenseTypeId: result.item.expense_type_id,
expenseDate: result.item.expense_date.toISOString().slice(0, 10),
amount: toMoneyString(result.item.amount),
description: result.item.description,
},
totalAmount: toMoneyString(result.totalAmount),
});
},
);
fastify.patch(
"/reimbursements/:id/items/:itemId",
{
preHandler: fastify.requirePermission("expense.edit_own"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const parsedItemId = UUID_SCHEMA.safeParse(request.params?.itemId);
if (!parsedItemId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { itemId: "invalid_uuid" },
});
}
const parsedBody = REIMBURSEMENT_ITEM_BODY_SCHEMA.partial().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) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { body: "empty" },
});
}
if (typeof parsedBody.data.amount === "string") {
const amountNumber = Number(parsedBody.data.amount);
if (!Number.isFinite(amountNumber) || amountNumber <= 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { amount: "must_be_positive" },
});
}
}
const result = 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];
ensureApplicantOrAdmin(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_item_edit_status" },
});
}
const item = await tx.reimbursementItem.findFirst({
where: {
id: parsedItemId.data,
reimbursement_id: reimbursementId,
},
select: { id: true },
});
if (!item) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
if (typeof parsedBody.data.expenseTypeId === "string") {
const expenseType = await tx.expenseType.findUnique({
where: { id: parsedBody.data.expenseTypeId },
select: { id: true, enabled: true },
});
if (!expenseType || !expenseType.enabled) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { expenseTypeId: "invalid_or_disabled" },
});
}
}
const updated = await tx.reimbursementItem.update({
where: { id: parsedItemId.data },
data: {
...(typeof parsedBody.data.expenseTypeId === "string"
? { expense_type_id: parsedBody.data.expenseTypeId }
: {}),
...(typeof parsedBody.data.expenseDate === "string"
? { expense_date: parseDateOnlyString(parsedBody.data.expenseDate) }
: {}),
...(typeof parsedBody.data.amount === "string"
? { amount: parsedBody.data.amount }
: {}),
...(typeof parsedBody.data.description === "string"
? { description: parsedBody.data.description.trim() }
: {}),
updated_by: request.employee.id,
},
select: {
id: true,
expense_type_id: true,
expense_date: true,
amount: true,
description: true,
},
});
const nextTotal = await recomputeTotalAmount(tx, reimbursementId, request.employee.id);
return {
item: updated,
totalAmount: nextTotal,
};
});
return sendData(reply, request, {
item: {
id: result.item.id,
expenseTypeId: result.item.expense_type_id,
expenseDate: result.item.expense_date.toISOString().slice(0, 10),
amount: toMoneyString(result.item.amount),
description: result.item.description,
},
totalAmount: toMoneyString(result.totalAmount),
});
},
);
fastify.delete(
"/reimbursements/:id/items/:itemId",
{
preHandler: fastify.requirePermission("expense.edit_own"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const parsedItemId = UUID_SCHEMA.safeParse(request.params?.itemId);
if (!parsedItemId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { itemId: "invalid_uuid" },
});
}
const result = 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];
ensureApplicantOrAdmin(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_item_edit_status" },
});
}
const deleted = await tx.reimbursementItem.deleteMany({
where: {
id: parsedItemId.data,
reimbursement_id: reimbursementId,
},
});
if (deleted.count === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const nextTotal = await recomputeTotalAmount(tx, reimbursementId, request.employee.id);
return { totalAmount: nextTotal };
});
return sendData(reply, request, {
outcome: "deleted",
totalAmount: toMoneyString(result.totalAmount),
});
},
);
fastify.post(
"/reimbursements/:id/precheck",
{
preHandler: fastify.requirePermission("expense.submit"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const precheck = await resolveEntityPrecheck({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
entityType: "reimbursement",
entityId: reimbursementId,
actorDepartmentId: request.employee.department_id ?? null,
});
return sendData(reply, request, precheck);
},
);
fastify.post(
"/reimbursements/:id/submit",
{
preHandler: fastify.requirePermission("expense.submit"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const submitResult = await fastify.prisma.$transaction(async (tx) => {
let budgetCheck = null;
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, department_id, reimbursement_date, status, form_number, total_amount,
linked_travel_request_id, travel_purpose, travel_location, travel_start_date, travel_end_date
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];
ensureApplicantOrAdmin(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_submit_status" },
});
}
const reimbursementItems = await tx.reimbursementItem.findMany({
where: { reimbursement_id: reimbursementId },
orderBy: [{ created_at: "asc" }, { id: "asc" }],
select: { id: true, amount: true },
});
if (!reimbursementItems.length) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { items: "at_least_one_required" },
});
}
const invalidAmountItems = reimbursementItems
.filter((item) => !Number.isFinite(Number(item.amount)) || Number(item.amount) <= 0)
.map((item) => item.id);
if (invalidAmountItems.length) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { items: "positive_amount_required", itemIds: invalidAmountItems },
});
}
reimbursement.total_amount = await recomputeTotalAmount(
tx,
reimbursementId,
request.employee.id,
);
ensureNonNegativeAmount(reimbursement.total_amount);
const travelItem = await tx.reimbursementItem.findFirst({
where: {
reimbursement_id: reimbursementId,
expense_type: { category: "travel" },
},
select: { id: true },
});
if (travelItem || reimbursement.linked_travel_request_id) {
const missingTravelFields = [
["travelPurpose", reimbursement.travel_purpose],
["travelLocation", reimbursement.travel_location],
["travelStartDate", reimbursement.travel_start_date],
["travelEndDate", reimbursement.travel_end_date],
].filter(([, value]) => !value).map(([field]) => field);
if (missingTravelFields.length) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { travelSummary: "incomplete", missingFields: missingTravelFields },
});
}
}
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 authoritativePrecheck = await resolveEntityPrecheck({
prisma: tx,
config: fastify.config,
logger: fastify.log,
entityType: "reimbursement",
entityId: reimbursementId,
actorDepartmentId: request.employee.department_id ?? null,
});
if (authoritativePrecheck.decision === "block") {
throw buildPrecheckBlockedError(authoritativePrecheck);
}
const standardViolations = listExceededStandardViolations(authoritativePrecheck);
const standardWarnings = listExceededStandardViolations(authoritativePrecheck, "warn");
const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, {
entityType: "reimbursement",
entityId: reimbursementId,
actorId: request.employee.id,
precheck: authoritativePrecheck,
});
const budgetPool = await resolveApplicableBudgetPool(tx, reimbursement);
if (budgetPool) {
const allocatedAmount = Number(budgetPool.allocated_amount ?? 0);
const usedAmount = Number(budgetPool.used_amount ?? 0);
const currentAmount = Number(reimbursement.total_amount ?? 0);
const nextUsed = usedAmount + currentAmount;
const usagePercent = allocatedAmount > 0 ? (nextUsed / allocatedAmount) * 100 : 0;
const budgetPrecheckGate = await evaluateFeatureFlag({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
const fallbackControlMode = normalizeBudgetControlMode(fastify.config);
const controlMode = budgetPrecheckGate.enabled
? authoritativePrecheck.matchedBudgetControlRule?.controlMode
?? authoritativePrecheck.budgetHint?.controlMode
?? fallbackControlMode
: fallbackControlMode;
const shouldBlockByLegacyMode = !budgetPrecheckGate.enabled && controlMode === "block" && nextUsed > allocatedAmount;
budgetCheck = {
budgetId: budgetPool.id,
budgetCode: budgetPool.code,
controlMode,
usagePercent: Number(usagePercent.toFixed(2)),
exceededThreshold: usagePercent >= budgetPool.warning_threshold_percent,
exceededBudget: nextUsed > allocatedAmount,
};
if (usagePercent >= budgetPool.warning_threshold_percent) {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "BUDGET_THRESHOLD_HIT",
targetType: "budget_pool",
targetId: budgetPool.id,
extra: {
reimbursementId,
formNumber: reimbursement.form_number ?? null,
usagePercent: Number(usagePercent.toFixed(2)),
threshold: budgetPool.warning_threshold_percent,
controlMode,
},
}),
});
}
if (shouldBlockByLegacyMode) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { budget: "exceeded_block_mode" },
});
}
await tx.budgetLedgerEntry.create({
data: {
budget_pool_id: budgetPool.id,
reimbursement_id: reimbursementId,
kind: shouldBlockByLegacyMode ? "blocked" : "submit",
amount: reimbursement.total_amount,
note: `submit:${reimbursement.form_number ?? reimbursementId}`,
created_by: request.employee.id,
},
});
await tx.budgetPool.update({
where: { id: budgetPool.id },
data: {
used_amount: nextUsed.toFixed(2),
updated_by: request.employee.id,
},
});
}
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 = await buildApprovalWorkflowSnapshot({
tx,
approvalRule,
ownerId: reimbursement.owner_id,
logger: fastify.log,
});
const { actionable, skipped } = await resolveActionableStep({
tx,
steps: workflowSteps,
startIndex: 0,
ownerId: reimbursement.owner_id,
logger: fastify.log,
blockOnUnresolvedAssignee: true,
});
if (!actionable) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
workflow: "no_actionable_approver",
},
});
}
let formNumber = reimbursement.form_number;
if (!formNumber) {
const issued = await issueDocumentNumber(tx, {
documentType: "reimbursement",
actorId: request.employee.id,
timeZone: fastify.config.appTimezone,
});
formNumber = issued.documentNumber;
}
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,
reimbursement_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,
standardViolations,
},
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,
},
},
});
if (reimbursement.submitter_id !== reimbursement.owner_id) {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "REIMBURSEMENT_PROXY_SUBMITTED",
targetType: "reimbursement_form",
targetId: reimbursementId,
extra: {
ownerEmployeeId: reimbursement.owner_id,
submitterEmployeeId: reimbursement.submitter_id,
formNumber,
},
}),
});
}
return {
response: {
reimbursementId,
formNumber,
workflowInstanceId: workflowInstance.id,
currentTaskId: workflowTask.id,
status: "reviewing",
budget: budgetCheck,
precheckDecision: authoritativePrecheck.decision,
standardWarnings,
openedRiskFlagCount: openedRiskFlags.length,
},
sideEffectsContext: {
reimbursementId,
formNumber,
workflowTaskId: workflowTask.id,
assigneeId: workflowTask.assignee_id,
assigneeRole: workflowTask.assignee_role,
actorId: request.employee.id,
},
};
});
await finalizeReimbursementSubmitSideEffects({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
...submitResult.sideEffectsContext,
});
return sendData(reply, request, submitResult.response);
},
);
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,
},
});
const unbound = await tx.invoicePool.updateMany({
where: {
linked_reimbursement_id: reimbursementId,
status: "bound",
},
data: {
status: "available",
linked_reimbursement_id: null,
linked_item_id: null,
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",
unboundInvoiceCount: unbound.count,
};
});
return sendData(reply, request, response);
},
);
fastify.post(
"/reimbursements/:id/link-project",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.projectLinking,
departmentId: request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { featureFlag: "project_linking_v1_disabled", reason: gate.reason } });
}
const reimbursementId = parseReimbursementId(request.params?.id);
const body = parseBody(z.object({ projectId: z.string().uuid().nullable() }).strict(), request.body);
const current = await fastify.prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: { id: true, owner_id: true, submitter_id: true, project_id: true, status: true },
});
if (!current) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
ensureOwnOrCanViewAll(fastify, request, current);
const updated = await fastify.prisma.reimbursementForm.update({
where: { id: reimbursementId },
data: { project_id: body.projectId ?? null, updated_by: request.employee.id },
select: { id: true, project_id: true, project: { select: { id: true, project_no: true, name: true } } },
});
return sendData(reply, request, {
item: {
reimbursementId: updated.id,
projectId: updated.project_id ?? null,
project: updated.project ? { id: updated.project.id, projectNo: updated.project.project_no, name: updated.project.name } : null,
},
});
},
);
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;
const emailTasks = [];
const dispatchTasks = [];
await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, status, form_number, owner_id, submitter_id, department_id,
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];
await ensureCutoverAllowed({
prisma: tx,
config: fastify.config,
request,
domain: "expense",
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
});
const paymentBatchGate = await evaluateFeatureFlag({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.paymentBatch,
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
strictDepartment: true,
});
const activePaymentBatchItem = await tx.paymentBatchItem.findFirst({
where: {
entity_id: reimbursementId,
payment_batch: {
entity_type: "reimbursement",
status: { not: "cancelled" },
},
},
select: { id: true },
});
if (paymentBatchGate.enabled || activePaymentBatchItem) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
payment: "payment_batch_v1_required",
...(activePaymentBatchItem ? { paymentBatch: "active_batch_exists" } : {}),
},
});
}
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,
},
});
const refreshed = await tx.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: {
id: true,
form_number: true,
owner_id: true,
submitter_id: true,
department_id: true,
total_amount: true,
payment_voucher_no: true,
payment_date: true,
paid_at: true,
owner: {
select: {
legal_entity: {
select: {
code: true,
},
},
},
},
submitter: {
select: {
legal_entity: {
select: {
code: true,
},
},
},
},
},
});
if (refreshed) {
const departmentId =
refreshed.department_id ?? reimbursement.department_id ?? request.employee.department_id ?? null;
const erpGate = await evaluateFeatureFlag({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.erpSync,
departmentId,
strictDepartment: true,
});
if (erpGate.enabled) {
await enqueueReimbursementJournalExport(tx, fastify.config, {
reimbursement: refreshed,
actorId: request.employee.id,
});
} else {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "ERP_SYNC_SKIPPED_BY_FEATURE_FLAG",
targetType: "reimbursement_form",
targetId: refreshed.id,
extra: {
featureFlag: FEATURE_FLAG_KEYS.erpSync,
departmentId,
reason: erpGate.reason,
},
}),
});
}
}
try {
const recipients = Array.from(
new Set([reimbursement.owner_id, reimbursement.submitter_id].filter(Boolean)),
);
const notifications = await createNotificationsWithRecords(tx, {
recipientEmployeeIds: recipients,
type: NOTIFICATION_TYPES.reimbursementPaid,
title: `【已付款】报销单 ${reimbursement.form_number ?? reimbursementId}`,
body: `报销单 ${reimbursement.form_number ?? reimbursementId} 已完成付款。`,
targetType: "reimbursement_form",
targetId: reimbursementId,
});
dispatchTasks.push({
notifications,
type: NOTIFICATION_TYPES.reimbursementPaid,
title: `【已付款】报销单 ${reimbursement.form_number ?? reimbursementId}`,
body: `报销单 ${reimbursement.form_number ?? reimbursementId} 已完成付款。`,
targetType: "reimbursement_form",
targetId: reimbursementId,
targetPath: `/reimbursements/${reimbursementId}`,
actorId: request.employee.id,
});
emailTasks.push({
type: NOTIFICATION_TYPES.reimbursementPaid,
recipientEmployeeIds: recipients,
subject: `【已付款】报销单 ${reimbursement.form_number ?? reimbursementId}`,
body: `报销单 ${reimbursement.form_number ?? reimbursementId} 已完成付款。`,
targetPath: `/reimbursements/${reimbursementId}`,
});
} catch (error) {
fastify.log.error(
{ err: error, reimbursementId },
"Failed to create reimbursement paid notifications",
);
}
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "REIMBURSEMENT_PAYMENT_MARKED",
targetType: "reimbursement_form",
targetId: reimbursementId,
extra: {
formNumber: reimbursement.form_number ?? null,
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,
paymentNote: payInput.paymentNote ?? null,
idempotent: reimbursement.status === "paid",
archivedInvoiceCount: archivedResult.count,
},
}),
});
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,
paymentNote: payInput.paymentNote ?? null,
idempotent: reimbursement.status === "paid",
},
};
});
for (const task of emailTasks) {
void sendNotificationEmailSafe({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
...task,
});
}
await flushNotificationDispatchTasks({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
tasks: dispatchTasks,
});
return sendData(reply, request, response);
},
);
}
module.exports = reimbursementRoutes;
module.exports.__testables = {
finalizeReimbursementSubmitSideEffects,
};