2782 行
86 KiB
JavaScript
2782 行
86 KiB
JavaScript
const { Prisma } = require("@prisma/client");
|
|
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, 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 { 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 {
|
|
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),
|
|
});
|
|
|
|
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 REIMBURSEMENT_STATUS_VALUES = ["draft", "reviewing", "approved", "paid", "rejected", "cancelled"];
|
|
const ROLE_LABELS = {
|
|
manager: "主管",
|
|
ceo: "CEO",
|
|
finance: "财务",
|
|
finance_mgr: "财务经理",
|
|
administration: "行政",
|
|
admin: "管理员",
|
|
hr: "HR",
|
|
};
|
|
|
|
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 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 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: "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,
|
|
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 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 buildPrecheckBlockedError(precheck) {
|
|
return new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: {
|
|
precheck: precheck.blockingReasons,
|
|
},
|
|
});
|
|
}
|
|
|
|
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 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 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/: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 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
|
|
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 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 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 = 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 = [];
|
|
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,
|
|
});
|
|
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",
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
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,
|
|
};
|