feat: 完成P6-P8后端能力与P9路由重构(V1-V2,D1,D3,B1-B2,B4,I1-I4,P9A2-P9A3)
这个提交包含在:
@@ -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)),
|
||||
|
||||
在新工单中引用
屏蔽一个用户