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

2314 行
71 KiB
JavaScript

const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { currentDateString, parseDateOnlyString } = require("../utils/date");
const { findActiveDelegationForDelegate } = require("../utils/delegation");
const {
createNotifications,
resolveRecipientsForAssignee,
} = require("../services/notifications");
const {
buildPendingTaskCopy,
buildWorkflowEntityMeta,
} = require("../services/notifications/workflowCopy");
const { enqueueImNotifications } = require("../services/notifications/imEnqueue");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const { sendNotificationEmailSafe } = require("../services/email");
const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { ensureCutoverAllowed } = require("../services/cutover");
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
const {
buildPersistedRiskFlag,
buildReimbursementPrecheck,
createRiskFlagsFromPrecheck,
} = require("../services/feeControl");
const { issueDocumentNumber } = require("../services/documentSequence");
const {
buildWorkflowStepsFromApprovalRule,
getWorkflowStepsSnapshot,
resolveActionableStep,
toMoneyString,
} = require("../utils/workflow");
const UUID_SCHEMA = z.string().uuid();
const PAY_BODY_SCHEMA = z.object({
paymentVoucherNo: z.string().trim().min(1).max(50).optional(),
paymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
paymentChannel: z.enum(["bank", "alipay", "wechat", "cash", "other"]).optional(),
});
const REIMBURSEMENT_LIST_QUERY_SCHEMA = z.object({
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
status: z
.enum(["draft", "reviewing", "approved", "paid", "rejected", "cancelled"])
.optional(),
scope: z.enum(["own", "all"]).optional(),
});
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(),
});
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),
});
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 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 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 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: "invalid" },
});
}
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,
};
}
function toDateOnlyString(dateValue) {
if (!dateValue) {
return null;
}
if (typeof dateValue === "string") {
return dateValue.slice(0, 10);
}
return new Date(dateValue).toISOString().slice(0, 10);
}
function ensureNonNegativeAmount(totalAmount) {
const asNumber = Number(totalAmount);
if (!Number.isFinite(asNumber)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { total_amount: "invalid" },
});
}
if (asNumber < 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { total_amount: "negative_not_allowed" },
});
}
}
async function 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,
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 buildDisabledPrecheck(entityType, entityId) {
return {
entityType,
entityId,
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["功能未开启,沿用当前提交流程"],
};
}
function buildPrecheckBlockedError(precheck) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
precheck: precheck.blockingReasons,
},
});
}
function buildSkippedNotificationResult(reason) {
return { skipped: true, reason };
}
function buildSkippedImResult(reason) {
return { queued: 0, duplicated: 0, matchedChannels: [], skippedReason: reason };
}
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 = createNotifications,
enqueueIm = enqueueImNotifications,
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"),
im: buildSkippedImResult("recipient_resolution_failed"),
email: buildSkippedEmailResult("recipient_resolution_failed"),
};
}
if (!recipientEmployeeIds.length) {
return {
recipientEmployeeIds: [],
notification: buildSkippedNotificationResult("no_recipients"),
im: buildSkippedImResult("no_recipients"),
email: buildSkippedEmailResult("no_recipients"),
};
}
let notification = buildSkippedNotificationResult("not_attempted");
try {
notification = await createAppNotifications(prisma, {
recipientEmployeeIds,
type: NOTIFICATION_TYPES.workflowTaskAssigned,
title,
body,
targetType: "workflow_task",
targetId: workflowTaskId,
});
} catch (error) {
logger?.error?.(
{
err: error,
requestId,
reimbursementId,
workflowTaskId,
recipients: recipientEmployeeIds.length,
},
"Failed to create reimbursement submit notifications",
);
notification = buildSkippedNotificationResult("notification_failed");
}
let im = buildSkippedImResult("not_attempted");
try {
im = await enqueueIm(prisma, {
config,
logger,
recipientEmployeeIds,
type: NOTIFICATION_TYPES.workflowTaskAssigned,
title,
body,
targetType: "workflow_task",
targetId: workflowTaskId,
targetPath,
entityType: "reimbursement",
entityId: reimbursementId,
actorId,
});
} catch (error) {
logger?.error?.(
{
err: error,
requestId,
reimbursementId,
workflowTaskId,
recipients: recipientEmployeeIds.length,
},
"Failed to enqueue reimbursement submit IM notifications",
);
im = buildSkippedImResult("enqueue_im_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,
im,
email,
};
}
async function reimbursementRoutes(fastify) {
const createBodySchema = z.object({
ownerEmployeeId: z.string().uuid().optional(),
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
description: z.string().max(500).optional(),
});
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 parsed = createBodySchema.safeParse(request.body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const reimbursementDate =
parsed.data.reimbursementDate ?? currentDateString(fastify.config.appTimezone);
const ownerEmployeeId = parsed.data.ownerEmployeeId ?? request.employee.id;
const isProxyCreate = ownerEmployeeId !== request.employee.id;
const reimbursement = await fastify.prisma.$transaction(async (tx) => {
let departmentId = request.employee.department_id;
let delegationGrantId = null;
if (isProxyCreate) {
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" },
});
}
delegationGrantId = delegation.id;
departmentId = ownerEmployee.department_id;
}
const created = await tx.reimbursementForm.create({
data: {
submitter_id: request.employee.id,
owner_id: ownerEmployeeId,
department_id: departmentId,
reimbursement_date: parseDateOnlyString(reimbursementDate),
description: parsed.data.description ?? null,
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: {
id: true,
status: true,
reimbursement_date: true,
description: true,
total_amount: true,
form_number: true,
owner_id: true,
submitter_id: true,
},
});
if (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,
},
}),
});
}
return created;
});
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,
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),
});
},
);
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 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 },
},
},
}),
fastify.prisma.reimbursementForm.count({ where }),
]);
return sendData(reply, request, {
page: query.page,
pageSize: query.pageSize,
total,
scope,
items: items.map(mapReimbursementSummary),
});
},
);
fastify.get(
"/reimbursements/:id",
{
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,
reimbursement_date: true,
total_amount: true,
status: true,
description: true,
project_id: 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 },
},
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,
},
},
},
},
invoice_pool_items: {
select: {
id: true,
status: true,
invoice_code: true,
invoice_number: true,
total_amount: true,
},
},
workflow_instances: {
where: { current_status: "pending" },
orderBy: { created_at: "desc" },
take: 1,
select: {
id: true,
current_step_id: true,
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 workflowHistory = pendingInstance
? await fastify.prisma.workflowHistory.findMany({
where: { instance_id: pendingInstance.id },
orderBy: { created_at: "asc" },
select: {
action: true,
payload: true,
},
})
: [];
const riskFlags = await fastify.prisma.entityRiskFlag.findMany({
where: {
entity_type: "reimbursement",
entity_id: reimbursementId,
status: "open",
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
return sendData(reply, request, {
id: reimbursement.id,
formNumber: reimbursement.form_number,
submitterId: reimbursement.submitter_id,
ownerId: reimbursement.owner_id,
departmentId: reimbursement.department_id,
reimbursementDate: reimbursement.reimbursement_date.toISOString().slice(0, 10),
totalAmount: toMoneyString(reimbursement.total_amount),
status: reimbursement.status,
description: reimbursement.description,
projectId: reimbursement.project_id ?? null,
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
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,
})),
budgetHint,
invoices: reimbursement.invoice_pool_items.map((item) => ({
id: item.id,
status: item.status,
invoiceCode: item.invoice_code,
invoiceNumber: item.invoice_number,
totalAmount: toMoneyString(item.total_amount),
})),
pendingWorkflow: pendingInstance
? {
instanceId: pendingInstance.id,
stepId: pendingInstance.current_step_id,
task: pendingTask,
}
: null,
workflowProgress: pendingInstance ? buildWorkflowProgress(pendingInstance, workflowHistory) : null,
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, 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);
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;
return tx.reimbursementForm.update({
where: { id: reimbursementId },
data: {
...(body.reimbursementDate
? { reimbursement_date: parseDateOnlyString(body.reimbursementDate) }
: {}),
...(body.description !== undefined ? { description: nextDescription } : {}),
updated_by: request.employee.id,
},
select: {
id: true,
status: true,
reimbursement_date: true,
description: true,
total_amount: 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),
});
},
);
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/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: "invalid" },
});
}
}
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 reimbursement = await fastify.prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: { id: true, department_id: true },
});
if (!reimbursement) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
return sendData(reply, request, buildDisabledPrecheck("reimbursement", reimbursementId));
}
const precheck = await buildReimbursementPrecheck(fastify.prisma, {
reimbursementId,
config: fastify.config,
logger: fastify.log,
});
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
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" },
});
}
ensureNonNegativeAmount(reimbursement.total_amount);
const activeInstance = await tx.workflowInstance.findFirst({
where: {
entity_type: "reimbursement",
entity_id: reimbursementId,
current_status: "pending",
},
select: { id: true },
});
if (activeInstance) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { submit: "already_in_review" },
});
}
const boundInvoiceCount = await tx.invoicePool.count({
where: {
linked_reimbursement_id: reimbursementId,
status: "bound",
},
});
if (boundInvoiceCount < 1) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { invoices: "bound_invoice_required" },
});
}
const 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 authoritativePrecheck = budgetPrecheckGate.enabled
? await buildReimbursementPrecheck(tx, {
reimbursementId,
config: fastify.config,
logger: fastify.log,
})
: buildDisabledPrecheck("reimbursement", reimbursementId);
if (authoritativePrecheck.decision === "block") {
throw buildPrecheckBlockedError(authoritativePrecheck);
}
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 controlMode = normalizeBudgetControlMode(fastify.config);
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 (controlMode === "block" && nextUsed > allocatedAmount) {
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: controlMode === "block" && nextUsed > allocatedAmount ? "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 = buildWorkflowStepsFromApprovalRule(approvalRule);
const { actionable, skipped } = await resolveActionableStep({
tx,
steps: workflowSteps,
startIndex: 0,
ownerId: reimbursement.owner_id,
logger: fastify.log,
});
if (!actionable) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
workflow: "no_actionable_approver",
},
});
}
let formNumber = reimbursement.form_number;
if (!formNumber) {
const 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,
},
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,
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,
},
});
await tx.reimbursementForm.update({
where: { id: reimbursementId },
data: {
status: "draft",
updated_by: request.employee.id,
},
});
await tx.workflowHistory.create({
data: {
instance_id: activeInstance.id,
actor_id: request.employee.id,
action: "withdraw",
comment: comment ?? null,
payload: {
reimbursement_id: reimbursementId,
},
},
});
response = {
reimbursementId,
outcome: "withdrawn",
workflowInstanceId: activeInstance.id,
reimbursementStatus: "draft",
};
});
return sendData(reply, request, response);
},
);
fastify.post(
"/reimbursements/:id/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 = [];
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,
});
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)),
);
await createNotifications(tx, {
recipientEmployeeIds: recipients,
type: NOTIFICATION_TYPES.reimbursementPaid,
title: `【已付款】报销单 ${reimbursement.form_number ?? reimbursementId}`,
body: `报销单 ${reimbursement.form_number ?? reimbursementId} 已完成付款。`,
targetType: "reimbursement_form",
targetId: reimbursementId,
});
await enqueueImNotifications(tx, {
config: fastify.config,
logger: fastify.log,
recipientEmployeeIds: recipients,
type: NOTIFICATION_TYPES.reimbursementPaid,
title: `【已付款】报销单 ${reimbursement.form_number ?? reimbursementId}`,
body: `报销单 ${reimbursement.form_number ?? reimbursementId} 已完成付款。`,
targetType: "reimbursement_form",
targetId: reimbursementId,
targetPath: `/reimbursements/${reimbursementId}`,
entityType: "reimbursement",
entityId: 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",
);
}
}
response = {
reimbursementId,
outcome: "paid",
reimbursementStatus: "paid",
// archivedInvoiceCount=0 is valid for idempotent pay or when no bound invoices remain.
archivedInvoiceCount: archivedResult.count,
payment: {
paymentVoucherNo:
reimbursement.status === "paid"
? reimbursement.payment_voucher_no ?? null
: payInput.paymentVoucherNo ?? null,
paymentDate:
reimbursement.status === "paid"
? existingPaymentDate
: payInput.paymentDate ?? null,
paymentChannel:
reimbursement.status === "paid"
? reimbursement.payment_channel ?? null
: payInput.paymentChannel ?? null,
paidAt:
reimbursement.status === "paid"
? reimbursement.paid_at?.toISOString?.() ?? null
: now.toISOString(),
paidBy:
reimbursement.status === "paid"
? reimbursement.paid_by ?? null
: request.employee.id,
idempotent: reimbursement.status === "paid",
},
};
});
for (const task of emailTasks) {
void sendNotificationEmailSafe({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
...task,
});
}
return sendData(reply, request, response);
},
);
}
module.exports = reimbursementRoutes;
module.exports.__testables = {
finalizeReimbursementSubmitSideEffects,
};