feat: 完成P6-P8后端能力与P9路由重构(V1-V2,D1,D3,B1-B2,B4,I1-I4,P9A2-P9A3)

这个提交包含在:
2026-02-10 11:28:28 +08:00
父节点 bec21e1b38
当前提交 6bc09f557b
共修改 33 个文件,包含 3467 行新增和 13 行删除
+144
查看文件
@@ -9,6 +9,8 @@ const {
resolveRecipientsForAssignee,
} = require("../services/notifications");
const { sendNotificationEmailSafe } = require("../services/email");
const { enqueueIntegrationEvent } = require("../services/integrations/outbox");
const { buildErpPayload } = require("../services/integrations/erp");
const {
buildWorkflowStepsFromApprovalRule,
resolveActionableStep,
@@ -346,6 +348,50 @@ async function loadApprovalRuleForAmount(tx, totalAmount) {
return approvalRule;
}
async function resolveApplicableBudgetPool(tx, reimbursement) {
return tx.budgetPool.findFirst({
where: {
status: "active",
OR: [{ department_id: reimbursement.department_id }, { department_id: null }],
period_start: { lte: reimbursement.reimbursement_date },
period_end: { gte: reimbursement.reimbursement_date },
},
orderBy: [{ department_id: "desc" }, { created_at: "asc" }],
});
}
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,
};
}
async function reimbursementRoutes(fastify) {
const createBodySchema = z.object({
ownerEmployeeId: z.string().uuid().optional(),
@@ -638,6 +684,12 @@ async function reimbursementRoutes(fastify) {
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;
@@ -669,6 +721,7 @@ async function reimbursementRoutes(fastify) {
}
: null,
})),
budgetHint,
invoices: reimbursement.invoice_pool_items.map((item) => ({
id: item.id,
status: item.status,
@@ -1142,6 +1195,7 @@ async function reimbursementRoutes(fastify) {
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
let submitResult = null;
let budgetCheck = null;
const emailTasks = [];
await fastify.prisma.$transaction(async (tx) => {
@@ -1206,6 +1260,71 @@ async function reimbursementRoutes(fastify) {
});
}
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",
@@ -1405,6 +1524,7 @@ async function reimbursementRoutes(fastify) {
workflowInstanceId: workflowInstance.id,
currentTaskId: workflowTask.id,
status: "reviewing",
budget: budgetCheck,
};
});
@@ -1645,6 +1765,30 @@ async function reimbursementRoutes(fastify) {
},
});
const refreshed = await tx.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: {
id: true,
form_number: true,
owner_id: true,
submitter_id: true,
total_amount: true,
payment_voucher_no: true,
payment_date: true,
paid_at: true,
},
});
if (refreshed && fastify.config.erpSyncEnabled) {
await enqueueIntegrationEvent(tx, {
eventType: "erp.reimbursement.paid",
dedupeKey: refreshed.id,
payload: buildErpPayload({ reimbursement: refreshed }),
maxAttempts: fastify.config.integrationOutboxMaxAttempts,
actorId: request.employee.id,
});
}
try {
const recipients = Array.from(
new Set([reimbursement.owner_id, reimbursement.submitter_id].filter(Boolean)),