feat(P25-1/P25-5/P26-1/P26-3/P24-1/P29-4): 落地内部OA够用版后端与试运行能力

关联 Issue: P25-1, P25-2, P25-3, P25-4, P25-5, P26-1, P26-3, P24-1, P24-2, P29-1, P29-2, P29-3, P29-4
这个提交包含在:
2026-03-21 18:03:20 +08:00
父节点 9b7cd818f7
当前提交 e39e41287e
共修改 45 个文件,包含 4571 行新增和 328 行删除
+123
查看文件
@@ -13,7 +13,13 @@ const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const { sendNotificationEmailSafe } = require("../services/email");
const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { ensureCutoverAllowed } = require("../services/cutover");
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
const {
buildPersistedRiskFlag,
buildReimbursementPrecheck,
createRiskFlagsFromPrecheck,
} = require("../services/feeControl");
const { issueDocumentNumber } = require("../services/documentSequence");
const {
buildWorkflowStepsFromApprovalRule,
@@ -385,6 +391,31 @@ function buildBudgetHint(budgetPool, reimbursementAmount, controlMode) {
};
}
function buildDisabledPrecheck(entityType, entityId) {
return {
entityType,
entityId,
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["功能未开启,沿用当前提交流程"],
};
}
function buildPrecheckBlockedError(precheck) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
precheck: precheck.blockingReasons,
},
});
}
async function reimbursementRoutes(fastify) {
const createBodySchema = z.object({
ownerEmployeeId: z.string().uuid().optional(),
@@ -399,6 +430,13 @@ async function reimbursementRoutes(fastify) {
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({
@@ -688,6 +726,14 @@ async function reimbursementRoutes(fastify) {
const pendingInstance = reimbursement.workflow_instances[0] ?? null;
const pendingTask = pendingInstance?.workflow_tasks?.[0] ?? null;
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,
@@ -732,6 +778,7 @@ async function reimbursementRoutes(fastify) {
task: pendingTask,
}
: null,
riskFlags: riskFlags.map(buildPersistedRiskFlag),
});
},
);
@@ -763,6 +810,13 @@ async function reimbursementRoutes(fastify) {
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({
@@ -1183,6 +1237,42 @@ async function reimbursementRoutes(fastify) {
},
);
fastify.post(
"/reimbursements/:id/precheck",
{
preHandler: fastify.requirePermission("expense.submit"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: { id: true, department_id: true },
});
if (!reimbursement) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
return sendData(reply, request, buildDisabledPrecheck("reimbursement", reimbursementId));
}
const precheck = await buildReimbursementPrecheck(fastify.prisma, {
reimbursementId,
config: fastify.config,
logger: fastify.log,
});
return sendData(reply, request, precheck);
},
);
fastify.post(
"/reimbursements/:id/submit",
{
@@ -1256,6 +1346,30 @@ async function reimbursementRoutes(fastify) {
});
}
const budgetPrecheckGate = await evaluateFeatureFlag({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
const authoritativePrecheck = budgetPrecheckGate.enabled
? await buildReimbursementPrecheck(tx, {
reimbursementId,
config: fastify.config,
logger: fastify.log,
})
: buildDisabledPrecheck("reimbursement", reimbursementId);
if (authoritativePrecheck.decision === "block") {
throw buildPrecheckBlockedError(authoritativePrecheck);
}
const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, {
entityType: "reimbursement",
entityId: reimbursementId,
actorId: request.employee.id,
precheck: authoritativePrecheck,
});
const budgetPool = await resolveApplicableBudgetPool(tx, reimbursement);
if (budgetPool) {
const allocatedAmount = Number(budgetPool.allocated_amount ?? 0);
@@ -1518,6 +1632,8 @@ async function reimbursementRoutes(fastify) {
currentTaskId: workflowTask.id,
status: "reviewing",
budget: budgetCheck,
precheckDecision: authoritativePrecheck.decision,
openedRiskFlagCount: openedRiskFlags.length,
};
});
@@ -1700,6 +1816,13 @@ async function reimbursementRoutes(fastify) {
}
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",