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 行删除
+124 -4
查看文件
@@ -12,6 +12,12 @@ const { parseWorkflowSteps, resolveActionableStep, toMoneyString } = require("..
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
const { issueDocumentNumber } = require("../services/documentSequence");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { ensureCutoverAllowed } = require("../services/cutover");
const {
buildPersistedRiskFlag,
buildPurchaseRequestPrecheck,
createRiskFlagsFromPrecheck,
} = require("../services/feeControl");
const {
PROCUREMENT_ERP_TARGETS,
enqueuePurchaseAccrualJournalExport,
@@ -306,6 +312,31 @@ function normalizeBudgetControlMode(config) {
return String(config.budgetControlMode ?? "warn").toLowerCase() === "block" ? "block" : "warn";
}
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,
},
});
}
function ensurePurchaseReadPermission(fastify, request) {
const canViewOwn = fastify.hasPermission(request.employee, "procurement.view_own");
const canViewAll =
@@ -1010,6 +1041,13 @@ async function purchaseRequestRoutes(fastify) {
preHandler: fastify.requirePermission("procurement.create"),
},
async (request, reply) => {
await ensureCutoverAllowed({
prisma: fastify.prisma,
config: fastify.config,
request,
domain: "procurement",
departmentId: request.employee.department_id ?? null,
});
const purchaseRequest = await fastify.prisma.$transaction(async (tx) => {
const created = await tx.purchaseRequest.create({
data: {
@@ -1078,7 +1116,19 @@ async function purchaseRequestRoutes(fastify) {
});
}
return sendData(reply, request, mapPurchaseRequestDetail(purchaseRequest, request));
const riskFlags = await fastify.prisma.entityRiskFlag.findMany({
where: {
entity_type: "purchase_request",
entity_id: purchaseRequestId,
status: "open",
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
return sendData(reply, request, {
...mapPurchaseRequestDetail(purchaseRequest, request),
riskFlags: riskFlags.map(buildPersistedRiskFlag),
});
},
);
@@ -1178,6 +1228,41 @@ async function purchaseRequestRoutes(fastify) {
},
);
fastify.post(
"/purchase-requests/:id/precheck",
{
preHandler: fastify.requirePermission("procurement.submit"),
},
async (request, reply) => {
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
const purchaseRequest = await fastify.prisma.purchaseRequest.findUnique({
where: { id: purchaseRequestId },
select: { id: true, department_id: true },
});
if (!purchaseRequest) {
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: purchaseRequest.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
return sendData(reply, request, buildDisabledPrecheck("purchase_request", purchaseRequestId));
}
const precheck = await buildPurchaseRequestPrecheck(fastify.prisma, {
purchaseRequestId,
config: fastify.config,
logger: fastify.log,
});
return sendData(reply, request, precheck);
},
);
fastify.post(
"/purchase-requests/:id/submit",
{
@@ -1197,6 +1282,15 @@ async function purchaseRequestRoutes(fastify) {
throw buildValidationError({ status: "only_draft_submittable" }, 409);
}
await ensureCutoverAllowed({
prisma: tx,
config: fastify.config,
request,
domain: "procurement",
departmentId: locked.department_id ?? request.employee.department_id ?? null,
legalEntityId: locked.legal_entity_id ?? null,
});
const purchaseRequest = await tx.purchaseRequest.findUnique({
where: { id: purchaseRequestId },
select: {
@@ -1244,6 +1338,30 @@ async function purchaseRequestRoutes(fastify) {
const documentCategory = await ensureDocumentCategoryAvailable(tx, purchaseRequest.document_category_id);
const totalAmount = await recomputePurchaseRequestTotal(tx, purchaseRequestId, request.employee.id);
const budgetPrecheckGate = await evaluateFeatureFlag({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: purchaseRequest.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
const authoritativePrecheck = budgetPrecheckGate.enabled
? await buildPurchaseRequestPrecheck(tx, {
purchaseRequestId,
config: fastify.config,
logger: fastify.log,
})
: buildDisabledPrecheck("purchase_request", purchaseRequestId);
if (authoritativePrecheck.decision === "block") {
throw buildPrecheckBlockedError(authoritativePrecheck);
}
const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, {
entityType: "purchase_request",
entityId: purchaseRequestId,
actorId: request.employee.id,
precheck: authoritativePrecheck,
});
const effectiveDate = new Date();
let resolvedBudgetPool = await resolveApplicableBudgetPoolByContext(tx, {
departmentId: purchaseRequest.department_id,
@@ -1263,12 +1381,14 @@ async function purchaseRequestRoutes(fastify) {
}
const warnings = [];
const budgetControlMode = normalizeBudgetControlMode(fastify.config);
const budgetControlMode = authoritativePrecheck?.budgetHint?.controlMode ?? normalizeBudgetControlMode(fastify.config);
if (!selectedBudgetPoolId) {
if (budgetControlMode === "block") {
if (!budgetPrecheckGate.enabled && budgetControlMode === "block") {
throw buildValidationError({ budget: "missing_budget_pool" }, 409);
}
warnings.push("missing_budget_pool");
if (!budgetPrecheckGate.enabled) {
warnings.push("missing_budget_pool");
}
}
const workflowDefinition = await tx.workflowDefinition.findFirst({