4841 行
155 KiB
JavaScript
4841 行
155 KiB
JavaScript
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,
|
||
};
|