From cbb98f4469a00431139f792cf1e8561625603683 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Wed, 27 May 2026 14:35:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E9=80=80=E5=9B=9E=E6=B5=81=E7=A8=8B=E4=B8=8E=E6=8A=A5=E9=94=80?= =?UTF-8?q?=E7=94=B3=E8=AF=B7=E5=85=B3=E8=81=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端优化报销单访问策略和常量定义,增强退回原因和审批状态 流转,前端完善退回对话框和审批交互组件,新增报销申请关联 模型,优化文档中心行数据和审批收件箱工具函数,增强引导 流程和会话模型,补充单元测试覆盖。 --- .../services/expense_claim_access_policy.py | 43 +-- .../app/services/expense_claim_constants.py | 5 + server/src/app/services/expense_claims.py | 25 +- server/tests/test_expense_claim_service.py | 222 ++++++++++++- .../views/travel-request-detail-view.css | 136 +++++--- .../components/shared/ReturnReasonDialog.vue | 101 +++++- .../travel/TravelRequestApprovalDialog.vue | 103 +++++- .../travel/TravelRequestReturnDialog.vue | 4 +- web/src/composables/useRequests.js | 100 +++++- web/src/utils/accessControl.js | 66 +++- web/src/utils/applicationApproval.js | 95 ++++-- web/src/utils/approvalInbox.js | 17 +- web/src/utils/documentCenterRows.js | 19 ++ web/src/views/DocumentsCenterView.vue | 17 +- web/src/views/TravelRequestDetailView.vue | 55 ++-- .../scripts/TravelReimbursementCreateView.js | 5 +- .../views/scripts/TravelRequestDetailView.js | 77 +++-- ...travelReimbursementApplicationLinkModel.js | 294 ++++++++++++++++++ .../travelReimbursementConversationModel.js | 3 + .../travelReimbursementGuidedFlowModel.js | 92 +++++- .../useTravelReimbursementGuidedFlow.js | 172 +++++++++- web/tests/accessControl.test.mjs | 30 +- web/tests/application-approval-info.test.mjs | 54 ++++ .../document-center-archived-scope.test.mjs | 33 ++ .../documents-center-status-filter.test.mjs | 8 +- .../expense-application-fast-preview.test.mjs | 27 +- web/tests/requestProgressSteps.test.mjs | 61 +++- .../travel-reimbursement-guided-flow.test.mjs | 99 +++++- ...el-request-detail-leader-approval.test.mjs | 68 +++- ...travel-request-detail-risk-advice.test.mjs | 13 +- 30 files changed, 1794 insertions(+), 250 deletions(-) create mode 100644 web/src/views/scripts/travelReimbursementApplicationLinkModel.js diff --git a/server/src/app/services/expense_claim_access_policy.py b/server/src/app/services/expense_claim_access_policy.py index 01f1bd4..0d6e0e0 100644 --- a/server/src/app/services/expense_claim_access_policy.py +++ b/server/src/app/services/expense_claim_access_policy.py @@ -95,35 +95,19 @@ class ExpenseClaimAccessPolicy: return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", "归档入账", "completed"} def can_return_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool: - if self.has_privileged_claim_access(current_user): - return True - - role_codes = self.normalize_role_codes(current_user) - if not (role_codes & APPROVAL_VISIBLE_CLAIM_ROLE_CODES): - return False - if str(claim.status or "").strip().lower() != "submitted": - return False - if str(claim.approval_stage or "").strip() != "直属领导审批": + normalized_status = str(claim.status or "").strip().lower() + if normalized_status != "submitted": return False - current_employee = self.resolve_current_employee(current_user) - if current_employee is not None and str(claim.employee_id or "").strip() == current_employee.id: - return False - - claim_employee = claim.employee - if current_employee is not None and claim_employee is not None: - if claim_employee.manager_id == current_employee.id: - return True - if claim_employee.manager is not None and claim_employee.manager.id == current_employee.id: - return True - - approver_name = str( - current_employee.name if current_employee is not None and current_employee.name else current_user.name or "" - ).strip() - if not approver_name: - return False - - return self.resolve_claim_manager_name(claim) == approver_name + stage = str(claim.approval_stage or "").strip() + if stage == "直属领导审批": + return self.is_current_direct_manager_approver(current_user, claim) + if stage == "财务审批": + return self.has_privileged_claim_access(current_user) and not self.is_claim_owned_by_current_user( + claim, + current_user, + ) + return False def can_approve_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool: stage = str(claim.approval_stage or "").strip() @@ -131,7 +115,10 @@ class ExpenseClaimAccessPolicy: return self.is_current_direct_manager_approver(current_user, claim) if stage == "财务审批": role_codes = self.normalize_role_codes(current_user) - return current_user.is_admin or "finance" in role_codes + return ( + (current_user.is_admin or "finance" in role_codes) + and not self.is_claim_owned_by_current_user(claim, current_user) + ) return False def is_current_direct_manager_approver(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool: diff --git a/server/src/app/services/expense_claim_constants.py b/server/src/app/services/expense_claim_constants.py index bc24453..3f5fba4 100644 --- a/server/src/app/services/expense_claim_constants.py +++ b/server/src/app/services/expense_claim_constants.py @@ -220,6 +220,11 @@ RETURN_REASON_OPTIONS = { "business_explanation": "业务事由/地点/人员信息不完整", "duplicate_or_abnormal": "疑似重复或异常票据", "approval_question": "审批人需要补充说明", + "application_info_incomplete": "申请信息不完整", + "application_business_need_unclear": "业务必要性说明不足", + "application_budget_basis_missing": "预算测算依据不足", + "application_policy_mismatch": "制度口径不匹配", + "application_attachment_needed": "前置材料需补充", } MAX_CLAIM_NO_RETRY_ATTEMPTS = 3 DOCUMENT_DATE_PATTERN = re.compile( diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index 611d7af..274930f 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -578,10 +578,26 @@ class ExpenseClaimService( previous_status = str(claim.status or "").strip() previous_stage = str(claim.approval_stage or "").strip() or "未标记审批环节" previous_stage_key = self._normalize_return_stage_key(previous_stage) + is_application_claim = self._is_expense_application_claim(claim) + is_direct_manager_return = previous_stage_key == "direct_manager" + return_event_type = ( + "expense_application_return" + if is_application_claim and is_direct_manager_return + else "expense_claim_return" + ) + return_label = ( + "领导退回" + if is_application_claim and is_direct_manager_return + else "人工退回" + ) return_reason = str(reason or "").strip() reason_code_payload = self._normalize_return_reason_code_payload(reason_codes) normalized_reason_codes = reason_code_payload["reason_codes"] unknown_reason_codes = reason_code_payload["unknown_reason_codes"] + if is_application_claim and is_direct_manager_return and not any( + code.startswith("application_") for code in normalized_reason_codes + ): + raise ValueError("申请单退回必须选择至少一个退单类型。") risk_points = [RETURN_REASON_OPTIONS[code] for code in normalized_reason_codes] existing_return_flags = self._collect_return_flags(claim.risk_flags_json) return_count = len(existing_return_flags) + 1 @@ -600,12 +616,17 @@ class ExpenseClaimService( message = return_reason or self._build_default_return_message(operator=operator, risk_points=risk_points) return_flag = { "source": "manual_return", - "event_type": "expense_claim_return", + "event_type": return_event_type, "return_event_id": str(uuid.uuid4()), "severity": "medium", - "label": "人工退回", + "label": return_label, + "node_key": "returned", + "node_label": "退回", + "approval_node": "退回", "message": message, "reason": return_reason, + "opinion": message, + "leader_opinion": message if is_application_claim and is_direct_manager_return else "", "reason_codes": normalized_reason_codes, "risk_points": risk_points, "operator": operator, diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index dd5b6c8..87b9526 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -2791,7 +2791,7 @@ def test_finance_can_return_but_cannot_delete_submitted_claim() -> None: occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC), submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC), status="submitted", - approval_stage="直属领导审批", + approval_stage="财务审批", risk_flags_json=[], ) db.add(claim) @@ -3050,6 +3050,63 @@ def test_direct_manager_can_approve_subordinate_claim_to_finance_review() -> Non ) +def test_manager_cannot_operate_own_claim_submitted_to_direct_manager() -> None: + current_user = CurrentUserContext( + username="manager-own-approval@example.com", + name="李经理", + role_codes=["manager"], + is_admin=False, + ) + + with build_session() as db: + superior = Employee( + employee_no="E8112", + name="王总", + email="superior-own-approval@example.com", + ) + manager = Employee( + employee_no="E8113", + name="李经理", + email="manager-own-approval@example.com", + manager=superior, + ) + db.add_all([superior, manager]) + db.flush() + claim = ExpenseClaim( + claim_no="EXP-APP-SELF-201", + employee_id=manager.id, + employee_name="李经理", + department_name="市场部", + project_code="PRJ-A", + expense_type="transport", + reason="交通报销", + location="上海", + amount=Decimal("66.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC), + status="submitted", + approval_stage="直属领导审批", + risk_flags_json=[], + ) + db.add(claim) + db.commit() + claim_id = claim.id + service = ExpenseClaimService(db) + + with pytest.raises(ValueError, match="当前直属领导审批人"): + service.approve_claim(claim_id, current_user, opinion="同意") + + with pytest.raises(ValueError, match="当前审批人"): + service.return_claim(claim_id, current_user, reason="退回") + + db.refresh(claim) + assert claim.status == "submitted" + assert claim.approval_stage == "直属领导审批" + assert claim.risk_flags_json == [] + + def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch: pytest.MonkeyPatch) -> None: current_user = CurrentUserContext( username="application-owner@example.com", @@ -3359,6 +3416,92 @@ def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() - ) +def test_direct_manager_return_application_claim_records_return_node_and_opinion() -> None: + manager_user = CurrentUserContext( + username="manager-application-return@example.com", + name="李经理", + role_codes=["manager"], + is_admin=False, + ) + + with build_session() as db: + manager = Employee( + employee_no="E8114", + name="李经理", + email="manager-application-return@example.com", + ) + employee = Employee( + employee_no="E8115", + name="张三", + email="zhangsan-application-return@example.com", + manager=manager, + ) + db.add_all([manager, employee]) + db.flush() + claim = ExpenseClaim( + claim_no="APP-20260525-RETURN", + employee_id=employee.id, + employee_name="张三", + department_name="交付部", + project_code="PRJ-A", + expense_type="travel_application", + reason="支撑国网服务器上线部署", + location="上海", + amount=Decimal("12000.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC), + status="submitted", + approval_stage="直属领导审批", + risk_flags_json=[], + ) + db.add(claim) + db.commit() + + with pytest.raises(ValueError, match="退单类型"): + ExpenseClaimService(db).return_claim( + claim.id, + manager_user, + reason="预算说明不够清楚,请补充项目必要性。", + ) + db.refresh(claim) + assert claim.status == "submitted" + assert claim.risk_flags_json == [] + + returned = ExpenseClaimService(db).return_claim( + claim.id, + manager_user, + reason="预算说明不够清楚,请补充项目必要性。", + reason_codes=["application_business_need_unclear", "application_budget_basis_missing"], + ) + + assert returned is not None + assert returned.status == "returned" + assert returned.approval_stage == "待提交" + return_event = next( + flag + for flag in returned.risk_flags_json + if isinstance(flag, dict) and flag.get("event_type") == "expense_application_return" + ) + assert return_event["label"] == "领导退回" + assert return_event["node_key"] == "returned" + assert return_event["node_label"] == "退回" + assert return_event["approval_node"] == "退回" + assert return_event["operator"] == "李经理" + assert return_event["opinion"] == "预算说明不够清楚,请补充项目必要性。" + assert return_event["leader_opinion"] == "预算说明不够清楚,请补充项目必要性。" + assert return_event["return_stage"] == "直属领导审批" + assert return_event["return_stage_key"] == "direct_manager" + assert return_event["reason_codes"] == [ + "application_business_need_unclear", + "application_budget_basis_missing", + ] + assert return_event["risk_points"] == ["业务必要性说明不足", "预算测算依据不足"] + assert return_event["next_status"] == "returned" + assert return_event["next_approval_stage"] == "待提交" + + def test_application_approval_transfers_budget_reservation_to_reimbursement_draft() -> None: owner = CurrentUserContext( username="application-budget-owner-approve@example.com", @@ -3554,6 +3697,55 @@ def test_finance_approve_reimbursement_consumes_budget_reservation() -> None: ) +def test_finance_cannot_operate_own_claim_in_finance_stage() -> None: + current_user = CurrentUserContext( + username="finance-own-approval@example.com", + name="财务", + role_codes=["finance"], + is_admin=False, + ) + + with build_session() as db: + employee = Employee( + employee_no="E8124", + name="财务", + email="finance-own-approval@example.com", + ) + db.add(employee) + db.flush() + claim = ExpenseClaim( + claim_no="RE-20260525-FINANCE-SELF", + employee_id=employee.id, + employee_name="财务", + department_name="财务部", + project_code=None, + expense_type="travel", + reason="差旅报销", + location="上海", + amount=Decimal("800.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC), + status="submitted", + approval_stage="财务审批", + risk_flags_json=[], + ) + db.add(claim) + db.commit() + service = ExpenseClaimService(db) + + with pytest.raises(ValueError, match="财务终审"): + service.approve_claim(claim.id, current_user, opinion="同意入账") + with pytest.raises(ValueError, match="可以退回"): + service.return_claim(claim.id, current_user, reason="退回") + + db.refresh(claim) + assert claim.status == "submitted" + assert claim.approval_stage == "财务审批" + assert claim.risk_flags_json == [] + + def test_finance_can_approve_claim_to_archive_stage() -> None: current_user = CurrentUserContext( username="finance-approve@example.com", @@ -3655,7 +3847,13 @@ def test_return_claim_rejects_already_returned_claim_without_adding_event() -> N def test_return_claim_records_each_return_event_with_stage_reason_and_counts() -> None: - current_user = CurrentUserContext( + manager_user = CurrentUserContext( + username="manager-return-count@example.com", + name="李经理", + role_codes=["manager"], + is_admin=False, + ) + finance_user = CurrentUserContext( username="finance-return@example.com", name="财务复核", role_codes=["finance"], @@ -3663,8 +3861,22 @@ def test_return_claim_records_each_return_event_with_stage_reason_and_counts() - ) with build_session() as db: + manager = Employee( + employee_no="E8130", + name="李经理", + email="manager-return-count@example.com", + ) + employee = Employee( + employee_no="E8131", + name="张三", + email="zhangsan-return-count@example.com", + manager=manager, + ) + db.add_all([manager, employee]) + db.flush() claim = ExpenseClaim( claim_no="EXP-RET-301", + employee_id=employee.id, employee_name="张三", department_name="市场部", project_code="PRJ-A", @@ -3687,7 +3899,7 @@ def test_return_claim_records_each_return_event_with_stage_reason_and_counts() - service = ExpenseClaimService(db) first_returned = service.return_claim( claim_id, - current_user, + manager_user, reason="发票金额与明细金额不一致,请重新核对。", reason_codes=["invoice_mismatch", "business_explanation"], ) @@ -3700,7 +3912,7 @@ def test_return_claim_records_each_return_event_with_stage_reason_and_counts() - second_returned = service.return_claim( claim_id, - current_user, + finance_user, reason="超标说明仍不完整,请补充制度例外依据。", reason_codes=["over_policy"], ) @@ -3718,7 +3930,7 @@ def test_return_claim_records_each_return_event_with_stage_reason_and_counts() - assert return_events[0]["reason_codes"] == ["invoice_mismatch", "business_explanation"] assert return_events[0]["risk_points"] == ["票据类型/金额与明细不一致", "业务事由/地点/人员信息不完整"] assert return_events[0]["reason"] == "发票金额与明细金额不一致,请重新核对。" - assert return_events[0]["operator_role_codes"] == ["finance"] + assert return_events[0]["operator_role_codes"] == ["manager"] assert return_events[1]["return_count"] == 2 assert return_events[1]["stage_return_count"] == 1 assert return_events[1]["return_stage"] == "财务审批" diff --git a/web/src/assets/styles/views/travel-request-detail-view.css b/web/src/assets/styles/views/travel-request-detail-view.css index d89c919..469de7d 100644 --- a/web/src/assets/styles/views/travel-request-detail-view.css +++ b/web/src/assets/styles/views/travel-request-detail-view.css @@ -691,41 +691,6 @@ gap: 8px; } -.leader-approval-card { - border-color: rgba(var(--theme-primary-rgb), .18); - background: linear-gradient(180deg, #ffffff 0%, var(--theme-primary-soft) 100%); -} - -.leader-approval-card textarea { - min-height: 96px; - background: #fff; - color: #0f172a; -} - -.leader-approval-card textarea:focus { - outline: 0; - border-color: rgba(var(--theme-primary-rgb), .5); - box-shadow: 0 0 0 3px var(--theme-focus-ring); -} - -.leader-opinion-meta { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-top: 8px; - color: #64748b; - font-size: 12px; - line-height: 1.5; -} - -.leader-opinion-meta strong { - flex: 0 0 auto; - color: var(--theme-primary-active); - font-weight: 850; -} - - .application-leader-opinion { display: grid; gap: 10px; @@ -763,14 +728,103 @@ font-size: 14px; } -.inline-leader-opinion { - padding: 0; - border: 0; - background: transparent; +.application-leader-opinion-timeline { + position: relative; + display: grid; + gap: 10px; + padding-left: 18px; } -.application-leader-opinion-display { - min-height: 64px; +.application-leader-opinion-timeline::before { + content: ""; + position: absolute; + top: 6px; + bottom: 6px; + left: 5px; + width: 1px; + background: #dbe4ee; +} + +.application-leader-opinion-event { + position: relative; + display: grid; + gap: 8px; + padding: 12px 14px; + border: 1px solid #dbe4ee; + border-radius: 8px; + background: #ffffff; +} + +.application-leader-opinion-event::before { + content: ""; + position: absolute; + top: 17px; + left: -18px; + width: 9px; + height: 9px; + border: 2px solid #ffffff; + border-radius: 999px; + background: var(--theme-primary, #3a7ca5); + box-shadow: 0 0 0 1px rgba(var(--theme-primary-rgb, 58, 124, 165), .34); +} + +.application-leader-opinion-event.danger::before { + background: #dc2626; + box-shadow: 0 0 0 1px rgba(220, 38, 38, .32); +} + +.application-leader-opinion-event.success::before { + background: #16a34a; + box-shadow: 0 0 0 1px rgba(22, 163, 74, .32); +} + +.application-leader-opinion-event-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.application-leader-opinion-event-head span { + display: inline-flex; + align-items: center; + gap: 6px; + color: #0f172a; + font-size: 14px; + font-weight: 850; +} + +.application-leader-opinion-event-head i { + color: var(--theme-primary-active, #255b7d); + font-size: 16px; +} + +.application-leader-opinion-event.danger .application-leader-opinion-event-head i { + color: #dc2626; +} + +.application-leader-opinion-event.success .application-leader-opinion-event-head i { + color: #16a34a; +} + +.application-leader-opinion-event-head time, +.application-leader-opinion-event footer { + color: #64748b; + font-size: 12px; + font-weight: 720; +} + +.application-leader-opinion-event p { + margin: 0; + color: #334155; + font-size: 13px; + line-height: 1.65; +} + +.application-leader-opinion-event footer { + display: flex; + flex-wrap: wrap; + gap: 8px; } .detail-expense-table { diff --git a/web/src/components/shared/ReturnReasonDialog.vue b/web/src/components/shared/ReturnReasonDialog.vue index cfea680..01c3c5d 100644 --- a/web/src/components/shared/ReturnReasonDialog.vue +++ b/web/src/components/shared/ReturnReasonDialog.vue @@ -1,7 +1,7 @@ @@ -53,10 +69,83 @@ const props = defineProps({ summaryLabel: { type: String, required: true }, nextStage: { type: String, required: true }, opinionTitle: { type: String, required: true }, - opinion: { type: String, default: '' } + opinion: { type: String, default: '' }, + opinionPlaceholder: { type: String, default: '' }, + opinionHint: { type: String, default: '' }, + opinionRequired: { type: Boolean, default: false } }) -const emit = defineEmits(['close', 'confirm']) +const emit = defineEmits(['close', 'confirm', 'update:opinion']) -const normalizedOpinion = computed(() => props.opinion.trim() || '未填写') +const currentOpinion = computed(() => String(props.opinion || '')) + +function handleOpinionInput(event) { + emit('update:opinion', event.target.value) +} + + diff --git a/web/src/components/travel/TravelRequestReturnDialog.vue b/web/src/components/travel/TravelRequestReturnDialog.vue index 24dbf81..044b9f5 100644 --- a/web/src/components/travel/TravelRequestReturnDialog.vue +++ b/web/src/components/travel/TravelRequestReturnDialog.vue @@ -4,6 +4,7 @@ :title="title" :description="description" :busy="busy" + :application="application" @close="emit('close')" @confirm="emit('confirm', $event)" /> @@ -16,7 +17,8 @@ defineProps({ open: { type: Boolean, required: true }, title: { type: String, required: true }, description: { type: String, required: true }, - busy: { type: Boolean, required: true } + busy: { type: Boolean, required: true }, + application: { type: Boolean, default: false } }) const emit = defineEmits(['close', 'confirm']) diff --git a/web/src/composables/useRequests.js b/web/src/composables/useRequests.js index adc4c3e..3ff08cd 100644 --- a/web/src/composables/useRequests.js +++ b/web/src/composables/useRequests.js @@ -426,6 +426,29 @@ function resolveDisplayName(...values) { return '' } +function resolveApplicationApproverName(claim) { + return resolveDisplayName( + claim?.manager_name, + claim?.managerName, + claim?.profile_manager, + claim?.profileManager, + claim?.direct_manager_name, + claim?.directManagerName + ) || '直属领导' +} + +function resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta) { + if ( + documentTypeCode === DOCUMENT_TYPE_APPLICATION + && approvalMeta.key !== 'completed' + && normalizeText(label) === '直属领导审批' + ) { + return `等待 ${resolveApplicationApproverName(claim)} 批复` + } + + return label +} + function getRiskFlags(claim) { return Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : [] } @@ -488,6 +511,25 @@ function findLatestReturnEvent(claim) { ) } +function findLatestApplicationReturnEvent(claim) { + return getLatestEvent( + getRiskFlags(claim).filter((flag) => { + if (!flag || typeof flag !== 'object' || normalizeText(flag.source) !== 'manual_return') { + return false + } + const eventType = normalizeText(flag.event_type || flag.eventType) + const returnStage = normalizeText(flag.return_stage || flag.returnStage || flag.previous_approval_stage) + const stageKey = normalizeText(flag.return_stage_key || flag.returnStageKey) + return ( + eventType === 'expense_application_return' + || stageKey === 'direct_manager' + || returnStage.includes('直属领导') + || returnStage.includes('领导审批') + ) + }) + ) +} + function buildProgressStepMeta(time, detail = '', title = '') { return { time, @@ -532,6 +574,28 @@ function buildCompletedStepMeta(claim, label) { const updatedAt = formatDateTime(claim?.updated_at) return buildProgressStepMeta('财务通过', updatedAt, `财务审批通过 ${updatedAt}`.trim()) } + + if (stepLabel === '直属领导审批') { + const returnEvent = findLatestApplicationReturnEvent(claim) + if (returnEvent) { + const handledAt = formatDateTime(returnEvent.created_at || returnEvent.createdAt) + return buildProgressStepMeta('已处理', handledAt, `直属领导已处理 ${handledAt}`.trim()) + } + } + } + + if (stepLabel === '退回') { + const returnEvent = findLatestApplicationReturnEvent(claim) || findLatestReturnEvent(claim) + if (returnEvent) { + const operator = resolveDisplayName( + returnEvent.operator, + returnEvent.operator_name, + returnEvent.operatorName, + claim?.manager_name + ) || '直属领导' + const returnedAt = formatDateTime(returnEvent.created_at || returnEvent.createdAt) + return buildProgressStepMeta(`${operator}退回`, returnedAt, `${operator}退回 ${returnedAt}`.trim()) + } } if (stepLabel === '归档入账') { @@ -574,13 +638,22 @@ function resolveCurrentStepStartedAt(claim, label) { function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}) { const documentTypeCode = String(options.documentTypeCode || '').trim() + const hasApplicationReturnStep = ( + documentTypeCode === DOCUMENT_TYPE_APPLICATION + && Boolean(findLatestApplicationReturnEvent(claim)) + && approvalMeta.key === 'supplement' + ) const progressLabels = documentTypeCode === DOCUMENT_TYPE_APPLICATION - ? APPLICATION_PROGRESS_LABELS + ? hasApplicationReturnStep + ? ['创建申请', '直属领导审批', '退回', '待提交'] + : APPLICATION_PROGRESS_LABELS : REIMBURSEMENT_PROGRESS_LABELS const currentIndex = documentTypeCode === DOCUMENT_TYPE_APPLICATION - ? resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode) + ? hasApplicationReturnStep + ? 3 + : resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode) : resolveProgressCurrentIndex(approvalMeta, workflowNode) const currentTime = approvalMeta.key === 'completed' @@ -592,11 +665,13 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {} : '进行中' return progressLabels.map((label, index) => { + const displayLabel = resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta) if (approvalMeta.key === 'completed') { const stepMeta = buildCompletedStepMeta(claim, label) return { index: index + 1, - label, + label: displayLabel, + rawLabel: label, time: stepMeta.time, detail: stepMeta.detail, title: stepMeta.title, @@ -610,7 +685,8 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {} const stepMeta = buildCompletedStepMeta(claim, label) return { index: index + 1, - label, + label: displayLabel, + rawLabel: label, time: stepMeta.time, detail: stepMeta.detail, title: stepMeta.title, @@ -624,10 +700,11 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {} const stayDuration = formatDurationFrom(resolveCurrentStepStartedAt(claim, label)) return { index: index + 1, - label, + label: displayLabel, + rawLabel: label, time: stayDuration ? `停留 ${stayDuration}` : currentTime, detail: '', - title: stayDuration ? `当前${label}已停留 ${stayDuration}` : currentTime, + title: stayDuration ? `当前${displayLabel}已停留 ${stayDuration}` : currentTime, done: false, active: true, current: true @@ -636,7 +713,8 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {} return { index: index + 1, - label, + label: displayLabel, + rawLabel: label, time: '待处理', detail: '', title: '待处理', @@ -758,9 +836,13 @@ export function mapExpenseClaimToRequest(claim) { approvalTone: approvalMeta.tone, secondaryStatusLabel: isApplicationDocument ? '申请材料' : (typeCode === 'travel' ? '行程状态' : '票据状态'), secondaryStatusValue: isApplicationDocument - ? '已进入审批流程' + ? approvalMeta.key === 'supplement' + ? '领导已退回,待重新提交' + : '已进入审批流程' : (invoiceCount > 0 ? `已关联 ${invoiceCount} 张票据` : '待上传票据'), - secondaryStatusTone: isApplicationDocument ? 'success' : (invoiceCount > 0 ? 'success' : 'warning'), + secondaryStatusTone: isApplicationDocument + ? approvalMeta.key === 'supplement' ? 'warning' : 'success' + : (invoiceCount > 0 ? 'success' : 'warning'), riskSummary, attachmentSummary: isApplicationDocument ? '申请单' : (invoiceCount > 0 ? `${invoiceCount} 张票据` : '无'), expenseTableSummary: isApplicationDocument diff --git a/web/src/utils/accessControl.js b/web/src/utils/accessControl.js index 8e3f335..a1bfa9d 100644 --- a/web/src/utils/accessControl.js +++ b/web/src/utils/accessControl.js @@ -40,6 +40,21 @@ function normalizeRoleCode(value) { return roleCode === 'auditor' ? 'budget_monitor' : roleCode } +function normalizeComparableText(value) { + return String(value || '').trim() +} + +function collectIdentityNames(...values) { + return values + .map((value) => normalizeComparableText(value)) + .filter(Boolean) +} + +function identityIntersects(leftValues, rightValues) { + const rightSet = new Set(rightValues) + return leftValues.some((item) => rightSet.has(item)) +} + function hasPlatformAdminIdentity(user) { if (!user) { return false @@ -111,10 +126,53 @@ export function canApproveLeaderExpenseClaims(user) { if (isPlatformAdminUser(user)) { return true } - - return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode)) -} - + + return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode)) +} + +export function isCurrentRequestApplicant(request, user) { + const applicantNames = collectIdentityNames( + request?.person, + request?.employeeName, + request?.employee_name, + request?.profileName, + request?.applicant + ) + const currentNames = collectIdentityNames( + user?.name, + user?.username, + user?.email, + user?.employeeNo, + user?.employee_no + ) + + return applicantNames.length > 0 && identityIntersects(applicantNames, currentNames) +} + +export function isCurrentDirectManagerForRequest(request, user) { + if (isCurrentRequestApplicant(request, user)) { + return false + } + + const managerNames = collectIdentityNames( + request?.profileManager, + request?.managerName, + request?.manager_name, + request?.directManagerName, + request?.direct_manager_name, + request?.manager + ) + const currentNames = collectIdentityNames( + user?.name, + user?.username, + user?.email, + user?.employeeNo, + user?.employee_no + ) + + return managerNames.length > 0 && identityIntersects(managerNames, currentNames) +} + export function canAccessAppView(user, viewId) { if (!viewId || !user) { return false diff --git a/web/src/utils/applicationApproval.js b/web/src/utils/applicationApproval.js index f485a36..20ad206 100644 --- a/web/src/utils/applicationApproval.js +++ b/web/src/utils/applicationApproval.js @@ -51,27 +51,86 @@ function getLatestEvent(events) { return sortedEvents.length ? sortedEvents[sortedEvents.length - 1] : null } -export function findLeaderApprovalEvent(request) { - return getLatestEvent( - getRiskFlags(request).filter((flag) => { - const source = normalizeText(flag?.source) - const eventType = normalizeText(flag?.event_type || flag?.eventType) - const previousStage = normalizeText(flag?.previous_approval_stage || flag?.previousApprovalStage) - const nextStage = normalizeText(flag?.next_approval_stage || flag?.nextApprovalStage) - return ( - source === 'manual_approval' - && ( - eventType === 'expense_application_approval' - || previousStage.includes('直属领导') - || previousStage.includes('领导审批') - || nextStage.includes('财务') - || nextStage.includes('审批完成') - ) - ) - }) +function isLeaderApprovalEvent(flag) { + const source = normalizeText(flag?.source) + const eventType = normalizeText(flag?.event_type || flag?.eventType) + const previousStage = normalizeText(flag?.previous_approval_stage || flag?.previousApprovalStage) + const nextStage = normalizeText(flag?.next_approval_stage || flag?.nextApprovalStage) + return ( + source === 'manual_approval' + && ( + eventType === 'expense_application_approval' + || previousStage.includes('直属领导') + || previousStage.includes('领导审批') + || nextStage.includes('财务') + || nextStage.includes('审批完成') + ) ) } +function isLeaderReturnEvent(flag) { + const source = normalizeText(flag?.source) + const eventType = normalizeText(flag?.event_type || flag?.eventType) + const returnStage = normalizeText(flag?.return_stage || flag?.returnStage || flag?.previous_approval_stage) + const stageKey = normalizeText(flag?.return_stage_key || flag?.returnStageKey) + return ( + source === 'manual_return' + && ( + eventType === 'expense_application_return' + || stageKey === 'direct_manager' + || returnStage.includes('直属领导') + || returnStage.includes('领导审批') + ) + ) +} + +export function findLeaderApprovalEvent(request) { + return getLatestEvent(getRiskFlags(request).filter(isLeaderApprovalEvent)) +} + +export function buildLeaderApprovalEvents(request) { + return getRiskFlags(request) + .filter((flag) => isLeaderApprovalEvent(flag) || isLeaderReturnEvent(flag)) + .map((event) => { + const returned = isLeaderReturnEvent(event) + const rawTime = event.created_at || event.createdAt + const operator = resolveDisplayName( + event.operator, + event.operator_name, + event.operatorName, + request?.profileManager, + request?.managerName + ) || '直属领导' + const time = formatDateTime(rawTime) + const opinion = normalizeText(event.opinion) + || normalizeText(event.leader_opinion || event.leaderOpinion) + || normalizeText(event.reason) + || normalizeText(event.message) + || (returned ? '已退回申请,请申请人补充后重新提交。' : '已审批通过。') + const returnCount = Number(event.return_count || event.returnCount || 0) + return { + id: normalizeText(event.return_event_id || event.returnEventId || event.approval_event_id || event.approvalEventId) + || `${returned ? 'return' : 'approval'}-${event.created_at || event.createdAt || opinion}`, + type: returned ? 'returned' : 'approved', + tone: returned ? 'danger' : 'success', + title: returned ? '领导退回' : '领导审批通过', + operator, + time, + sortAt: rawTime, + opinion, + returnCount, + meta: [operator ? `${operator}${returned ? '退回' : '通过'}` : '', time].filter(Boolean).join(' · ') + } + }) + .sort((left, right) => { + const leftDate = toDate(left.sortAt) + const rightDate = toDate(right.sortAt) + if (!leftDate || !rightDate) return 0 + return leftDate.getTime() - rightDate.getTime() + }) + .map(({ sortAt, ...event }) => event) +} + export function buildLeaderApprovalInfo(request) { const event = findLeaderApprovalEvent(request) if (!event) { diff --git a/web/src/utils/approvalInbox.js b/web/src/utils/approvalInbox.js index 961ad78..290d127 100644 --- a/web/src/utils/approvalInbox.js +++ b/web/src/utils/approvalInbox.js @@ -1,23 +1,18 @@ import { mapExpenseClaimToRequest } from '../composables/useRequests.js' import { canApproveLeaderExpenseClaims, - canManageExpenseClaims, + isCurrentDirectManagerForRequest, + isCurrentRequestApplicant, isFinanceUser } from './accessControl.js' export function canProcessApprovalRequest(request, currentUser) { const node = String(request?.workflowNode || '').trim() - const currentName = String(currentUser?.name || '').trim() - const applicantName = String(request?.person || request?.employeeName || '').trim() - if (currentName && applicantName && currentName === applicantName) { + if (isCurrentRequestApplicant(request, currentUser)) { return false } - if (canManageExpenseClaims(currentUser)) { - return true - } - if (isFinanceUser(currentUser) && node.includes('财务')) { return true } @@ -29,7 +24,11 @@ export function canProcessApprovalRequest(request, currentUser) { || node.includes('负责人审批') ) - return canApproveLeaderExpenseClaims(currentUser) && isLeaderApprovalNode + return ( + canApproveLeaderExpenseClaims(currentUser) + && isLeaderApprovalNode + && isCurrentDirectManagerForRequest(request, currentUser) + ) } export function listPendingApprovalRequests(claimsPayload, currentUser) { diff --git a/web/src/utils/documentCenterRows.js b/web/src/utils/documentCenterRows.js index 72dfae1..44202ec 100644 --- a/web/src/utils/documentCenterRows.js +++ b/web/src/utils/documentCenterRows.js @@ -45,3 +45,22 @@ export function isArchivedDocumentRow(row) { export function excludeArchivedDocumentRows(rows) { return (Array.isArray(rows) ? rows : []).filter((row) => !isArchivedDocumentRow(row)) } + +export function isApplicationApprovalRow(row) { + if (!row) { + return false + } + + const statusGroup = String(row.statusGroup || '').trim() + return statusGroup === 'in_progress' && isApplicationRequestLike(row.rawRequest || row) +} + +export function filterApplicationScopeNewRows(rows) { + return (Array.isArray(rows) ? rows : []).filter((row) => !isApplicationApprovalRow(row)) +} + +export function prepareApplicationScopeRows(rows) { + return (Array.isArray(rows) ? rows : []) + .filter((row) => isApplicationRequestLike(row.rawRequest || row)) + .map((row) => (isApplicationApprovalRow(row) ? { ...row, isNewDocument: false } : row)) +} diff --git a/web/src/views/DocumentsCenterView.vue b/web/src/views/DocumentsCenterView.vue index 4b72e68..891965d 100644 --- a/web/src/views/DocumentsCenterView.vue +++ b/web/src/views/DocumentsCenterView.vue @@ -240,7 +240,6 @@