fix: 财务本人报销由总经理独立审批(09-04-reimbursement-state-gap-finance-view)
这个提交包含在:
@@ -1,4 +1,6 @@
|
||||
const ERROR_REASONS = [
|
||||
{"code":"reimbursement.finance_owner_ceo_unavailable","domain":"reimbursement","audience":"user","defaultMessage":"财务本人报销需要非申请人的总经理审批,请联系管理员核实审批配置。","recovery":"follow_guidance"},
|
||||
{"code":"reimbursement.finance_owner_ceo_required","domain":"reimbursement","audience":"user","defaultMessage":"财务本人报销只能由非申请人的在职总经理审批或接收转审,不能委托其他角色代批。","recovery":"follow_guidance"},
|
||||
{"code":"reimbursement.invoice_prefill_incomplete","domain":"reimbursement","audience":"user","defaultMessage":"票据日期或金额尚未完整识别,请先处理该票据;其他已识别票据可继续添加。","recovery":"follow_guidance"},
|
||||
{"code":"reimbursement.items_description_required","domain":"reimbursement","audience":"user","defaultMessage":"请补充明细的支出说明后再提交。","recovery":"follow_guidance"},
|
||||
{"code":"reimbursement.material_preview_limit","domain":"reimbursement","audience":"user","defaultMessage":"报销材料较多,暂时无法连续预览,请分批查看材料或联系管理员协助。","recovery":"view_individually"},
|
||||
|
||||
@@ -66,6 +66,7 @@ const {
|
||||
} = require("../services/businessAttachmentGovernance");
|
||||
const {
|
||||
buildApprovalWorkflowSnapshot,
|
||||
FINANCE_OWNER_APPROVAL_STEP_ID,
|
||||
getWorkflowStepsSnapshot,
|
||||
resolveActionableStep,
|
||||
resolveWorkflowStepDisplayName,
|
||||
@@ -955,6 +956,10 @@ function buildPendingWorkflowSummary(item, request, delegatedPrincipalIds) {
|
||||
if (!pendingInstance || !pendingTask) {
|
||||
return null;
|
||||
}
|
||||
if (pendingInstance.current_step_id === FINANCE_OWNER_APPROVAL_STEP_ID
|
||||
&& !(request.employee.roleCodes ?? []).includes("ceo")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isPendingWorkflowTaskActionable(request, pendingTask, delegatedPrincipalIds)) {
|
||||
return null;
|
||||
@@ -4349,6 +4354,8 @@ async function reimbursementRoutes(fastify) {
|
||||
tx,
|
||||
approvalRule,
|
||||
ownerId: reimbursement.owner_id,
|
||||
submitterId: reimbursement.submitter_id,
|
||||
entityType: "reimbursement",
|
||||
logger: fastify.log,
|
||||
});
|
||||
const { actionable, skipped } = await resolveActionableStep({
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
const { z } = require("zod");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { hasPermission, ROLE_PERMISSIONS, resolveEntityPermission } = require("../constants/permissions");
|
||||
const {
|
||||
BUSINESS_APPROVAL_ROLE_CODES,
|
||||
FINANCE_OWNER_APPROVAL_STEP_ID,
|
||||
getWorkflowStepsSnapshot,
|
||||
isBusinessApprovalRoleCode,
|
||||
resolveActionableStep,
|
||||
@@ -685,6 +687,11 @@ function listAllowedActionsForTask(request, task, actionable) {
|
||||
if (task.status !== "pending" || !actionable) {
|
||||
return [];
|
||||
}
|
||||
if (task.workflow_instance?.entity_type === "reimbursement"
|
||||
&& task.step_id === FINANCE_OWNER_APPROVAL_STEP_ID
|
||||
&& !request.employee.roleCodes.includes("ceo")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const actions = [];
|
||||
const approvePermission = resolveEntityPermission(task.workflow_instance?.entity_type, "approve");
|
||||
@@ -1066,10 +1073,32 @@ function ensureNoSelfApproval(actorIds, applicantIds) {
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureFinanceOwnerCeoActors(tx, workflowInstance, actorIds) {
|
||||
if (workflowInstance.entity_type !== "reimbursement"
|
||||
|| workflowInstance.current_step_id !== FINANCE_OWNER_APPROVAL_STEP_ID) {
|
||||
return;
|
||||
}
|
||||
const eligible = await tx.employeeRole.findMany({
|
||||
where: {
|
||||
employee_id: { in: actorIds },
|
||||
role_code: "ceo",
|
||||
employee: { status: "active", archived_at: null },
|
||||
},
|
||||
select: { employee_id: true },
|
||||
});
|
||||
if (actorIds.some((actorId) => !eligible.some((holder) => holder.employee_id === actorId))) {
|
||||
throw buildBusinessValidationError("reimbursement.finance_owner_ceo_required", {
|
||||
workflow: "finance_owner_ceo_required",
|
||||
}, 403);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureWorkflowApprovalAllowed(tx, workflowInstance, request, assignment) {
|
||||
ensureBusinessApproveRole(request.employee, workflowInstance.entity_type);
|
||||
const applicantIds = await loadWorkflowApplicantIds(tx, workflowInstance);
|
||||
ensureNoSelfApproval(uniq([request.employee.id, assignment?.principalId]), applicantIds);
|
||||
const actorIds = uniq([request.employee.id, assignment?.principalId]);
|
||||
ensureNoSelfApproval(actorIds, applicantIds);
|
||||
await ensureFinanceOwnerCeoActors(tx, workflowInstance, actorIds);
|
||||
return applicantIds;
|
||||
}
|
||||
|
||||
@@ -3197,6 +3226,7 @@ async function workflowRoutes(fastify) {
|
||||
const transferCount = await ensureTransferChainNotExceeded(tx, taskId);
|
||||
await ensureTransferTargetEligible(tx, toApproverId, approvePermission);
|
||||
ensureNoSelfApproval([toApproverId], applicantIds);
|
||||
await ensureFinanceOwnerCeoActors(tx, workflowInstance, [toApproverId]);
|
||||
|
||||
if (toApproverId === request.employee.id) {
|
||||
throw new AppError({
|
||||
|
||||
@@ -4,6 +4,7 @@ const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("./featureFlags");
|
||||
const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||
const {
|
||||
buildApprovalWorkflowSnapshot,
|
||||
buildFinanceOwnerApprovalSnapshot,
|
||||
parseWorkflowSteps,
|
||||
resolveActionableStep,
|
||||
} = require("../utils/workflow");
|
||||
@@ -92,6 +93,8 @@ async function buildReimbursementOperationalChecks(tx, { reimbursementId, config
|
||||
where: { id: reimbursementId },
|
||||
select: {
|
||||
id: true,
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
items: {
|
||||
orderBy: [{ created_at: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
@@ -106,6 +109,22 @@ async function buildReimbursementOperationalChecks(tx, { reimbursementId, config
|
||||
throw buildNotFoundError();
|
||||
}
|
||||
|
||||
const blockingReasons = [];
|
||||
try {
|
||||
await buildFinanceOwnerApprovalSnapshot({
|
||||
tx, ownerId: reimbursement.owner_id, submitterId: reimbursement.submitter_id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof AppError) || error.details?.reason !== "finance_owner_ceo_unavailable") {
|
||||
throw error;
|
||||
}
|
||||
blockingReasons.push(buildIssue(
|
||||
"finance_owner_ceo_unavailable",
|
||||
"缺少非申请人的总经理审批人",
|
||||
"财务本人报销需由非归属人且非提交人的在职总经理审批,请联系管理员核实审批配置。",
|
||||
));
|
||||
}
|
||||
|
||||
const [invoices, attachmentCount] = await Promise.all([
|
||||
tx.invoicePool.findMany({
|
||||
where: { linked_reimbursement_id: reimbursementId, status: "bound" },
|
||||
@@ -191,6 +210,7 @@ async function buildReimbursementOperationalChecks(tx, { reimbursementId, config
|
||||
timelinessChecks,
|
||||
assetHint: { required: assetItemCount > 0, itemCount: assetItemCount },
|
||||
warnings,
|
||||
blockingReasons,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -200,6 +220,7 @@ function mergeReimbursementOperationalChecks(precheck, operationalChecks) {
|
||||
evidenceSummary: operationalChecks.evidenceSummary,
|
||||
timelinessChecks: operationalChecks.timelinessChecks,
|
||||
assetHint: operationalChecks.assetHint,
|
||||
blockingReasons: [...precheck.blockingReasons, ...operationalChecks.blockingReasons],
|
||||
warnings: [...precheck.warnings, ...operationalChecks.warnings],
|
||||
};
|
||||
}
|
||||
@@ -659,6 +680,7 @@ async function buildReimbursementPrecheck(tx, { reimbursementId, config, logger
|
||||
total_amount: true,
|
||||
status: true,
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
items: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -729,6 +751,8 @@ async function buildReimbursementPrecheck(tx, { reimbursementId, config, logger
|
||||
tx,
|
||||
approvalRule,
|
||||
ownerId: reimbursement.owner_id,
|
||||
submitterId: reimbursement.submitter_id,
|
||||
entityType: "reimbursement",
|
||||
logger,
|
||||
});
|
||||
const resolved = await resolveActionableStep({
|
||||
|
||||
+37
-1
@@ -1,4 +1,7 @@
|
||||
const { AppError } = require("./AppError");
|
||||
const { buildBusinessValidationError } = require("./businessValidationError");
|
||||
|
||||
const FINANCE_OWNER_APPROVAL_STEP_ID = "approval_1_ceo_finance_owner";
|
||||
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const MAX_MANAGER_DEPTH = 10;
|
||||
@@ -199,8 +202,39 @@ function assertOwnerSelfFallbackRole(roleCode, ownerRoleCodes) {
|
||||
return roleCode;
|
||||
}
|
||||
|
||||
async function buildApprovalWorkflowSnapshot({ tx, approvalRule, ownerId, logger }) {
|
||||
async function buildFinanceOwnerApprovalSnapshot({ tx, ownerId, submitterId }) {
|
||||
const ownerRoleCodes = await loadOwnerRoleCodes(tx, ownerId);
|
||||
if (ownerRoleCodes?.has("finance")) {
|
||||
const applicantIds = [...new Set([ownerId, submitterId].filter(Boolean))];
|
||||
const ceo = await tx.employeeRole.findFirst({
|
||||
where: {
|
||||
role_code: "ceo",
|
||||
employee_id: { notIn: applicantIds },
|
||||
employee: { status: "active", archived_at: null },
|
||||
},
|
||||
select: { employee_id: true },
|
||||
});
|
||||
if (!ceo) {
|
||||
throw buildBusinessValidationError("reimbursement.finance_owner_ceo_unavailable", {
|
||||
workflow: "no_actionable_approver", reason: "finance_owner_ceo_unavailable",
|
||||
}, 409);
|
||||
}
|
||||
return [{
|
||||
id: FINANCE_OWNER_APPROVAL_STEP_ID,
|
||||
name: "总经理审批",
|
||||
type: "approval",
|
||||
assignee_rule: { type: "role", value: "ceo" },
|
||||
}];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function buildApprovalWorkflowSnapshot({ tx, approvalRule, ownerId, submitterId, entityType, logger }) {
|
||||
const rawSteps = buildWorkflowStepsFromApprovalRule(approvalRule);
|
||||
if (entityType === "reimbursement") {
|
||||
const financeOwnerSteps = await buildFinanceOwnerApprovalSnapshot({ tx, ownerId, submitterId });
|
||||
if (financeOwnerSteps) return financeOwnerSteps;
|
||||
}
|
||||
const hasManagerStep = rawSteps.some((step) => isDepartmentManagerRule(step.assignee_rule));
|
||||
|
||||
if (!hasManagerStep || !approvalRule.owner_self_fallback_role) {
|
||||
@@ -567,9 +601,11 @@ function toMoneyString(value) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FINANCE_OWNER_APPROVAL_STEP_ID,
|
||||
BUSINESS_APPROVAL_ROLE_CODES,
|
||||
OWNER_SELF_FALLBACK_ROLE_CODES,
|
||||
buildApprovalWorkflowSnapshot,
|
||||
buildFinanceOwnerApprovalSnapshot,
|
||||
buildWorkflowStepsFromApprovalRule,
|
||||
getWorkflowStepsSnapshot,
|
||||
isBusinessApprovalRoleCode,
|
||||
|
||||
在新工单中引用
屏蔽一个用户