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
这个提交包含在:
@@ -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",
|
||||
|
||||
在新工单中引用
屏蔽一个用户