From d4d5d40569899d468c28b7bba4f2a0211da14dc3 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Wed, 27 May 2026 17:31:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=A2=84=E7=AE=97?= =?UTF-8?q?=E8=B4=B9=E6=8E=A7=E6=A8=A1=E5=9E=8B=E4=B8=8E=E6=8A=A5=E9=94=80?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E6=B5=81=E5=BC=95=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端新增预算费控服务和报销单审批流模块,引入申请人费用画像 算法,优化知识库 RAG 运行时和同步逻辑,完善报销单工作流常 量和明细同步,更新差旅报销规则电子表格,前端新增预算分析 组件和数字员工模型,完善审批对话框和洞察面板交互,优化侧 边栏和顶栏样式,补充单元测试。 --- .../index.html | 1472 +++++++++++++++++ .../finance-rules/公司差旅费报销规则.xlsx | Bin 19825 -> 19827 bytes server/src/app/algorithem/README.md | 21 + server/src/app/algorithem/__init__.py | 13 + .../algorithem/applicant_expense_profile.py | 445 +++++ .../applicant_expense_profile_formula.md | 224 +++ .../app/api/v1/endpoints/reimbursements.py | 36 +- server/src/app/schemas/budget.py | 12 + server/src/app/services/agent_foundation.py | 5 +- server/src/app/services/agent_runs.py | 3 +- server/src/app/services/budget.py | 4 + .../app/services/budget_expense_control.py | 194 +++ server/src/app/services/budget_support.py | 17 + .../services/expense_claim_access_policy.py | 188 ++- .../expense_claim_application_handoff.py | 5 +- .../services/expense_claim_approval_flow.py | 173 ++ .../app/services/expense_claim_constants.py | 1 + .../app/services/expense_claim_item_sync.py | 13 +- .../app/services/expense_claim_read_model.py | 2 + .../expense_claim_workflow_constants.py | 6 + server/src/app/services/expense_claims.py | 227 +-- server/src/app/services/knowledge.py | 10 +- server/src/app/services/knowledge_rag.py | 115 +- .../src/app/services/knowledge_rag_runtime.py | 8 +- server/src/app/services/settings.py | 13 +- server/src/app/services/user_agent.py | 17 +- .../src/app/services/user_agent_knowledge.py | 167 +- .../src/app/services/user_agent_response.py | 9 +- server/storage/knowledge/.index.json | 146 +- server/tests/test_agent_runs_service.py | 21 + ...est_applicant_expense_profile_algorithm.py | 74 + server/tests/test_budget_endpoints.py | 91 + server/tests/test_expense_claim_service.py | 322 +++- server/tests/test_knowledge_rag_runtime.py | 35 + server/tests/test_knowledge_rag_service.py | 6 +- server/tests/test_knowledge_service.py | 39 + server/tests/test_knowledge_sync.py | 65 + server/tests/test_settings_persistence.py | 18 +- server/tests/test_user_agent_service.py | 44 +- .../assets/styles/components/sidebar-rail.css | 23 + web/src/assets/styles/components/top-bar.css | 6 + .../travel-reimbursement-insight-panel.css | 14 + web/src/assets/styles/detail-page-corners.css | 210 +++ .../styles/views/documents-center-view.css | 7 +- web/src/assets/styles/views/logs-view.css | 37 +- web/src/assets/styles/views/policies-view.css | 95 +- .../styles/views/settings-view-form.css | 6 +- web/src/components/layout/SidebarRail.vue | 89 +- web/src/components/layout/TopBar.vue | 2 +- .../components/shared/ReturnReasonDialog.vue | 64 +- .../TravelReimbursementInsightPanel.vue | 3 +- .../travel/TravelRequestApprovalDialog.vue | 19 - .../travel/TravelRequestBudgetAnalysis.vue | 293 ++++ web/src/composables/useRequests.js | 57 +- web/src/main.js | 1 + web/src/services/reimbursements.js | 4 + web/src/utils/accessControl.js | 45 +- web/src/utils/approvalInbox.js | 5 + web/src/utils/detailAlerts.js | 77 +- web/src/views/DocumentsCenterView.vue | 15 + web/src/views/LogsView.vue | 2 - web/src/views/PoliciesView.vue | 33 +- web/src/views/TravelRequestDetailView.vue | 9 +- .../views/scripts/EmployeeManagementView.js | 10 +- web/src/views/scripts/PoliciesView.js | 54 +- .../views/scripts/TravelRequestDetailView.js | 70 +- .../scripts/auditViewDigitalEmployeeModel.js | 263 +++ web/src/views/scripts/auditViewMetadata.js | 44 + web/src/views/scripts/auditViewModel.js | 72 +- web/tests/accessControl.test.mjs | 50 + web/tests/app-shell-detail-alerts.test.mjs | 30 +- .../documents-center-status-filter.test.mjs | 8 +- web/tests/requestProgressSteps.test.mjs | 62 +- ...el-request-detail-leader-approval.test.mjs | 34 +- ...travel-request-detail-risk-advice.test.mjs | 5 +- 75 files changed, 5393 insertions(+), 686 deletions(-) create mode 100644 document/development/budget-expense-control-model-plan/index.html create mode 100644 server/src/app/algorithem/README.md create mode 100644 server/src/app/algorithem/__init__.py create mode 100644 server/src/app/algorithem/applicant_expense_profile.py create mode 100644 server/src/app/algorithem/applicant_expense_profile_formula.md create mode 100644 server/src/app/services/budget_expense_control.py create mode 100644 server/src/app/services/expense_claim_approval_flow.py create mode 100644 server/src/app/services/expense_claim_workflow_constants.py create mode 100644 server/tests/test_applicant_expense_profile_algorithm.py create mode 100644 server/tests/test_knowledge_rag_runtime.py create mode 100644 server/tests/test_knowledge_sync.py create mode 100644 web/src/assets/styles/detail-page-corners.css create mode 100644 web/src/components/travel/TravelRequestBudgetAnalysis.vue create mode 100644 web/src/views/scripts/auditViewDigitalEmployeeModel.js diff --git a/document/development/budget-expense-control-model-plan/index.html b/document/development/budget-expense-control-model-plan/index.html new file mode 100644 index 0000000..8fbbb63 --- /dev/null +++ b/document/development/budget-expense-control-model-plan/index.html @@ -0,0 +1,1472 @@ + + + + + + X-Financial 预算费用规划推荐模型方案 + + + +
+ + +
+
+
+
X-Financial Model Proposal
+

预算费用规划推荐模型方案

+

+ 这个模型不只是给审批页打一个分,而是把“预算是否够、费用是否合理、是否影响预算节奏、下一步该怎么处理”整理成可解释、可审计、可持续迭代的推荐结果。 +

+
+ 规则模型优先 + 硬约束可审计 + LLM 只做解释层 + 审批反馈可回流 +
+
+ +
+
+
+ 模型主目标 + 帮预算管理者快速判断是否可承接 +
+
Recommend
+
+
+
+ 第一版边界 + 不替代人工审批,不让 LLM 直接做硬判断 +
+
Guarded
+
+
+
+ 核心口径 + 扣除当前申请已预占,避免重复计算 +
+
Traceable
+
+
+
+ 迭代方向 + 规则评分 + 历史基准 + 解释生成 +
+
V1 → V3
+
+
+
+ +
+
+
+

方案结论

+

建议把模型定位为“预算审批辅助决策引擎”,输出结构化推荐和解释依据,而不是单纯的页面分数。

+
+
Executive Summary
+
+ +
+
+
推荐动作
+
4 类
+
建议通过、谨慎通过、需要复核、不建议直接通过。
+
+
+
核心评分
+
100 分
+
预算安全优先,费用必要性与信息完整度共同影响。
+
+
+
硬性约束
+
3 条
+
超预算、强阻断控制、预算池无法匹配必须显式处理。
+
+
+
上线策略
+
分阶段
+
先确定性规则,再接入历史基准和解释层。
+
+
+ +
+
+ 推荐采用的产品口径: + 分数不是结论本身,结论应由“推荐动作 + 风险等级 + 触发依据 + 可执行建议”共同表达。 +
+
+ 需要避免的方向: + 不要让 LLM 直接判断是否通过,也不要只用一个综合分覆盖预算、业务必要性、历史异常等不同原因。 +
+
+
+ +
+
+
+

业务闭环

+

模型要服务完整预算链路:预算池建立、申请预占、审批推荐、报销核销、结果反馈。

+
+
Business Loop
+
+ +
+
+ 1 + 预算计划建立 + 按部门、项目、科目、成本中心形成预算池和预警线。 +
+
+ 2 + 费用申请预占 + 申请提交后先占用预算,避免后续审批期间额度被重复使用。 +
+
+ 3 + 预算审批推荐 + 预算管理者看到风险等级、计算依据、建议动作和补充要求。 +
+
+ 4 + 报销与核销 + 申请通过后生成报销草稿,实际报销后转为已核销金额。 +
+
+ 5 + 结果反馈回流 + 记录审批采纳、驳回原因、实际报销差异,反哺后续规则。 +
+
+
+ +
+
+
+

模型架构

+

采用“上下文构建 → 特征计算 → 硬约束判定 → 综合评分 → 推荐解释 → 反馈沉淀”的分层结构。

+
+
Model Layers
+
+ +
+
+

上下文层

+
+

统一组装申请单、预算池、历史费用、项目计划和审批身份,形成模型输入快照。

+
    +
  • 复用 build_claim_budget_context() 的预算上下文。
  • +
  • 补充申请事由、地点、费用类型、项目编号、附件摘要。
  • +
  • 保留计算时刻的预算快照,方便事后审计。
  • +
+
+
+ +
+

特征层

+
+

把业务数据转换成可解释特征,而不是直接把原始字段丢给模型。

+
    +
  • 预算容量:审批前可用、审批后使用率、超预算金额。
  • +
  • 单笔影响:本次金额占预算比例、是否突破预算节奏。
  • +
  • 必要性证据:事由、项目、地点、附件、业务场景是否完整。
  • +
  • 历史基准:同部门、同项目、同费用类型的历史均值和波动。
  • +
+
+
+ +
+

决策层

+
+

先执行硬规则,再计算综合分,确保强管控场景不会被其他维度“平均掉”。

+
    +
  • 硬规则:超预算、预算池阻断、预算无法匹配、关键字段缺失。
  • +
  • 综合评分:预算安全、费用影响、规划匹配、历史异常、信息完整。
  • +
  • 分档输出:recommended、caution、review、block、reference。
  • +
+
+
+ +
+

解释层

+
+

第一版直接用模板解释;后续允许 LLM 改写语气,但不能改写分数、等级和硬性结论。

+
    +
  • 解释必须引用结构化依据,不允许凭空补充原因。
  • +
  • 建议要能变成审批动作,例如补充材料、拆分费用、调整预算。
  • +
  • 所有展示文案保留对应的 basis_code,方便追踪。
  • +
+
+
+
+
+ +
+
+
+

数据与特征

+

模型输入需要覆盖“预算是否够”和“费用是否该花”两条线,避免只看额度、不看业务必要性。

+
+
Feature Design
+
+ +
+
+

预算上下文

+
    +
  • budget_applicable:是否纳入预算管控。
  • +
  • matched:是否匹配到预算池。
  • +
  • total_amountreserved_amountconsumed_amount
  • +
  • current_reserved_amount:当前申请已预占金额。
  • +
  • warning_thresholdcontrol_action
  • +
+
+ +
+

申请单主数据

+
    +
  • 申请金额、费用类型、申请事由。
  • +
  • 业务地点、项目编号、成本中心。
  • +
  • 直属领导意见、附件摘要。
  • +
  • 申请时间、期望发生时间、是否紧急。
  • +
+
+ +
+

增强特征

+
    +
  • 同类费用历史均值与分位数。
  • +
  • 部门剩余预算消耗速度。
  • +
  • 项目里程碑与预算计划匹配度。
  • +
  • 申请人近期预算占用频次。
  • +
+
+
+ +
+ 预算容量 + 单笔影响 + 预算节奏 + 业务必要性 + 历史异常 + 信息完整度 + 审批反馈 +
+
+ +
+
+
+

计算口径

+

申请提交时已经发生预算预占,所以审批分析必须扣除当前申请已预占金额,避免重复计算。

+
+
Calculation
+
+ +
+
已使用基数 = 已预占金额 + 已核销金额 - 当前申请已预占金额
+审批前可用预算 = 预算总额度 - 已使用基数
+审批后占用 = 已使用基数 + 本次申请金额
+此次费用占预算 = 本次申请金额 / 预算总额度
+审批后使用率 = 审批后占用 / 预算总额度
+超预算金额 = max(本次申请金额 - 审批前可用预算, 0)
+ +
+ 关键原则 + 如果当前申请在提交阶段已经进入 reserved_amount,审批页就不能再把它当成新增占用直接叠加。否则预算管理者看到的风险会被放大,尤其是大额申请会明显失真。 +
+
+
+ +
+
+
+

评分推荐

+

建议采用“硬规则优先 + 加权评分”的组合。硬规则决定是否必须复核或阻断,评分用于排序和解释风险程度。

+
+
Decision Strategy
+
+ +
+
+

建议权重

+
    +
  • 预算安全:40 分,覆盖可用额度、审批后使用率、超预算金额。
  • +
  • 单笔影响:18 分,判断本次费用对预算池的冲击。
  • +
  • 规划匹配:16 分,比较项目计划、预算周期和费用发生时间。
  • +
  • 历史基准:14 分,识别同类费用异常偏高或频繁占用。
  • +
  • 信息完整:12 分,衡量事由、项目、地点、附件是否足够。
  • +
+
+ +
+ +
+ caution +
70-84
+ 预算可承接但影响较明显,建议谨慎通过并关注后续节奏。 +
+
+ review +
50-69
+ 存在信息缺口、历史异常或接近预警线,需要复核。 +
+
+ block +
0-49
+ 超预算或触发强管控动作,不建议直接通过。 +
+
+
+ +
+
+ 硬规则示例: + control_action = block 且超预算时,直接进入 block;预算池未匹配时输出 reference,不能伪装成低风险。 +
+
+ 推荐动作要具体: + 不只写“需关注”,而要给出“补充业务必要性、拆分费用、调整预算池、发起预算调剂”等可执行动作。 +
+
+
+ +
+
+
+

申请人费用画像公式

+

画像只用于调整复核强度和审核建议,不能直接把申请人定义为“异常人员”。核心是用同组基准解释个人费用节奏是否偏离。

+
+
Applicant Profile
+
+ +
+
申请人画像风险分 =
+  高频申请分 * 20%
++ 高金额占用分 * 25%
++ 同组偏离分 * 25%
++ 历史调减退回分 * 15%
++ 本次申请偏离分 * 15%
+
+等级:
+0-39   正常
+40-59  需关注
+60-79  重点复核
+80-100 强复核 / 升级审批
+ +
+ 同组基准口径 + 同组不按全公司粗暴比较,而应按 部门 + 岗位 + 费用类型 + 城市等级 + 项目类型 + 近 90/180 天 聚合。销售、项目经理和研发不能混在一个基准池里比较。 +
+
+ +
+
+

出差天数建议

+
同类基准天数 = peer_group.travel_days_p75
+天数偏离率 = 本次出差天数 / 同类基准天数
+建议天数 = min(
+  本次出差天数,
+  同类基准天数 + 业务缓冲天数
+)
+

当偏离率超过 1.5 时,建议补充行程安排或压缩天数;超过 2.0 时进入重点复核。

+
+ +
+

出差费用建议

+
本次日均费用 = 本次申请金额 / 本次出差天数
+日均费用偏离率 =
+  本次日均费用 / 同城市同职级日均基准
+建议金额上限 =
+  建议天数 * 日均基准 * 容忍系数
+

容忍系数第一版建议使用 1.15 到 1.20,避免模型因为小幅波动给出过硬建议。

+
+ +
+

招待费用建议

+
人均招待金额 = 本次招待金额 / 参与人数
+人均偏离率 = 人均招待金额 / 招待标准上限
+同客户招待频率 = 近 90 天同客户招待次数
+个人招待分位 = 个人近 90 天招待金额在同组分位
+

人均偏离率超过 1.2 时建议调低标准;同客户 90 天内多次招待时要求补充客户推进阶段。

+
+
+ +
+
+ 建议生成公式: + 审核建议强度 = max(画像风险等级, 本次偏离等级, 硬规则等级)。展示时必须说明是哪一类指标触发,而不是只给一个结论。 +
+
+ 审核建议示例: + “该申请人近 90 天费用占用处于同组 P88,本次出差天数为同类 P75 的 1.67 倍。建议将 5 天调整为 4 天,或补充客户拜访安排和项目阶段说明。” +
+
+
+ +
+
+
+

输出协议

+

接口输出需要同时服务审批页展示、看板统计和后续审计,因此自然语言必须和结构化字段分开。

+
+
API Contract
+
+ +
{
+  "score": 82,
+  "rating": "caution",
+  "risk_level": "medium",
+  "recommendation": "cautious_approve",
+  "summary": "预算整体可承接,但本次费用对预算池已有明显影响。",
+  "metrics": {
+    "claim_amount": "12000.00",
+    "total_amount": "50000.00",
+    "available_before_approval": "38000.00",
+    "claim_amount_ratio": "24.00",
+    "after_usage_rate": "72.00",
+    "over_budget_amount": "0.00"
+  },
+  "applicant_profile": {
+    "profile_score": 66,
+    "profile_level": "review",
+    "peer_percentile": 88,
+    "travel_days_ratio": "1.67",
+    "daily_cost_ratio": "1.34",
+    "suggested_days": 4,
+    "suggested_amount_cap": "6800.00"
+  },
+  "evidence": [
+    {
+      "basis_code": "budget.after_usage_rate.warning_near",
+      "text": "审批后预算使用率为 72.00%,接近预警区间。"
+    }
+  ],
+  "suggested_actions": [
+    {
+      "action": "approve_with_note",
+      "text": "可继续审批,但建议备注本次费用对应的项目阶段和后续预算节奏。"
+    }
+  ],
+  "explainable_snapshot": {
+    "budget_no": "BUD-TEST",
+    "control_action": "warn",
+    "warning_threshold": "80.00"
+  }
+}
+
+ +
+
+
+

审批展示

+

预算管理者最需要看到的是“为什么是这个建议”和“如果要通过,还要补什么”。页面不应只展示一个分数。

+
+
Approval UX
+
+ +
+
+ 顶部结论条 + 展示推荐动作、风险等级、综合分和一句话摘要,例如“谨慎通过:预算可承接,但审批后使用率接近预警线”。 +
+
+ 关键指标 + 本次金额、预算总额、审批前可用、审批后使用率、超预算金额。 +
+
+ 触发依据 + 逐条展示规则依据,保留 basis_code,方便定位到模型逻辑。 +
+
+ 建议动作 + 通过、谨慎通过、补充材料、拆分费用、预算调剂、退回修改。 +
+
+ 申请人费用画像 + 展示近 90 天频率、金额分位、天数偏离率和建议压缩区间,只作为审批参考。 +
+
+ 审批反馈 + 记录预算管理者是否采纳建议,以及不采纳时的原因。 +
+
+
+ +
+
+
+

落地路线

+

先把确定性口径做稳,再逐步接入历史基准和解释层,避免第一版就变成难以审计的黑盒。

+
+
Implementation
+
+ +
+
+

P0:口径对齐

+

确认预算预占、审批前可用、审批后占用的计算口径。

+
    +
  • 梳理预算上下文字段。
  • +
  • 固化不重复计算规则。
  • +
  • 定义输出协议。
  • +
+
+
+

P1:规则评分

+

上线可审计的确定性模型,覆盖预算审批主流程。

+
    +
  • 实现特征计算。
  • +
  • 实现硬规则和分档。
  • +
  • 补齐单元测试。
  • +
+
+
+

P2:历史基准

+

引入同类费用历史均值、部门消耗速度和预算节奏。

+
    +
  • 沉淀历史统计任务。
  • +
  • 增加异常检测特征。
  • +
  • 看板展示采纳率。
  • +
+
+
+

P3:解释增强

+

在结构化依据上增加 LLM 文案层,提升可读性。

+
    +
  • LLM 不改分数和等级。
  • +
  • 解释引用 basis_code。
  • +
  • 增加人工反馈闭环。
  • +
+
+
+
+ +
+
+
+

验收清单

+

下面的清单会保存在当前浏览器本地,方便后续评审和开发跟踪。

+
+
Checklist
+
+ +
+ + + + + + + +
+
+ + +
+
+ + + + diff --git a/server/rules/finance-rules/公司差旅费报销规则.xlsx b/server/rules/finance-rules/公司差旅费报销规则.xlsx index cca003bdf4bb317d4b495eb4a0046ecf1cc41af1..22ca3d855043fd4a858f8bcf738c134524d5b0bb 100644 GIT binary patch delta 1523 zcmZ8hdpMM76#wStw#_mzu7fcop=YK{qOF8AH5V;X4dohLsHWU+HH=#k&H74WRjAO# zC6b7;LSrUVq=v~PLs+F0ZLwo^=G)!p*?RwZfA8;{^PcmZ^Lx%rA?#Bj4DCTjAawxc zNuf2@q61VBio1@b8VQHMn0hfnUBT==lyKX%8q7xH*urpaXPqwj+2}`W226(zHkXLK zj8cCsMTIBYfo;5Y`uiS? ztlh?zCT&g~VJQ zEUMav=hn?p&oA68AgxJOTN;FF<8$ppDcQ2A;b7Ys-^cfaQcHXoxipNJPFVSw-mfBs zzaVUB+M|0rH<_OzCJzUN(v15q05Cr|yS_%s03bVZ=HCp?7dHE?dQVmK)mGk$ty0&E93qELOsC(ig=Zmd*6H2kj7%Fuwn%O!i34r}$}u?5M-Q7N&DlQn;SoYLVnaYFnoi({PR$3M5m zMu@~tjtiehZY+dmt&K&ordL<9<*#dA9gAF)HOi;^_1jxHLFHroFkSAtP>V**dTO{; z&Cp5aZc){fEf~*EjTbdY--jTYHyWtwYKXLOq&@8optEi66>k{LKZ<#PQ_>3(d6rpU0X?ZZxj@omfP^Ur;qI0+Nf0j8f% zvd#M)wm-4NxyASWd>X-6TYeP(IySL&WMc~tfg3^1wb|vk344mJIQ^$_HKoSu0=4$5 zL)zvW5MfJ|+FlZ$C2)lSKs+1(FaUUzuFcNL^JAsrbu#2xuO*zqNJbk4w7ieQS3!t;1A_RSm-&xM{SBw6X>@m>IaD)Bmk=q|>BYU%GhkfJsxCm>tCL z(0c{js(wBQokBNpm`<-X&Y8GzCfIwBwG$0rLGr%ThCB^YIqU&o>q4_$#%R_v>w4Yk z(sM^HpZ3oQ>?4DgMo+5P94C8Wb$dnaYS~pJKA-NKjxY)9n*Vwz@kT__Tx~#L$1zb< zk;Iy-xl-z714{2Z3)eYRES_X~<^A|5u19EoQGhS2vfhA>p-Sqy$|G`^v|b;^)uXqp zs4jLcyua#*(UJ0<6+-l45Mz9qPj3jdeBkU1A<$JSew8i*zU}*AoB<5#dzP_}3IyJm zuSG%tZU!N`twH!fFkNfr3Eyg*+aJ%&EbEG38axBwwq=I_tpYg zTDqW&wgjS<(2d~t4ZuMIoQ($^rWmnu-9b|LZt&0+1Qa?@oEh*49dD`>hbyt8Yg)lP zP#Clz8}P5s90k4d|w8uby3jzS{sR4lgH}P02 z(AgaerZF&(V@a!5My8^+aPWIa&<1ksW?-QIB@83D$0mrqbLz6%ztXh8yiwxYss PlZe1g?lvlXsxtH6$l;8& delta 1608 zcmex7i}B+uM&1B#W)=|!5b&?vypcDaof$}P&Sx)X0yCa+&SmBVip2O>&2nBhxtmv} zJ~~_2QRMjkQ&vw*Z+e^G&U>-#%CUqc83%9KO*{Ec#k!UML~3-)Hi*{D(Jv&8E9~b=C@gD|>e4&%ft$EUizkE|k#t?cy@UnEfRyuV%~6 zJB!ymT;Lu(^^Z`HQs>Ne=5rm*Rw_=bwNK~jeaot!^Imhq99O(bFLk#GZrm$^L5-H{)As<|_ihZEP)*cJMV{e|<$Y_1!k7Lr)l#JQ`G4 z--m|t**44#QF5ja!>u9z-x1K zvX5*!W)S;3eb&0(!eeu`JnBeWP-vXbReI^cq#d5=2NR4o_<8wS^ zY+7QP5}w>pa|$Xil5h%EPx3ZCbC2t=;+MSlUv~We_TbI)*Y`fI@189Gx4(Vw;pd$E z%R=@tMkjF^Pi}J9t?W8Q!@}jNH|w&5_SgrpHCC_o{;_g4TBjg6-822}`Tg_vzMm`i zmCx_|{=F9O>t;UGjX4;-}np*=Ao&&8-#Ti4M} z_REqrYuD=@oVs1RZKk>J=`;7&>D)aT-L~-d)304SBR0;z_k@w}W7Dq(GmkAwdw%wm zJS>TGfRgy;M1c%WaMC^_sn1vsO43=EY_3=D$6jF6I_98i>BP^_PvUzDm> zk(<*y>7w5u1Ch4))R%v+P2)`3-0Q*{K52%<;*aYx`7i7!^Np6QuHMebtRx;+_W!#6 zf6LfyH(qPqYW%#)%yJQ@qo72fcdD6XmHzaPGOl(7%9WwgCjzeaH?*RR|5 zpovMoRPOQ24VJgs5+sksEle^2V3@6CVJ?3taZwMs8+@0UiJPehneqWla~!; ze>MNb?A^n=_}Hm0vxOSEe7MwPJ_OY7UNryyw%=NZ#pQpqhTUlo-Trbb)6W}XFa4Jazd5W=y{2 zX$KOREbV0mrbE0ekP5GHy@p(%#?whRTtr>?cL4Zm} zVEO=p4^mi?M}RjYFy(*>QE&=_d+q>Gj{sO3Onc(wjo$L|AS*s*JnhtBV_;Y(&%hvr zqNaB8dv8Usn!|hB?{cy*Fz5<1FaQx4K%8-Nvc8WJSPdeO7#Ji_)Cf;5@KFbQZkCTU z(?spbh8m)i&-e&}g&qKf{2V|+hQ^>2j_!rGNoO?o0<9JXx*yfpYc61eB78;JPI`hY F1OS@`msbD) diff --git a/server/src/app/algorithem/README.md b/server/src/app/algorithem/README.md new file mode 100644 index 0000000..ae62a73 --- /dev/null +++ b/server/src/app/algorithem/README.md @@ -0,0 +1,21 @@ +# X-Financial 核心算法推演目录 + +> 目录名 `algorithem` 沿用当前任务指定拼写。该目录用于沉淀核心算法推演、公式口径和可审计实现,避免把算法细节直接堆进 `services`。 + +## 目录职责 + +- 保存预算、费用、风控、知识检索等核心算法的推演文档。 +- 记录公式、权重、阈值、输入输出协议和边界案例。 +- 为后续 Python 实现、单元测试和接口协议提供依据。 + +## 当前算法主题 + +- `applicant_expense_profile_formula.md`:申请人费用画像与审核建议量化公式。 +- `applicant_expense_profile.py`:申请人费用画像评分的第一版纯算法实现。 + +## 落地原则 + +- 算法先有可解释公式,再进入业务服务实现。 +- 硬规则、评分权重和自然语言解释要分层。 +- 所有核心算法模块都要遵守 800 行上限,按职责拆分。 +- 涉及审批建议时,输出“依据 + 建议动作”,不要直接给人贴负面标签。 diff --git a/server/src/app/algorithem/__init__.py b/server/src/app/algorithem/__init__.py new file mode 100644 index 0000000..4f60fcc --- /dev/null +++ b/server/src/app/algorithem/__init__.py @@ -0,0 +1,13 @@ +"""Core algorithm derivations for X-Financial.""" + +from .applicant_expense_profile import ( + ApplicantExpenseProfileInput, + ApplicantExpenseProfileResult, + evaluate_applicant_expense_profile, +) + +__all__ = [ + "ApplicantExpenseProfileInput", + "ApplicantExpenseProfileResult", + "evaluate_applicant_expense_profile", +] diff --git a/server/src/app/algorithem/applicant_expense_profile.py b/server/src/app/algorithem/applicant_expense_profile.py new file mode 100644 index 0000000..8c6eea3 --- /dev/null +++ b/server/src/app/algorithem/applicant_expense_profile.py @@ -0,0 +1,445 @@ +"""Applicant expense profile scoring algorithm. + +The module is intentionally pure and framework-free. Service layers can build the +input snapshot from database records, while this module only owns the formula, +scores, thresholds, and explainable result. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import ROUND_HALF_UP, Decimal, InvalidOperation +from typing import Any + +ZERO = Decimal("0") +ONE = Decimal("1") +HUNDRED = Decimal("100") + +LEVEL_NORMAL = "normal" +LEVEL_WATCH = "watch" +LEVEL_REVIEW = "review" +LEVEL_ESCALATION = "escalation" + + +@dataclass(slots=True) +class ApplicantExpenseProfileInput: + """Inputs for applicant expense behavior scoring. + + Values should be pre-aggregated by a comparable peer group, such as + department + role + expense type + city grade + project type. + """ + + applicant_claim_count_90d: int = 0 + peer_claim_count_p75_90d: Any = ZERO + applicant_amount_90d: Any = ZERO + available_peer_budget_90d: Any = ZERO + amount_percentile: Any = ZERO + peer_amount_median_90d: Any = ZERO + adjusted_or_returned_count_180d: int = 0 + approved_claim_count_180d: int = 0 + requested_days: Any = ZERO + peer_travel_days_p75: Any = ZERO + business_buffer_days: Any = ONE + claim_amount: Any = ZERO + peer_daily_cost_baseline: Any = ZERO + tolerance_factor: Any = Decimal("1.20") + entertainment_amount: Any = ZERO + attendee_count: int = 0 + entertainment_standard_cap: Any = ZERO + same_customer_frequency_90d: int = 0 + applicant_entertainment_percentile: Any = ZERO + hard_rule_score: int = 0 + + +@dataclass(slots=True) +class ApplicantExpenseProfileResult: + profile_score: int + profile_level: str + recommendation_score: int + recommendation_level: str + frequency_score: int + amount_occupancy_score: int + peer_deviation_score: int + adjustment_history_score: int + current_claim_deviation_score: int + travel_days_deviation_score: int + daily_cost_deviation_score: int + entertainment_deviation_score: int + frequency_ratio: Decimal + budget_share_ratio: Decimal + peer_deviation_ratio: Decimal + adjustment_ratio: Decimal + travel_days_ratio: Decimal + daily_cost_ratio: Decimal + per_capita_entertainment_ratio: Decimal + suggested_days: Decimal | None + suggested_amount_cap: Decimal | None + basis_codes: list[str] = field(default_factory=list) + + def as_dict(self) -> dict[str, Any]: + return { + "profile_score": self.profile_score, + "profile_level": self.profile_level, + "recommendation_score": self.recommendation_score, + "recommendation_level": self.recommendation_level, + "scores": { + "frequency_score": self.frequency_score, + "amount_occupancy_score": self.amount_occupancy_score, + "peer_deviation_score": self.peer_deviation_score, + "adjustment_history_score": self.adjustment_history_score, + "current_claim_deviation_score": self.current_claim_deviation_score, + "travel_days_deviation_score": self.travel_days_deviation_score, + "daily_cost_deviation_score": self.daily_cost_deviation_score, + "entertainment_deviation_score": self.entertainment_deviation_score, + }, + "metrics": { + "frequency_ratio": _format_decimal(self.frequency_ratio), + "budget_share_ratio": _format_decimal(self.budget_share_ratio), + "peer_deviation_ratio": _format_decimal(self.peer_deviation_ratio), + "adjustment_ratio": _format_decimal(self.adjustment_ratio), + "travel_days_ratio": _format_decimal(self.travel_days_ratio), + "daily_cost_ratio": _format_decimal(self.daily_cost_ratio), + "per_capita_entertainment_ratio": _format_decimal( + self.per_capita_entertainment_ratio + ), + "suggested_days": _format_decimal(self.suggested_days), + "suggested_amount_cap": _format_decimal(self.suggested_amount_cap), + }, + "basis_codes": list(self.basis_codes), + } + + +def evaluate_applicant_expense_profile( + payload: ApplicantExpenseProfileInput, +) -> ApplicantExpenseProfileResult: + frequency_ratio = _ratio(payload.applicant_claim_count_90d, payload.peer_claim_count_p75_90d) + frequency_score = _score_frequency_ratio(frequency_ratio) + + budget_share_ratio = _ratio(payload.applicant_amount_90d, payload.available_peer_budget_90d) + amount_percentile_score = _score_percentile(_to_decimal(payload.amount_percentile)) + budget_share_score = _score_budget_share_ratio(budget_share_ratio) + amount_occupancy_score = max(amount_percentile_score, budget_share_score) + + peer_deviation_ratio = _ratio(payload.applicant_amount_90d, payload.peer_amount_median_90d) + peer_deviation_score = _score_peer_deviation_ratio(peer_deviation_ratio) + + adjustment_ratio = _ratio( + payload.adjusted_or_returned_count_180d, + payload.approved_claim_count_180d, + ) + adjustment_history_score = _score_adjustment_ratio(adjustment_ratio) + + travel_days_ratio = _ratio(payload.requested_days, payload.peer_travel_days_p75) + travel_days_deviation_score = _score_travel_days_ratio(travel_days_ratio) + + requested_days = _to_decimal(payload.requested_days) + daily_cost = _ratio(payload.claim_amount, requested_days) + daily_cost_ratio = _ratio(daily_cost, payload.peer_daily_cost_baseline) + daily_cost_deviation_score = _score_daily_cost_ratio(daily_cost_ratio) + + per_capita_amount = _ratio(payload.entertainment_amount, payload.attendee_count) + per_capita_entertainment_ratio = _ratio( + per_capita_amount, + payload.entertainment_standard_cap, + ) + entertainment_deviation_score = max( + _score_entertainment_per_capita_ratio(per_capita_entertainment_ratio), + _score_same_customer_frequency(payload.same_customer_frequency_90d), + _score_percentile(_to_decimal(payload.applicant_entertainment_percentile)), + ) + + current_claim_deviation_score = max( + travel_days_deviation_score, + daily_cost_deviation_score, + entertainment_deviation_score, + ) + + profile_score = _weighted_profile_score( + frequency_score=frequency_score, + amount_occupancy_score=amount_occupancy_score, + peer_deviation_score=peer_deviation_score, + adjustment_history_score=adjustment_history_score, + current_claim_deviation_score=current_claim_deviation_score, + ) + + hard_rule_score = _clamp_score(payload.hard_rule_score) + recommendation_score = max(profile_score, current_claim_deviation_score, hard_rule_score) + + suggested_days = _suggest_days( + requested_days=requested_days, + baseline_days=_to_decimal(payload.peer_travel_days_p75), + business_buffer_days=_to_decimal(payload.business_buffer_days), + ) + suggested_amount_cap = _suggest_amount_cap( + suggested_days=suggested_days, + daily_cost_baseline=_to_decimal(payload.peer_daily_cost_baseline), + tolerance_factor=_to_decimal(payload.tolerance_factor), + ) + + basis_codes = _build_basis_codes( + frequency_ratio=frequency_ratio, + amount_percentile=_to_decimal(payload.amount_percentile), + budget_share_ratio=budget_share_ratio, + peer_deviation_ratio=peer_deviation_ratio, + adjustment_ratio=adjustment_ratio, + travel_days_ratio=travel_days_ratio, + daily_cost_ratio=daily_cost_ratio, + per_capita_entertainment_ratio=per_capita_entertainment_ratio, + same_customer_frequency_90d=payload.same_customer_frequency_90d, + applicant_entertainment_percentile=_to_decimal(payload.applicant_entertainment_percentile), + hard_rule_score=hard_rule_score, + ) + + return ApplicantExpenseProfileResult( + profile_score=profile_score, + profile_level=level_from_score(profile_score), + recommendation_score=recommendation_score, + recommendation_level=level_from_score(recommendation_score), + frequency_score=frequency_score, + amount_occupancy_score=amount_occupancy_score, + peer_deviation_score=peer_deviation_score, + adjustment_history_score=adjustment_history_score, + current_claim_deviation_score=current_claim_deviation_score, + travel_days_deviation_score=travel_days_deviation_score, + daily_cost_deviation_score=daily_cost_deviation_score, + entertainment_deviation_score=entertainment_deviation_score, + frequency_ratio=frequency_ratio, + budget_share_ratio=budget_share_ratio, + peer_deviation_ratio=peer_deviation_ratio, + adjustment_ratio=adjustment_ratio, + travel_days_ratio=travel_days_ratio, + daily_cost_ratio=daily_cost_ratio, + per_capita_entertainment_ratio=per_capita_entertainment_ratio, + suggested_days=suggested_days, + suggested_amount_cap=suggested_amount_cap, + basis_codes=basis_codes, + ) + + +def level_from_score(score: int) -> str: + normalized = _clamp_score(score) + if normalized >= 80: + return LEVEL_ESCALATION + if normalized >= 60: + return LEVEL_REVIEW + if normalized >= 40: + return LEVEL_WATCH + return LEVEL_NORMAL + + +def _weighted_profile_score( + *, + frequency_score: int, + amount_occupancy_score: int, + peer_deviation_score: int, + adjustment_history_score: int, + current_claim_deviation_score: int, +) -> int: + weighted = ( + Decimal(frequency_score) * Decimal("0.20") + + Decimal(amount_occupancy_score) * Decimal("0.25") + + Decimal(peer_deviation_score) * Decimal("0.25") + + Decimal(adjustment_history_score) * Decimal("0.15") + + Decimal(current_claim_deviation_score) * Decimal("0.15") + ) + return _clamp_score(int(weighted.quantize(Decimal("1"), rounding=ROUND_HALF_UP))) + + +def _score_frequency_ratio(ratio: Decimal) -> int: + return _score_ratio( + ratio, + [(Decimal("1.0"), 0), (Decimal("1.2"), 30), (Decimal("1.5"), 60), (Decimal("2.0"), 80)], + ) + + +def _score_budget_share_ratio(ratio: Decimal) -> int: + return _score_ratio(ratio, [(Decimal("0.10"), 0), (Decimal("0.20"), 40), (Decimal("0.35"), 70)]) + + +def _score_peer_deviation_ratio(ratio: Decimal) -> int: + return _score_ratio(ratio, [(Decimal("1.0"), 0), (Decimal("1.3"), 40), (Decimal("1.8"), 70)]) + + +def _score_adjustment_ratio(ratio: Decimal) -> int: + return _score_ratio(ratio, [(Decimal("0.05"), 0), (Decimal("0.15"), 40), (Decimal("0.30"), 70)]) + + +def _score_travel_days_ratio(ratio: Decimal) -> int: + return _score_ratio(ratio, [(Decimal("1.2"), 0), (Decimal("1.5"), 40), (Decimal("2.0"), 70)]) + + +def _score_daily_cost_ratio(ratio: Decimal) -> int: + return _score_ratio(ratio, [(Decimal("1.1"), 0), (Decimal("1.3"), 40), (Decimal("1.6"), 70)]) + + +def _score_entertainment_per_capita_ratio(ratio: Decimal) -> int: + return _score_ratio(ratio, [(Decimal("1.0"), 0), (Decimal("1.2"), 40), (Decimal("1.5"), 70)]) + + +def _score_same_customer_frequency(frequency: int) -> int: + if frequency >= 5: + return 100 + if frequency >= 3: + return 70 + if frequency >= 2: + return 40 + return 0 + + +def _score_percentile(percentile: Decimal) -> int: + normalized = max(ZERO, min(HUNDRED, percentile)) + if normalized <= Decimal("75"): + return 0 + if normalized <= Decimal("85"): + return 40 + if normalized <= Decimal("95"): + return 70 + return 100 + + +def _score_ratio(ratio: Decimal, bands: list[tuple[Decimal, int]]) -> int: + if ratio <= ZERO: + return 0 + for upper_bound, score in bands: + if ratio <= upper_bound: + return score + return 100 + + +def _suggest_days( + *, + requested_days: Decimal, + baseline_days: Decimal, + business_buffer_days: Decimal, +) -> Decimal | None: + if requested_days <= ZERO: + return None + if baseline_days <= ZERO: + return _quantize(requested_days) + return _quantize(min(requested_days, baseline_days + max(ZERO, business_buffer_days))) + + +def _suggest_amount_cap( + *, + suggested_days: Decimal | None, + daily_cost_baseline: Decimal, + tolerance_factor: Decimal, +) -> Decimal | None: + if suggested_days is None or suggested_days <= ZERO or daily_cost_baseline <= ZERO: + return None + factor = tolerance_factor if tolerance_factor > ZERO else ONE + return _quantize_money(suggested_days * daily_cost_baseline * factor) + + +def _build_basis_codes( + *, + frequency_ratio: Decimal, + amount_percentile: Decimal, + budget_share_ratio: Decimal, + peer_deviation_ratio: Decimal, + adjustment_ratio: Decimal, + travel_days_ratio: Decimal, + daily_cost_ratio: Decimal, + per_capita_entertainment_ratio: Decimal, + same_customer_frequency_90d: int, + applicant_entertainment_percentile: Decimal, + hard_rule_score: int, +) -> list[str]: + basis_codes: list[str] = [] + + if frequency_ratio > Decimal("1.5"): + basis_codes.append("applicant.frequency.ratio_review") + elif frequency_ratio > Decimal("1.2"): + basis_codes.append("applicant.frequency.ratio_watch") + + if amount_percentile > Decimal("95"): + basis_codes.append("applicant.amount_percentile.p95") + elif amount_percentile > Decimal("85"): + basis_codes.append("applicant.amount_percentile.p85") + + if budget_share_ratio > Decimal("0.35"): + basis_codes.append("applicant.budget_share.high") + elif budget_share_ratio > Decimal("0.20"): + basis_codes.append("applicant.budget_share.watch") + + if peer_deviation_ratio > Decimal("1.8"): + basis_codes.append("applicant.peer_deviation.escalation") + elif peer_deviation_ratio > Decimal("1.3"): + basis_codes.append("applicant.peer_deviation.review") + + if adjustment_ratio > Decimal("0.30"): + basis_codes.append("applicant.adjustment_history.escalation") + elif adjustment_ratio > Decimal("0.15"): + basis_codes.append("applicant.adjustment_history.review") + + if travel_days_ratio > Decimal("2.0"): + basis_codes.append("travel.days_ratio.escalation") + elif travel_days_ratio > Decimal("1.5"): + basis_codes.append("travel.days_ratio.review") + elif travel_days_ratio > Decimal("1.2"): + basis_codes.append("travel.days_ratio.watch") + + if daily_cost_ratio > Decimal("1.6"): + basis_codes.append("travel.daily_cost_ratio.escalation") + elif daily_cost_ratio > Decimal("1.3"): + basis_codes.append("travel.daily_cost_ratio.review") + elif daily_cost_ratio > Decimal("1.1"): + basis_codes.append("travel.daily_cost_ratio.watch") + + if per_capita_entertainment_ratio > Decimal("1.5"): + basis_codes.append("entertainment.per_capita.escalation") + elif per_capita_entertainment_ratio > Decimal("1.2"): + basis_codes.append("entertainment.per_capita.review") + + if same_customer_frequency_90d >= 5: + basis_codes.append("entertainment.same_customer.frequency_escalation") + elif same_customer_frequency_90d >= 3: + basis_codes.append("entertainment.same_customer.frequency_review") + + if applicant_entertainment_percentile > Decimal("95"): + basis_codes.append("entertainment.amount_percentile.p95") + elif applicant_entertainment_percentile > Decimal("85"): + basis_codes.append("entertainment.amount_percentile.p85") + + if hard_rule_score >= 80: + basis_codes.append("hard_rule.escalation") + elif hard_rule_score >= 60: + basis_codes.append("hard_rule.review") + + return basis_codes + + +def _ratio(numerator: Any, denominator: Any) -> Decimal: + divisor = _to_decimal(denominator) + if divisor <= ZERO: + return ZERO + return _quantize(_to_decimal(numerator) / divisor) + + +def _to_decimal(value: Any) -> Decimal: + if value is None: + return ZERO + if isinstance(value, Decimal): + return value + try: + return Decimal(str(value)) + except (InvalidOperation, ValueError): + return ZERO + + +def _quantize(value: Decimal) -> Decimal: + return value.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) + + +def _quantize_money(value: Decimal) -> Decimal: + return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + + +def _format_decimal(value: Decimal | None) -> str | None: + if value is None: + return None + return format(value.normalize(), "f") + + +def _clamp_score(score: int) -> int: + return max(0, min(100, int(score))) diff --git a/server/src/app/algorithem/applicant_expense_profile_formula.md b/server/src/app/algorithem/applicant_expense_profile_formula.md new file mode 100644 index 0000000..1691e42 --- /dev/null +++ b/server/src/app/algorithem/applicant_expense_profile_formula.md @@ -0,0 +1,224 @@ +# 申请人费用画像与审核建议量化公式 + +## 目标 + +申请人费用画像用于回答: + +- 该申请人的近期费用节奏是否高于同组基准。 +- 本次申请的天数、金额、招待频率是否明显偏离。 +- 审批时应普通通过、谨慎通过、重点复核,还是升级审批。 + +画像结果只作为审批参考,不应直接定义员工为异常人员。 + +## 同组基准 + +同组基准必须按业务可比口径聚合: + +```text +peer_group = + 部门 ++ 岗位/职级 ++ 费用类型 ++ 城市等级/业务区域 ++ 项目类型/客户阶段 ++ 近 90 天或近 180 天窗口 +``` + +不能用全公司平均值直接比较销售、研发、项目经理等不同岗位。 + +## 画像风险分 + +```text +profile_score = + frequency_score * 0.20 ++ amount_occupancy_score * 0.25 ++ peer_deviation_score * 0.25 ++ adjustment_history_score * 0.15 ++ current_claim_deviation_score * 0.15 +``` + +等级: + +```text +0-39 normal 正常 +40-59 watch 需关注 +60-79 review 重点复核 +80-100 escalation 强复核 / 升级审批 +``` + +## 子分计算 + +### 高频申请分 + +```text +frequency_ratio = applicant_claim_count_90d / peer_claim_count_p75_90d + +frequency_score = + 0 if frequency_ratio <= 1.0 + 30 if 1.0 < frequency_ratio <= 1.2 + 60 if 1.2 < frequency_ratio <= 1.5 + 80 if 1.5 < frequency_ratio <= 2.0 + 100 if frequency_ratio > 2.0 +``` + +### 高金额占用分 + +```text +budget_share_ratio = applicant_amount_90d / available_peer_budget_90d +amount_percentile = applicant_amount_90d 在同组中的分位数 + +amount_occupancy_score = + max( + percentile_score(amount_percentile), + ratio_score(budget_share_ratio) + ) +``` + +建议第一版分位规则: + +```text +P0-P75 -> 0 +P75-P85 -> 40 +P85-P95 -> 70 +P95-P100 -> 100 +``` + +### 同组偏离分 + +```text +peer_deviation_ratio = + applicant_amount_90d / peer_amount_median_90d + +peer_deviation_score = + 0 if peer_deviation_ratio <= 1.0 + 40 if 1.0 < peer_deviation_ratio <= 1.3 + 70 if 1.3 < peer_deviation_ratio <= 1.8 + 100 if peer_deviation_ratio > 1.8 +``` + +### 历史调减退回分 + +```text +adjustment_ratio = + adjusted_or_returned_count_180d / approved_claim_count_180d + +adjustment_history_score = + 0 if adjustment_ratio <= 0.05 + 40 if 0.05 < adjustment_ratio <= 0.15 + 70 if 0.15 < adjustment_ratio <= 0.30 + 100 if adjustment_ratio > 0.30 +``` + +### 本次申请偏离分 + +```text +current_claim_deviation_score = + max( + travel_days_deviation_score, + daily_cost_deviation_score, + entertainment_deviation_score + ) +``` + +## 出差天数建议 + +```text +baseline_days = peer_group.travel_days_p75 +travel_days_ratio = requested_days / baseline_days +suggested_days = min( + requested_days, + baseline_days + business_buffer_days +) +``` + +建议阈值: + +```text +travel_days_ratio <= 1.2 正常 +1.2 < ratio <= 1.5 提醒关注 +1.5 < ratio <= 2.0 建议压缩天数或补充说明 +ratio > 2.0 重点复核 / 升级审批 +``` + +## 出差费用建议 + +```text +daily_cost = claim_amount / requested_days +daily_cost_ratio = daily_cost / peer_city_grade_daily_cost_baseline +suggested_amount_cap = + suggested_days + * peer_city_grade_daily_cost_baseline + * tolerance_factor +``` + +第一版 `tolerance_factor` 建议取 `1.15` 到 `1.20`。 + +建议阈值: + +```text +daily_cost_ratio <= 1.1 正常 +1.1 < ratio <= 1.3 提醒关注 +1.3 < ratio <= 1.6 建议调减费用标准 +ratio > 1.6 重点复核 / 升级审批 +``` + +## 招待费用建议 + +```text +per_capita_entertainment_amount = entertainment_amount / attendee_count +per_capita_ratio = per_capita_entertainment_amount / entertainment_standard_cap +same_customer_frequency_90d = count(customer_id, applicant_id, 90d) +applicant_entertainment_percentile = + applicant_entertainment_amount_90d 在同组中的分位数 +``` + +建议阈值: + +```text +per_capita_ratio <= 1.0 正常 +1.0 < ratio <= 1.2 提醒关注 +1.2 < ratio <= 1.5 建议调低招待标准 +ratio > 1.5 重点复核 / 升级审批 +same_customer_frequency_90d >= 3 要求补充客户推进阶段 +percentile >= P85 重点复核 +percentile >= P95 升级审批 +``` + +## 审核建议强度 + +```text +recommendation_strength = + max( + level(profile_score), + level(current_claim_deviation_score), + level(hard_rule_score) + ) +``` + +输出文案必须引用触发指标: + +```text +该申请人近 90 天费用占用处于同组 P88, +本次出差天数为同类 P75 的 1.67 倍。 +建议将出差天数由 5 天调整为 4 天, +或补充客户拜访安排和项目阶段说明。 +``` + +## 输出字段建议 + +```json +{ + "profile_score": 66, + "profile_level": "review", + "peer_percentile": 88, + "travel_days_ratio": "1.67", + "daily_cost_ratio": "1.34", + "same_customer_frequency_90d": 3, + "suggested_days": 4, + "suggested_amount_cap": "6800.00", + "basis_codes": [ + "applicant.peer_percentile.p85", + "travel.days_ratio.review" + ] +} +``` diff --git a/server/src/app/api/v1/endpoints/reimbursements.py b/server/src/app/api/v1/endpoints/reimbursements.py index 65b002c..e17561c 100644 --- a/server/src/app/api/v1/endpoints/reimbursements.py +++ b/server/src/app/api/v1/endpoints/reimbursements.py @@ -7,6 +7,7 @@ from fastapi.responses import FileResponse from sqlalchemy.orm import Session from app.api.deps import CurrentUserContext, get_current_user, get_db +from app.schemas.budget import BudgetClaimAnalysisRead from app.schemas.common import ErrorResponse from app.schemas.reimbursement import ( ExpenseClaimAttachmentActionResponse, @@ -25,6 +26,7 @@ from app.schemas.reimbursement import ( TravelReimbursementCalculatorResponse, ) from app.services.expense_claims import ExpenseClaimService +from app.services.budget import BudgetService from app.services.reimbursement import ReimbursementService from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService @@ -126,6 +128,38 @@ def get_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser) - return claim +@router.get( + "/claims/{claim_id}/budget-analysis", + response_model=BudgetClaimAnalysisRead, + summary="读取申请单预算分析", + description="根据当前预算池、申请金额和预算管控模型,返回费用申请的预算影响和评分建议。", + responses={ + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "单据不存在。", + }, + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "当前用户无权查看预算分析。", + }, + }, +) +def get_expense_claim_budget_analysis( + claim_id: str, + db: DbSession, + current_user: CurrentUser, +) -> BudgetClaimAnalysisRead: + service = ExpenseClaimService(db) + if not service.can_view_budget_analysis(current_user): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有预算监控员或高级财务人员可以查看预算分析。") + claim = service.get_claim(claim_id, current_user) + if claim is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found") + if not service.can_view_budget_analysis(current_user, claim): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有该部门 P8 预算监控员或高级财务人员可以查看预算分析。") + return BudgetService(db).analyze_claim_budget(claim) + + @router.patch( "/claims/{claim_id}", response_model=ExpenseClaimRead, @@ -538,7 +572,7 @@ def return_expense_claim( "/claims/{claim_id}/approve", response_model=ExpenseClaimRead, summary="审批通过单据", - description="费用申请由直属领导审批通过后完成;报销单直属领导审批后流转到财务审批,财务终审通过后进入归档入账。", + description="费用申请由直属领导审批后流转到预算管理者审批,预算审核通过后生成报销草稿;报销单直属领导审批后流转到财务审批。", responses={ status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, diff --git a/server/src/app/schemas/budget.py b/server/src/app/schemas/budget.py index c8d3702..f85b53e 100644 --- a/server/src/app/schemas/budget.py +++ b/server/src/app/schemas/budget.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import datetime from decimal import Decimal +from typing import Any from pydantic import BaseModel, ConfigDict, Field @@ -144,6 +145,17 @@ class BudgetCheckRead(BaseModel): allocation: BudgetAllocationRead | None = None +class BudgetClaimAnalysisRead(BaseModel): + budget_context: dict[str, Any] = Field(default_factory=dict) + score: int + rating: str + risk_level: str + summary: str + metrics: dict[str, Any] = Field(default_factory=dict) + basis: list[str] = Field(default_factory=list) + suggestions: list[str] = Field(default_factory=list) + + class BudgetOperationRead(BaseModel): ok: bool message: str diff --git a/server/src/app/services/agent_foundation.py b/server/src/app/services/agent_foundation.py index 32cbd12..fadbe2f 100644 --- a/server/src/app/services/agent_foundation.py +++ b/server/src/app/services/agent_foundation.py @@ -87,7 +87,10 @@ class AgentFoundationService( def _foundation_cache_key(self) -> str: bind = self.db.get_bind() - return str(getattr(bind, "url", "") or id(bind)) + url = str(getattr(bind, "url", "") or "") + if url.endswith("/:memory:"): + return f"{url}:{id(bind)}" + return url or str(id(bind)) def _ensure_financial_record_schema(self) -> None: bind = self.db.get_bind() diff --git a/server/src/app/services/agent_runs.py b/server/src/app/services/agent_runs.py index 0e735d6..886ceb8 100644 --- a/server/src/app/services/agent_runs.py +++ b/server/src/app/services/agent_runs.py @@ -18,6 +18,7 @@ from app.services.knowledge_ingest_log import enrich_knowledge_ingest_route_json logger = get_logger("app.services.agent_runs") KNOWLEDGE_SYNC_HEARTBEAT_TIMEOUT = timedelta(minutes=30) +KNOWLEDGE_SYNC_JOB_TYPES = {"knowledge_index_sync", "llm_wiki_sync"} class AgentRunService: @@ -262,7 +263,7 @@ class AgentRunService: continue route_json = dict(run.route_json or {}) - if str(route_json.get("job_type") or "").strip() != "knowledge_index_sync": + if str(route_json.get("job_type") or "").strip() not in KNOWLEDGE_SYNC_JOB_TYPES: continue heartbeat_at = self._parse_heartbeat_time( diff --git a/server/src/app/services/budget.py b/server/src/app/services/budget.py index 9150612..934a07a 100644 --- a/server/src/app/services/budget.py +++ b/server/src/app/services/budget.py @@ -20,6 +20,7 @@ from app.schemas.budget import ( BudgetSummaryRead, BudgetTransactionRead, ) +from app.services.budget_expense_control import BudgetExpenseControlModel from app.services.budget_support import BudgetSupportMixin from app.services.budget_types import BudgetControlError, SUPPORTED_BUDGET_SUBJECT_CODES @@ -112,6 +113,9 @@ class BudgetService(BudgetSupportMixin): warnings=warnings, ) + def analyze_claim_budget(self, claim: ExpenseClaim) -> dict[str, Any]: + return BudgetExpenseControlModel().assess(self.build_claim_budget_context(claim), claim) + def create_or_update_allocation( self, payload: BudgetAllocationCreate, diff --git a/server/src/app/services/budget_expense_control.py b/server/src/app/services/budget_expense_control.py new file mode 100644 index 0000000..23ef660 --- /dev/null +++ b/server/src/app/services/budget_expense_control.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from decimal import Decimal, InvalidOperation +from typing import Any + +from app.models.financial_record import ExpenseClaim + + +class BudgetExpenseControlModel: + """预算费用管控模型:用预算容量、单据影响、规则动作和信息完整度给出建议。""" + + def assess(self, budget_context: dict[str, Any], claim: ExpenseClaim | None = None) -> dict[str, Any]: + context = dict(budget_context or {}) + amount = self._money(context.get("claim_amount")) + if not context.get("budget_applicable", True): + return self._build_result( + context=context, + score=72, + rating="reference", + risk_level="low", + summary="该费用类型暂未纳入预算强管控,本次仅作为申请合理性参考。", + basis=["当前费用科目未启用预算控制。"], + suggestions=["预算管理者可结合项目必要性和历史同类费用进行人工判断。"], + ) + if not context.get("matched"): + return self._build_result( + context=context, + score=38, + rating="block", + risk_level="high", + summary="未匹配到可用预算池,建议先完成预算编制或调整预算维度后再审批。", + basis=["系统按部门、成本中心、项目和费用科目未找到匹配预算额度。"], + suggestions=["请预算编制者补充对应预算池,或核对申请部门、项目和费用类型是否填写正确。"], + ) + + total = self._money(context.get("total_amount")) + reserved = self._money(context.get("reserved_amount")) + consumed = self._money(context.get("consumed_amount")) + current_reserved = self._money(context.get("current_reserved_amount")) + warning_threshold = self._money(context.get("warning_threshold") or "80") + used_before = max(reserved + consumed - current_reserved, Decimal("0.00")) + available_before = max(total - used_before, Decimal("0.00")) + after_used = used_before + amount + claim_ratio = self._percent(amount, total) + after_usage_rate = self._percent(after_used, total) + over_budget_amount = max(amount - available_before, Decimal("0.00")) + + score = 100 + basis = [ + f"预算池 {context.get('budget_no') or '未命名'} 总额度 {self._fmt(total)} 元。", + f"本次申请金额 {self._fmt(amount)} 元,占预算 {self._fmt(claim_ratio)}%。", + f"审批后预算使用率预计 {self._fmt(after_usage_rate)}%,预警线 {self._fmt(warning_threshold)}%。", + ] + suggestions: list[str] = [] + + if over_budget_amount > Decimal("0.00"): + score -= 55 + basis.append(f"按当前预算余额测算,本次申请将超出预算 {self._fmt(over_budget_amount)} 元。") + suggestions.append("建议先追加或调剂预算,再允许申请继续流转。") + elif after_usage_rate >= Decimal("100.00"): + score -= 38 + basis.append("审批后预算使用率将达到或超过 100%。") + suggestions.append("建议预算管理者复核剩余额度,并确认是否需要预算调剂。") + elif after_usage_rate >= warning_threshold: + score -= 20 + basis.append("审批后预算使用率将触达预算预警线。") + suggestions.append("建议关注后续同类费用,必要时提前调整预算节奏。") + elif after_usage_rate >= Decimal("70.00"): + score -= 8 + basis.append("审批后预算使用率较高,但尚未触达预警线。") + + if claim_ratio >= Decimal("50.00"): + score -= 20 + basis.append("单笔申请占预算比例超过 50%,对预算池影响较大。") + suggestions.append("建议补充业务必要性、交付范围和费用拆分依据。") + elif claim_ratio >= Decimal("30.00"): + score -= 12 + basis.append("单笔申请占预算比例超过 30%,需要关注预算节奏。") + elif claim_ratio >= Decimal("15.00"): + score -= 5 + basis.append("单笔申请占预算比例超过 15%,属于中等预算影响。") + + missing_fields = self._collect_context_gaps(claim) + if missing_fields: + score -= min(12, len(missing_fields) * 4) + basis.append(f"申请信息仍缺少:{'、'.join(missing_fields)}。") + suggestions.append("建议申请人补齐业务背景,便于预算管理者判断费用必要性。") + + control_action = str(context.get("control_action") or "").strip().lower() + if control_action == "block" and over_budget_amount > Decimal("0.00"): + suggestions.append("该预算池为硬控制口径,超预算时不建议直接通过。") + + score = max(0, min(100, int(round(score)))) + rating, risk_level = self._rate(score, over_budget_amount) + if not suggestions: + suggestions.append("预算额度与本次费用影响基本匹配,可以结合业务必要性继续审批。") + + return self._build_result( + context={ + **context, + "claim_amount_ratio": str(claim_ratio), + "after_usage_rate": str(after_usage_rate), + "available_before_amount": str(available_before), + "over_budget_amount": str(over_budget_amount), + }, + score=score, + rating=rating, + risk_level=risk_level, + summary=self._summary(rating), + basis=basis, + suggestions=suggestions, + ) + + @staticmethod + def _collect_context_gaps(claim: ExpenseClaim | None) -> list[str]: + if claim is None: + return [] + gaps = [] + if not str(claim.reason or "").strip(): + gaps.append("申请事由") + if not str(claim.location or "").strip(): + gaps.append("地点") + if not str(claim.project_code or "").strip(): + gaps.append("项目编号") + return gaps + + @staticmethod + def _rate(score: int, over_budget_amount: Decimal) -> tuple[str, str]: + if over_budget_amount > Decimal("0.00") or score < 50: + return "block", "high" + if score < 70: + return "review", "medium" + if score < 85: + return "caution", "medium" + return "recommended", "low" + + @staticmethod + def _summary(rating: str) -> str: + summaries = { + "recommended": "预算容量充足,单据费用与当前预算节奏基本匹配。", + "caution": "预算整体可承接,但本次费用对预算池已有一定影响。", + "review": "预算影响偏高,建议预算管理者结合业务必要性复核后再通过。", + "block": "预算风险较高,不建议在未补充依据或调整预算前直接通过。", + } + return summaries.get(rating, "已完成预算费用合理性测算。") + + @staticmethod + def _build_result( + *, + context: dict[str, Any], + score: int, + rating: str, + risk_level: str, + summary: str, + basis: list[str], + suggestions: list[str], + ) -> dict[str, Any]: + return { + "budget_context": context, + "score": score, + "rating": rating, + "risk_level": risk_level, + "summary": summary, + "basis": basis, + "suggestions": suggestions, + "metrics": { + "claim_amount": context.get("claim_amount"), + "total_amount": context.get("total_amount"), + "claim_amount_ratio": context.get("claim_amount_ratio", "0.00"), + "usage_rate": context.get("usage_rate", "0.00"), + "after_usage_rate": context.get("after_usage_rate", context.get("usage_rate", "0.00")), + "available_amount": context.get("available_amount"), + "available_before_amount": context.get("available_before_amount"), + "over_budget_amount": context.get("over_budget_amount", "0.00"), + }, + } + + @staticmethod + def _money(value: Any) -> Decimal: + try: + return Decimal(str(value if value is not None else "0")).quantize(Decimal("0.01")) + except (InvalidOperation, ValueError): + return Decimal("0.00") + + @staticmethod + def _percent(numerator: Decimal, denominator: Decimal) -> Decimal: + if denominator <= Decimal("0.00"): + return Decimal("0.00") + return ((numerator / denominator) * Decimal("100")).quantize(Decimal("0.01")) + + @staticmethod + def _fmt(value: Decimal) -> str: + return f"{value.quantize(Decimal('0.01'))}" + diff --git a/server/src/app/services/budget_support.py b/server/src/app/services/budget_support.py index 4acc809..4d01e1e 100644 --- a/server/src/app/services/budget_support.py +++ b/server/src/app/services/budget_support.py @@ -310,6 +310,14 @@ class BudgetSupportMixin: } balance = self.get_balance(allocation) + reservation_source_type = self._reservation_source_type_from_claim(claim) + current_reservation = self._find_active_reservation( + source_type=reservation_source_type, + source_id=claim.id, + ) + current_reserved_amount = self._money( + current_reservation.amount if current_reservation is not None else Decimal("0.00") + ) over_budget_amount = max(amount - balance.available_amount, Decimal("0.00")) return { "matched": True, @@ -319,6 +327,7 @@ class BudgetSupportMixin: "claim_amount": str(amount), "total_amount": str(balance.total_amount), "reserved_amount": str(balance.reserved_amount), + "current_reserved_amount": str(current_reserved_amount), "consumed_amount": str(balance.consumed_amount), "available_amount": str(balance.available_amount), "usage_rate": str(balance.usage_rate), @@ -335,6 +344,14 @@ class BudgetSupportMixin: "project_code": allocation.project_code, } + @staticmethod + def _reservation_source_type_from_claim(claim: ExpenseClaim) -> str: + claim_no = str(claim.claim_no or "").strip().upper() + expense_type = str(claim.expense_type or "").strip().lower() + if claim_no.startswith(("AP-", "APP-")) or expense_type == "application" or expense_type.endswith("_application"): + return "application" + return "claim" + def _find_allocation_for_claim(self, claim: ExpenseClaim) -> BudgetAllocation | None: fiscal_year, period_key = self._period_from_claim(claim) return self._find_allocation_for_dimension( diff --git a/server/src/app/services/expense_claim_access_policy.py b/server/src/app/services/expense_claim_access_policy.py index 0d6e0e0..4eb04e1 100644 --- a/server/src/app/services/expense_claim_access_policy.py +++ b/server/src/app/services/expense_claim_access_policy.py @@ -10,14 +10,25 @@ from app.api.deps import CurrentUserContext from app.models.employee import Employee from app.models.financial_record import ExpenseClaim from app.models.organization import OrganizationUnit +from app.models.role import Role +from app.services.expense_claim_workflow_constants import ( + APPROVAL_DONE_STAGE, + ARCHIVE_ACCOUNTING_STAGE, + BUDGET_MANAGER_APPROVAL_STAGE, + DIRECT_MANAGER_APPROVAL_STAGE, + FINANCE_APPROVAL_STAGE, +) PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"} ARCHIVE_CENTER_ROLE_CODES = {"finance", "executive"} APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"} +BUDGET_APPROVAL_ROLE_CODES = {"budget_monitor", "executive"} +BUDGET_MONITOR_ROLE_CODE = "budget_monitor" +BUDGET_MONITOR_APPROVAL_GRADE = "P8" CLAIM_DELETE_ROLE_CODES = {"executive"} ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid") -APPLICATION_ARCHIVED_STAGES = ("审批完成", "申请归档", "completed") +APPLICATION_ARCHIVED_STAGES = (APPROVAL_DONE_STAGE, "申请归档", "completed") class ExpenseClaimAccessPolicy: @@ -49,7 +60,7 @@ class ExpenseClaimAccessPolicy: normalized_type.like("%\\_application", escape="\\"), ) return or_( - stage == "归档入账", + stage == ARCHIVE_ACCOUNTING_STAGE, stage == "completed", and_( application_condition, @@ -61,7 +72,7 @@ class ExpenseClaimAccessPolicy: or_( stage == "", stage.is_(None), - stage == "归档入账", + stage == ARCHIVE_ACCOUNTING_STAGE, stage == "completed", ), ), @@ -77,7 +88,7 @@ class ExpenseClaimAccessPolicy: def is_archived_claim(claim: ExpenseClaim) -> bool: normalized_status = str(claim.status or "").strip().lower() stage = str(claim.approval_stage or "").strip() - if stage in {"归档入账", "completed"}: + if stage in {ARCHIVE_ACCOUNTING_STAGE, "completed"}: return True normalized_type = str(claim.expense_type or "").strip().lower() claim_no = str(claim.claim_no or "").strip().upper() @@ -92,7 +103,7 @@ class ExpenseClaimAccessPolicy: and stage in APPLICATION_ARCHIVED_STAGES ): return True - return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", "归档入账", "completed"} + return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", ARCHIVE_ACCOUNTING_STAGE, "completed"} def can_return_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool: normalized_status = str(claim.status or "").strip().lower() @@ -100,9 +111,11 @@ class ExpenseClaimAccessPolicy: return False stage = str(claim.approval_stage or "").strip() - if stage == "直属领导审批": + if stage == DIRECT_MANAGER_APPROVAL_STAGE: return self.is_current_direct_manager_approver(current_user, claim) - if stage == "财务审批": + if stage == BUDGET_MANAGER_APPROVAL_STAGE: + return self.is_budget_manager_approver(current_user, claim) + if stage == FINANCE_APPROVAL_STAGE: return self.has_privileged_claim_access(current_user) and not self.is_claim_owned_by_current_user( claim, current_user, @@ -111,9 +124,11 @@ class ExpenseClaimAccessPolicy: def can_approve_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool: stage = str(claim.approval_stage or "").strip() - if stage == "直属领导审批": + if stage == DIRECT_MANAGER_APPROVAL_STAGE: return self.is_current_direct_manager_approver(current_user, claim) - if stage == "财务审批": + if stage == BUDGET_MANAGER_APPROVAL_STAGE: + return self.is_budget_manager_approver(current_user, claim) + if stage == FINANCE_APPROVAL_STAGE: role_codes = self.normalize_role_codes(current_user) return ( (current_user.is_admin or "finance" in role_codes) @@ -127,7 +142,7 @@ class ExpenseClaimAccessPolicy: return False if str(claim.status or "").strip().lower() != "submitted": return False - if str(claim.approval_stage or "").strip() != "直属领导审批": + if str(claim.approval_stage or "").strip() != DIRECT_MANAGER_APPROVAL_STAGE: return False current_employee = self.resolve_current_employee(current_user) @@ -149,6 +164,65 @@ class ExpenseClaimAccessPolicy: return self.resolve_claim_manager_name(claim) == approver_name + def is_budget_manager_approver(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool: + if str(claim.status or "").strip().lower() != "submitted": + return False + if str(claim.approval_stage or "").strip() != BUDGET_MANAGER_APPROVAL_STAGE: + return False + if self.is_claim_owned_by_current_user(claim, current_user): + return False + if current_user.is_admin: + return True + role_codes = self.normalize_role_codes(current_user) + if "executive" in role_codes: + return True + return self.is_department_p8_budget_monitor(current_user, claim) + + def is_budget_manager_user(self, current_user: CurrentUserContext) -> bool: + if current_user.is_admin: + return True + role_codes = self.normalize_role_codes(current_user) + return bool(role_codes & BUDGET_APPROVAL_ROLE_CODES) + + def is_department_p8_budget_monitor(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool: + role_codes = self.normalize_role_codes(current_user) + if BUDGET_MONITOR_ROLE_CODE not in role_codes: + return False + + current_employee = self.resolve_current_employee(current_user) + if current_employee is None: + return False + if not self._employee_has_budget_approval_grade(current_employee): + return False + + return self._employee_matches_claim_department(current_employee, current_user, claim) + + def resolve_department_budget_manager(self, claim: ExpenseClaim) -> Employee | None: + department_ids, department_names = self._collect_claim_department_identity(claim) + department_conditions = [] + if department_ids: + department_conditions.append(Employee.organization_unit_id.in_(department_ids)) + if department_names: + department_conditions.append(Employee.organization_unit.has(OrganizationUnit.name.in_(department_names))) + if not department_conditions: + return None + + stmt = ( + select(Employee) + .options(selectinload(Employee.organization_unit), selectinload(Employee.roles)) + .where( + func.upper(func.coalesce(Employee.grade, "")) == BUDGET_MONITOR_APPROVAL_GRADE, + Employee.roles.any(Role.role_code == BUDGET_MONITOR_ROLE_CODE), + or_(*department_conditions), + ) + .order_by(Employee.name.asc(), Employee.employee_no.asc()) + .limit(1) + ) + claim_employee_id = str(claim.employee_id or "").strip() + if claim_employee_id: + stmt = stmt.where(Employee.id != claim_employee_id) + return self.db.scalar(stmt) + @staticmethod def normalize_role_codes(current_user: CurrentUserContext) -> set[str]: return { @@ -157,6 +231,51 @@ class ExpenseClaimAccessPolicy: if str(item).strip() } + @staticmethod + def _employee_has_budget_approval_grade(employee: Employee) -> bool: + return str(employee.grade or "").strip().upper() == BUDGET_MONITOR_APPROVAL_GRADE + + def _employee_matches_claim_department( + self, + employee: Employee, + current_user: CurrentUserContext, + claim: ExpenseClaim, + ) -> bool: + claim_department_ids, claim_department_names = self._collect_claim_department_identity(claim) + employee_department_ids = { + str(employee.organization_unit_id or "").strip(), + } + employee_department_names = { + str(current_user.department_name or "").strip(), + } + if employee.organization_unit is not None: + employee_department_names.add(str(employee.organization_unit.name or "").strip()) + + employee_department_ids.discard("") + employee_department_names.discard("") + return bool( + (claim_department_ids and employee_department_ids & claim_department_ids) + or (claim_department_names and employee_department_names & claim_department_names) + ) + + def _collect_claim_department_identity(self, claim: ExpenseClaim) -> tuple[set[str], set[str]]: + department_ids = { + str(claim.department_id or "").strip(), + } + department_names = { + str(claim.department_name or "").strip(), + } + + claim_employee = self.resolve_claim_employee_for_backfill(claim) + if claim_employee is not None: + department_ids.add(str(claim_employee.organization_unit_id or "").strip()) + if claim_employee.organization_unit is not None: + department_names.add(str(claim_employee.organization_unit.name or "").strip()) + + department_ids.discard("") + department_names.discard("") + return department_ids, department_names + def resolve_current_employee(self, current_user: CurrentUserContext) -> Employee | None: return self.resolve_employee_by_identity_candidates( [ @@ -375,7 +494,7 @@ class ExpenseClaimAccessPolicy: ).strip() pending_leader_approval_parts = [ ExpenseClaim.status == "submitted", - ExpenseClaim.approval_stage == "直属领导审批", + ExpenseClaim.approval_stage == DIRECT_MANAGER_APPROVAL_STAGE, ] if employee is not None: pending_leader_approval_parts.append( @@ -399,17 +518,55 @@ class ExpenseClaimAccessPolicy: return conditions + def build_budget_approval_claim_conditions(self, current_user: CurrentUserContext) -> list[Any]: + role_codes = self.normalize_role_codes(current_user) + if BUDGET_MONITOR_ROLE_CODE not in role_codes: + return [] + employee = self.resolve_current_employee(current_user) + if employee is None or not self._employee_has_budget_approval_grade(employee): + return [] + + pending_budget_approval_parts = [ + ExpenseClaim.status == "submitted", + ExpenseClaim.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE, + ] + pending_budget_approval_parts.append( + or_(ExpenseClaim.employee_id.is_(None), ExpenseClaim.employee_id != employee.id) + ) + if employee.name: + pending_budget_approval_parts.append(ExpenseClaim.employee_name != employee.name) + + department_conditions = [] + department_name = str(current_user.department_name or "").strip() + if employee.organization_unit_id: + department_conditions.append(ExpenseClaim.department_id == employee.organization_unit_id) + subordinate_department_employee_ids = select(Employee.id).where( + Employee.organization_unit_id == employee.organization_unit_id + ) + department_conditions.append(ExpenseClaim.employee_id.in_(subordinate_department_employee_ids)) + if employee.organization_unit is not None and employee.organization_unit.name: + department_conditions.append(ExpenseClaim.department_name == employee.organization_unit.name) + if department_name: + department_conditions.append(ExpenseClaim.department_name == department_name) + if not department_conditions: + return [] + + pending_budget_approval_parts.append(or_(*department_conditions)) + return [and_(*pending_budget_approval_parts)] + def apply_approval_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any: role_codes = self.normalize_role_codes(current_user) if current_user.is_admin or "executive" in role_codes: return stmt.where(ExpenseClaim.status == "submitted") + conditions = [] if "finance" in role_codes: - return stmt.where( + conditions.append(and_( ExpenseClaim.status == "submitted", - ExpenseClaim.approval_stage == "财务审批", - ) + ExpenseClaim.approval_stage == FINANCE_APPROVAL_STAGE, + )) - conditions = self.build_approval_claim_conditions(current_user) + conditions.extend(self.build_budget_approval_claim_conditions(current_user)) + conditions.extend(self.build_approval_claim_conditions(current_user)) if not conditions: return stmt.where(ExpenseClaim.id == "__no_visible_claim__") @@ -440,6 +597,7 @@ class ExpenseClaimAccessPolicy: return stmt.where(ExpenseClaim.id == "__no_visible_claim__") if include_approval_scope: + conditions.extend(self.build_budget_approval_claim_conditions(current_user)) conditions.extend(self.build_approval_claim_conditions(current_user)) return stmt.where(or_(*conditions)) diff --git a/server/src/app/services/expense_claim_application_handoff.py b/server/src/app/services/expense_claim_application_handoff.py index baab82b..f3327cf 100644 --- a/server/src/app/services/expense_claim_application_handoff.py +++ b/server/src/app/services/expense_claim_application_handoff.py @@ -68,7 +68,10 @@ class ExpenseClaimApplicationHandoffMixin: "application_claim_no": application_claim.claim_no, "application_budget_amount": str(application_claim.amount or Decimal("0.00")), "application_approval_event_id": str(approval_flag.get("approval_event_id") or ""), - "leader_opinion": str(approval_flag.get("opinion") or "").strip(), + "leader_opinion": str( + approval_flag.get("leader_opinion") or approval_flag.get("opinion") or "" + ).strip(), + "budget_opinion": str(approval_flag.get("budget_opinion") or "").strip(), "created_at": created_at.isoformat(), } ], diff --git a/server/src/app/services/expense_claim_approval_flow.py b/server/src/app/services/expense_claim_approval_flow.py new file mode 100644 index 0000000..4ff3a7e --- /dev/null +++ b/server/src/app/services/expense_claim_approval_flow.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from typing import Any + +from app.api.deps import CurrentUserContext +from app.services.expense_claim_workflow_constants import ( + APPROVAL_DONE_STAGE, + ARCHIVE_ACCOUNTING_STAGE, + BUDGET_MANAGER_APPROVAL_STAGE, + DIRECT_MANAGER_APPROVAL_STAGE, + FINANCE_APPROVAL_STAGE, +) + + +class ExpenseClaimApprovalFlowMixin: + def approve_claim( + self, + claim_id: str, + current_user: CurrentUserContext, + *, + opinion: str | None = None, + ): + claim = self.get_claim(claim_id, current_user) + if claim is None: + return None + + normalized_status = str(claim.status or "").strip().lower() + if normalized_status != "submitted": + raise ValueError("只有审批中的单据可以审批通过。") + + previous_stage = str(claim.approval_stage or "").strip() + is_application_claim = self._is_expense_application_claim(claim) + next_budget_manager = None + if previous_stage == DIRECT_MANAGER_APPROVAL_STAGE: + if not self._access_policy.can_approve_claim(current_user, claim): + raise ValueError("只有当前直属领导审批人可以审批通过该单据。") + approval_source = "manual_approval" + event_type = "expense_application_approval" if is_application_claim else "expense_claim_approval" + label = "领导审批通过" + if is_application_claim: + next_budget_manager = self._access_policy.resolve_department_budget_manager(claim) + next_status = "submitted" + next_stage = BUDGET_MANAGER_APPROVAL_STAGE + default_message = "{operator} 已确认直属领导审核,流转至预算管理者审批。" + else: + next_status = "submitted" + next_stage = FINANCE_APPROVAL_STAGE + default_message = "{operator} 已审批通过,流转至{next_stage}。" + elif previous_stage == BUDGET_MANAGER_APPROVAL_STAGE: + if not is_application_claim: + raise ValueError("只有费用申请需要预算管理者审批。") + if not self._access_policy.can_approve_claim(current_user, claim): + raise ValueError("只有当前预算管理者可以审批通过该费用申请。") + approval_source = "budget_approval" + event_type = "expense_application_budget_approval" + label = "预算管理者审核通过" + next_status = "approved" + next_stage = APPROVAL_DONE_STAGE + default_message = "{operator} 已完成预算审核,申请流程完成并生成报销草稿。" + elif previous_stage == FINANCE_APPROVAL_STAGE: + if is_application_claim: + raise ValueError("费用申请需先完成预算管理者审批。") + if not self._access_policy.can_approve_claim(current_user, claim): + raise ValueError("只有财务人员可以完成财务终审。") + approval_source = "finance_approval" + event_type = "expense_claim_finance_approval" + label = "财务审核通过" + next_status = "approved" + next_stage = ARCHIVE_ACCOUNTING_STAGE + default_message = "{operator} 已完成财务审核,进入归档入账。" + else: + raise ValueError("当前节点不支持审批通过。") + + approval_opinion = str(opinion or "").strip() + if previous_stage in {DIRECT_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE} and not approval_opinion: + approval_opinion = "同意" + + before_json = self._serialize_claim(claim) + operator = self._access_policy.resolve_current_user_display_name(current_user) + budget_flags: list[dict[str, Any]] = [] + if approval_source == "finance_approval" and not is_application_claim: + consumed_budget_flag = self._consume_budget_for_finance_approval(claim, current_user) + if consumed_budget_flag is not None: + budget_flags.append(consumed_budget_flag) + approval_flag = { + "source": approval_source, + "event_type": event_type, + "approval_event_id": str(uuid.uuid4()), + "severity": "info", + "label": label, + "message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage), + "opinion": approval_opinion, + "operator": operator, + "operator_username": current_user.username, + "operator_role_codes": [ + str(item).strip().lower() + for item in current_user.role_codes + if str(item).strip() + ], + "previous_status": str(claim.status or "").strip(), + "previous_approval_stage": previous_stage, + "next_status": next_status, + "next_approval_stage": next_stage, + "created_at": datetime.now(UTC).isoformat(), + } + if next_budget_manager is not None: + approval_flag.update( + { + "next_approver_name": str(next_budget_manager.name or "").strip(), + "next_approver_employee_id": next_budget_manager.id, + "next_approver_grade": str(next_budget_manager.grade or "").strip(), + "next_approver_role_code": "budget_monitor", + } + ) + + claim.status = next_status + claim.approval_stage = next_stage + if claim.submitted_at is None: + claim.submitted_at = datetime.now(UTC) + if is_application_claim and previous_stage == BUDGET_MANAGER_APPROVAL_STAGE: + approval_flag["leader_opinion"] = self._resolve_latest_approval_opinion( + claim, + source="manual_approval", + ) + approval_flag["budget_opinion"] = approval_opinion + generated_draft = self._create_reimbursement_draft_from_application( + application_claim=claim, + approval_flag=approval_flag, + operator=operator, + ) + transferred_budget_flag = self._transfer_application_budget_to_reimbursement( + application_claim=claim, + draft_claim=generated_draft, + current_user=current_user, + ) + if transferred_budget_flag is not None: + budget_flags.append(transferred_budget_flag) + generated_draft.risk_flags_json = self._append_budget_flags( + generated_draft.risk_flags_json, + transferred_budget_flag, + ) + claim.risk_flags_json = self._append_budget_flags( + [*list(claim.risk_flags_json or []), approval_flag], + budget_flags, + ) + + self.db.commit() + self.db.refresh(claim) + + self.audit_service.log_action( + actor=operator, + action="expense_claim.approve", + resource_type="expense_claim", + resource_id=claim.id, + before_json=before_json, + after_json=self._serialize_claim(claim), + ) + + return claim + + @staticmethod + def _resolve_latest_approval_opinion(claim, *, source: str) -> str: + for flag in reversed(list(claim.risk_flags_json or [])): + if not isinstance(flag, dict): + continue + if str(flag.get("source") or "").strip() != source: + continue + opinion = str(flag.get("opinion") or flag.get("message") or "").strip() + if opinion: + return opinion + return "" diff --git a/server/src/app/services/expense_claim_constants.py b/server/src/app/services/expense_claim_constants.py index 3f5fba4..1ea7f84 100644 --- a/server/src/app/services/expense_claim_constants.py +++ b/server/src/app/services/expense_claim_constants.py @@ -225,6 +225,7 @@ RETURN_REASON_OPTIONS = { "application_budget_basis_missing": "预算测算依据不足", "application_policy_mismatch": "制度口径不匹配", "application_attachment_needed": "前置材料需补充", + "application_other": "其他", } MAX_CLAIM_NO_RETRY_ATTEMPTS = 3 DOCUMENT_DATE_PATTERN = re.compile( diff --git a/server/src/app/services/expense_claim_item_sync.py b/server/src/app/services/expense_claim_item_sync.py index 8af4bf7..c3650fd 100644 --- a/server/src/app/services/expense_claim_item_sync.py +++ b/server/src/app/services/expense_claim_item_sync.py @@ -12,6 +12,7 @@ from sqlalchemy import inspect as sqlalchemy_inspect from app.api.deps import CurrentUserContext from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType from app.models.agent_asset import AgentAsset +from app.models.employee import Employee from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.schemas.reimbursement import TravelReimbursementCalculatorRequest from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager @@ -50,7 +51,7 @@ class ExpenseClaimItemSyncMixin: self._discard_claim_item(claim, item) return - grade = str(claim.employee_grade or "").strip() + grade = self._resolve_claim_employee_grade(claim) if not grade: return @@ -115,6 +116,16 @@ class ExpenseClaimItemSyncMixin: item.item_amount = allowance_amount item.invoice_id = None + def _resolve_claim_employee_grade(self, claim: ExpenseClaim) -> str: + grade = str(claim.employee_grade or "").strip() + if grade: + return grade + employee_id = str(claim.employee_id or "").strip() + if not employee_id: + return "" + employee = self.db.get(Employee, employee_id) + return str(employee.grade if employee is not None and employee.grade else "").strip() + def _discard_claim_item(self, claim: ExpenseClaim, item: ExpenseClaimItem) -> None: if item in claim.items: claim.items.remove(item) diff --git a/server/src/app/services/expense_claim_read_model.py b/server/src/app/services/expense_claim_read_model.py index 89cff40..5bed930 100644 --- a/server/src/app/services/expense_claim_read_model.py +++ b/server/src/app/services/expense_claim_read_model.py @@ -204,6 +204,8 @@ class ExpenseClaimReadModelMixin: normalized = str(stage or "").strip() if "直属" in normalized or "领导" in normalized or "负责人" in normalized: return "direct_manager" + if "预算" in normalized: + return "budget" if "财务" in normalized: return "finance" if "AI" in normalized or "预审" in normalized: diff --git a/server/src/app/services/expense_claim_workflow_constants.py b/server/src/app/services/expense_claim_workflow_constants.py new file mode 100644 index 0000000..74d3ad7 --- /dev/null +++ b/server/src/app/services/expense_claim_workflow_constants.py @@ -0,0 +1,6 @@ +DIRECT_MANAGER_APPROVAL_STAGE = "直属领导审批" +BUDGET_MANAGER_APPROVAL_STAGE = "预算管理者审批" +FINANCE_APPROVAL_STAGE = "财务审批" +APPROVAL_DONE_STAGE = "审批完成" +ARCHIVE_ACCOUNTING_STAGE = "归档入账" + diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index 274930f..850f5e6 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -35,6 +35,7 @@ from app.services.audit import AuditLogService from app.services.document_intelligence import build_document_insight from app.services.document_numbering import is_application_claim_no from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy +from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin @@ -127,6 +128,7 @@ from app.services.ocr import OcrService class ExpenseClaimService( + ExpenseClaimApprovalFlowMixin, ExpenseClaimApplicationHandoffMixin, ExpenseClaimBudgetFlowMixin, ExpenseClaimAttachmentOperationsMixin, @@ -234,6 +236,18 @@ class ExpenseClaimService( stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True) return self.db.scalar(stmt) + def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool: + if claim is None: + return self._access_policy.is_budget_manager_user(current_user) + if current_user.is_admin: + return True + role_codes = self._access_policy.normalize_role_codes(current_user) + if "executive" in role_codes: + return True + if self._access_policy.is_claim_owned_by_current_user(claim, current_user): + return False + return self._access_policy.is_department_p8_budget_monitor(current_user, claim) + def update_claim( self, *, @@ -562,9 +576,6 @@ class ExpenseClaimService( if claim is None: return None - if not self._access_policy.can_return_claim(current_user, claim): - raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。") - normalized_status = str(claim.status or "").strip().lower() if normalized_status == "draft": raise ValueError("草稿状态无需退回。") @@ -573,6 +584,9 @@ class ExpenseClaimService( if normalized_status in {"approved", "completed", "paid"}: raise ValueError("已完成单据不允许退回。") + if not self._access_policy.can_return_claim(current_user, claim): + raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。") + before_json = self._serialize_claim(claim) operator = self._access_policy.resolve_current_user_display_name(current_user) previous_status = str(claim.status or "").strip() @@ -580,21 +594,25 @@ class ExpenseClaimService( 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" + is_budget_return = previous_stage_key == "budget" + is_application_return = is_application_claim and (is_direct_manager_return or is_budget_return) return_event_type = ( "expense_application_return" - if is_application_claim and is_direct_manager_return + if is_application_return else "expense_claim_return" ) return_label = ( "领导退回" if is_application_claim and is_direct_manager_return + else "预算退回" + if is_application_claim and is_budget_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( + if is_application_return and not any( code.startswith("application_") for code in normalized_reason_codes ): raise ValueError("申请单退回必须选择至少一个退单类型。") @@ -627,6 +645,7 @@ class ExpenseClaimService( "reason": return_reason, "opinion": message, "leader_opinion": message if is_application_claim and is_direct_manager_return else "", + "budget_opinion": message if is_application_claim and is_budget_return else "", "reason_codes": normalized_reason_codes, "risk_points": risk_points, "operator": operator, @@ -676,204 +695,6 @@ class ExpenseClaimService( return claim - def approve_claim( - self, - claim_id: str, - current_user: CurrentUserContext, - *, - opinion: str | None = None, - ) -> ExpenseClaim | None: - claim = self.get_claim(claim_id, current_user) - if claim is None: - return None - - normalized_status = str(claim.status or "").strip().lower() - if normalized_status != "submitted": - raise ValueError("只有审批中的单据可以审批通过。") - - previous_stage = str(claim.approval_stage or "").strip() - is_application_claim = self._is_expense_application_claim(claim) - if previous_stage == "直属领导审批": - if not self._access_policy.can_approve_claim(current_user, claim): - raise ValueError("只有当前直属领导审批人可以审批通过该单据。") - approval_source = "manual_approval" - if is_application_claim: - event_type = "expense_application_approval" - label = "领导审批通过" - next_status = "approved" - next_stage = "审批完成" - default_message = "{operator} 已确认审核,申请流程完成并生成报销草稿。" - else: - event_type = "expense_claim_approval" - label = "领导审批通过" - next_status = "submitted" - next_stage = "财务审批" - default_message = "{operator} 已审批通过,流转至{next_stage}。" - elif previous_stage == "财务审批": - if is_application_claim: - raise ValueError("费用申请无需财务审批,直属领导审批通过后即完成。") - if not self._access_policy.can_approve_claim(current_user, claim): - raise ValueError("只有财务人员可以完成财务终审。") - approval_source = "finance_approval" - event_type = "expense_claim_finance_approval" - label = "财务审核通过" - next_status = "approved" - next_stage = "归档入账" - default_message = "{operator} 已完成财务审核,进入归档入账。" - else: - raise ValueError("当前节点不支持审批通过。") - - approval_opinion = str(opinion or "").strip() - if previous_stage == "直属领导审批" and not approval_opinion: - raise ValueError("领导审核意见不能为空,请填写意见后再确认审核。") - - before_json = self._serialize_claim(claim) - operator = self._access_policy.resolve_current_user_display_name(current_user) - budget_flags: list[dict[str, Any]] = [] - if approval_source == "finance_approval" and not is_application_claim: - consumed_budget_flag = self._consume_budget_for_finance_approval(claim, current_user) - if consumed_budget_flag is not None: - budget_flags.append(consumed_budget_flag) - approval_flag = { - "source": approval_source, - "event_type": event_type, - "approval_event_id": str(uuid.uuid4()), - "severity": "info", - "label": label, - "message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage), - "opinion": approval_opinion, - "operator": operator, - "operator_username": current_user.username, - "operator_role_codes": [ - str(item).strip().lower() - for item in current_user.role_codes - if str(item).strip() - ], - "previous_status": str(claim.status or "").strip(), - "previous_approval_stage": previous_stage, - "next_status": next_status, - "next_approval_stage": next_stage, - "created_at": datetime.now(UTC).isoformat(), - } - - claim.status = next_status - claim.approval_stage = next_stage - if claim.submitted_at is None: - claim.submitted_at = datetime.now(UTC) - if is_application_claim and previous_stage == "直属领导审批": - generated_draft = self._create_reimbursement_draft_from_application( - application_claim=claim, - approval_flag=approval_flag, - operator=operator, - ) - transferred_budget_flag = self._transfer_application_budget_to_reimbursement( - application_claim=claim, - draft_claim=generated_draft, - current_user=current_user, - ) - if transferred_budget_flag is not None: - budget_flags.append(transferred_budget_flag) - generated_draft.risk_flags_json = self._append_budget_flags( - generated_draft.risk_flags_json, - transferred_budget_flag, - ) - claim.risk_flags_json = self._append_budget_flags( - [*list(claim.risk_flags_json or []), approval_flag], - budget_flags, - ) - - self.db.commit() - self.db.refresh(claim) - - self.audit_service.log_action( - actor=operator, - action="expense_claim.approve", - resource_type="expense_claim", - resource_id=claim.id, - before_json=before_json, - after_json=self._serialize_claim(claim), - ) - - return claim - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/server/src/app/services/knowledge.py b/server/src/app/services/knowledge.py index 48cc4a9..d4dc797 100644 --- a/server/src/app/services/knowledge.py +++ b/server/src/app/services/knowledge.py @@ -602,7 +602,15 @@ class KnowledgeService: status_payload = status_map.get(document_id) or {} rag_status = str(status_payload.get("status") or "").strip().lower() linked_run_status = resolve_linked_ingest_run_status(entry, db=self.db) - if linked_run_status == AgentRunStatus.FAILED.value and rag_status in { + if not status_payload: + if ( + current_status == KNOWLEDGE_INGEST_STATUS_SYNCING + and linked_run_status == AgentRunStatus.FAILED.value + ): + desired_status = KNOWLEDGE_INGEST_STATUS_FAILED + else: + continue + elif linked_run_status == AgentRunStatus.FAILED.value and rag_status in { "pending", "processing", "preprocessed", diff --git a/server/src/app/services/knowledge_rag.py b/server/src/app/services/knowledge_rag.py index 684aaf7..2d36fd9 100644 --- a/server/src/app/services/knowledge_rag.py +++ b/server/src/app/services/knowledge_rag.py @@ -4,8 +4,9 @@ import os import re import socket import threading +from concurrent.futures import ThreadPoolExecutor from pathlib import Path -from typing import Any +from typing import Any, Callable from sqlalchemy.orm import Session @@ -89,8 +90,10 @@ STRUCTURED_APPENDIX_LEADING_MARKERS = ( ) STRUCTURED_APPENDIX_LEADING_WINDOW = 220 _runtime_lock = threading.RLock() -_runtime_instances: dict[int, _LightRagRuntime] = {} -_runtime_signatures: dict[int, tuple[Any, ...]] = {} +_runtime_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="knowledge-rag-runtime") +_runtime_instances: dict[str, _LightRagRuntime] = {} +_runtime_signatures: dict[str, tuple[Any, ...]] = {} +_RUNTIME_CACHE_KEY = "lightrag" class KnowledgeRagService: @@ -133,21 +136,26 @@ class KnowledgeRagService: runtime_hits: list[dict[str, Any]] = [] runtime_references: list[str] = [] - try: - runtime = self._get_runtime() - raw = runtime.query_data(rewritten_query, conversation_history=conversation_history) - data = raw.get("data") if isinstance(raw, dict) else {} - chunks = list(data.get("chunks") or []) if isinstance(data, dict) else [] - entities = list(data.get("entities") or []) if isinstance(data, dict) else [] - runtime_references = list(data.get("references") or []) if isinstance(data, dict) else [] - runtime_hits = self._build_hits_from_query_data( - query=rewritten_query, - chunks=chunks, - entities=entities, - limit=limit, - ) - except Exception as exc: - logger.warning("Knowledge query failed: %s", exc) + if not local_result.confident: + try: + raw = self._run_runtime_operation( + lambda runtime: runtime.query_data( + rewritten_query, + conversation_history=conversation_history, + ) + ) + data = raw.get("data") if isinstance(raw, dict) else {} + chunks = list(data.get("chunks") or []) if isinstance(data, dict) else [] + entities = list(data.get("entities") or []) if isinstance(data, dict) else [] + runtime_references = list(data.get("references") or []) if isinstance(data, dict) else [] + runtime_hits = self._build_hits_from_query_data( + query=rewritten_query, + chunks=chunks, + entities=entities, + limit=limit, + ) + except Exception as exc: + logger.warning("Knowledge query failed: %s", exc) all_hits: dict[str, dict[str, Any]] = {} for hit in local_result.hits: @@ -189,7 +197,7 @@ class KnowledgeRagService: ], "raw_references": runtime_references, "metadata": { - "retrieval_strategy": "fusion", + "retrieval_strategy": "fusion" if runtime_hits else "local_text_chunks", "local_total_chunks": local_result.total_chunks, "local_best_score": local_result.best_score, }, @@ -244,14 +252,17 @@ class KnowledgeRagService: file_paths: list[str] = [] document_summaries: list[dict[str, Any]] = [] - runtime = self._get_runtime() - existing_statuses = runtime.get_document_statuses(normalized_ids) + existing_statuses = self._run_runtime_operation( + lambda runtime: runtime.get_document_statuses(normalized_ids) + ) for document_id in normalized_ids: entry = knowledge_service.get_document_entry(document_id) if force and document_id in existing_statuses: try: - runtime.delete_document(document_id) + self._run_runtime_operation( + lambda runtime, target_id=document_id: runtime.delete_document(target_id) + ) except Exception as exc: logger.warning( "Delete existing LightRAG document failed doc_id=%s: %s", document_id, exc @@ -277,13 +288,17 @@ class KnowledgeRagService: ) ) - track_id = runtime.insert_documents( - texts=texts, - document_ids=normalized_ids, - file_paths=file_paths, + track_id = self._run_runtime_operation( + lambda runtime: runtime.insert_documents( + texts=texts, + document_ids=normalized_ids, + file_paths=file_paths, + ) ) - statuses = runtime.get_document_statuses(normalized_ids) + statuses = self._run_runtime_operation( + lambda runtime: runtime.get_document_statuses(normalized_ids) + ) succeeded_document_ids: list[str] = [] failed_documents: list[dict[str, str]] = [] summary_by_id = { @@ -344,7 +359,9 @@ class KnowledgeRagService: if not target_ids: return {} try: - statuses = self._get_runtime().get_document_statuses(target_ids) + statuses = self._run_runtime_operation( + lambda runtime: runtime.get_document_statuses(target_ids) + ) except Exception as exc: logger.warning("Load LightRAG document statuses failed: %s", exc) return {} @@ -358,16 +375,40 @@ class KnowledgeRagService: if not normalized_id: return try: - self._get_runtime().delete_document(normalized_id) + self._run_runtime_operation( + lambda runtime: runtime.delete_document(normalized_id) + ) except Exception as exc: logger.warning("Delete LightRAG document ignored doc_id=%s: %s", normalized_id, exc) - def _get_runtime(self) -> _LightRagRuntime: + def _run_runtime_operation(self, operation: Callable[[_LightRagRuntime], Any]) -> Any: signature, runtime_kwargs = self._build_runtime_signature() - thread_id = threading.get_ident() + return _runtime_executor.submit( + self._execute_runtime_operation, + signature, + runtime_kwargs, + operation, + ).result() + + def _execute_runtime_operation( + self, + signature: tuple[Any, ...], + runtime_kwargs: dict[str, Any], + operation: Callable[[_LightRagRuntime], Any], + ) -> Any: + return operation(self._get_runtime(signature=signature, runtime_kwargs=runtime_kwargs)) + + def _get_runtime( + self, + *, + signature: tuple[Any, ...] | None = None, + runtime_kwargs: dict[str, Any] | None = None, + ) -> _LightRagRuntime: + if signature is None or runtime_kwargs is None: + signature, runtime_kwargs = self._build_runtime_signature() with _runtime_lock: - runtime = _runtime_instances.get(thread_id) - if runtime is not None and _runtime_signatures.get(thread_id) == signature: + runtime = _runtime_instances.get(_RUNTIME_CACHE_KEY) + if runtime is not None and _runtime_signatures.get(_RUNTIME_CACHE_KEY) == signature: return runtime if runtime is not None: @@ -377,8 +418,8 @@ class KnowledgeRagService: logger.warning("Finalize previous LightRAG runtime failed: %s", exc) runtime = _LightRagRuntime(**runtime_kwargs) - _runtime_instances[thread_id] = runtime - _runtime_signatures[thread_id] = signature + _runtime_instances[_RUNTIME_CACHE_KEY] = runtime + _runtime_signatures[_RUNTIME_CACHE_KEY] = signature return runtime def _build_runtime_signature(self) -> tuple[tuple[Any, ...], dict[str, Any]]: @@ -633,6 +674,10 @@ class KnowledgeRagService: def shutdown_knowledge_rag_runtime() -> None: + _runtime_executor.submit(_shutdown_runtime_instances).result() + + +def _shutdown_runtime_instances() -> None: with _runtime_lock: for runtime in list(_runtime_instances.values()): try: diff --git a/server/src/app/services/knowledge_rag_runtime.py b/server/src/app/services/knowledge_rag_runtime.py index dea2222..6eeb269 100644 --- a/server/src/app/services/knowledge_rag_runtime.py +++ b/server/src/app/services/knowledge_rag_runtime.py @@ -229,7 +229,13 @@ class _LightRagRuntime: raise KnowledgeRagError(str(getattr(result, "message", "") or "LightRAG 删除文档失败。")) def _probe_embedding_dimension(self, config: RuntimeModelConfig) -> int: - vectors = self._request_embeddings(config, ["dimension probe"]) + try: + vectors = self._request_embeddings(config, ["dimension probe"]) + except Exception as exc: + raise KnowledgeRagError( + "Embedding model probe failed " + f"(slot={config.slot}, provider={config.provider}, model={config.model}): {exc}" + ) from exc if not vectors or not isinstance(vectors[0], list): raise KnowledgeRagError("无法从 embedding 模型返回结果中解析向量维度。") dimension = len(vectors[0]) diff --git a/server/src/app/services/settings.py b/server/src/app/services/settings.py index c5131a9..33b4d7d 100644 --- a/server/src/app/services/settings.py +++ b/server/src/app/services/settings.py @@ -335,7 +335,12 @@ class SettingsService: for model_row in model_rows.values(): self.db.refresh(model_row) - return self._serialize(settings_row, secrets_row, model_rows) + return self._serialize( + settings_row, + secrets_row, + model_rows, + self._build_hermes_form_snapshot(), + ) def load_saved_model_api_key(self, slot: str | None) -> str: if not slot or slot not in MODEL_SLOT_CONFIGS: @@ -748,7 +753,11 @@ class SettingsService: hermesForm=hermes_form, llmForm={ "mainProvider": main_model.provider, - "backupProvider": backup_model.provider, + "mainModel": main_model.model_name, + "mainEndpoint": main_model.endpoint, + "mainApiKey": "", + "mainApiKeyConfigured": bool(main_model.api_key_encrypted), + "backupProvider": backup_model.provider, "backupModel": backup_model.model_name, "backupEndpoint": backup_model.endpoint, "backupApiKey": "", diff --git a/server/src/app/services/user_agent.py b/server/src/app/services/user_agent.py index 295ba81..7630458 100644 --- a/server/src/app/services/user_agent.py +++ b/server/src/app/services/user_agent.py @@ -148,22 +148,7 @@ class UserAgentService( requires_confirmation=payload.requires_confirmation, ) - fast_knowledge_answer = self._build_fast_knowledge_answer( - payload, - citations=citations, - ) - if fast_knowledge_answer: - return UserAgentResponse( - answer=fast_knowledge_answer, - citations=citations, - suggested_actions=suggested_actions, - query_payload=query_payload, - draft_payload=draft_payload, - review_payload=review_payload, - risk_flags=risk_flags, - requires_confirmation=payload.requires_confirmation, - ) - + # 知识库问答必须优先让模型基于召回证据组织答案,避免片段渲染抢答导致答非所问。 fallback_answer = self._build_fallback_answer( payload, citations=citations, diff --git a/server/src/app/services/user_agent_knowledge.py b/server/src/app/services/user_agent_knowledge.py index f338688..71043c2 100644 --- a/server/src/app/services/user_agent_knowledge.py +++ b/server/src/app/services/user_agent_knowledge.py @@ -86,7 +86,6 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin): *, citations: list[UserAgentCitation], ) -> str | None: - return None if payload.ontology.scenario != "knowledge": return None if str(payload.tool_payload.get("result_type") or "").strip() != "knowledge_search": @@ -130,7 +129,10 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin): primary_heading = self._format_knowledge_heading_label( str(primary_item.get("heading") or "").strip() ) - primary_lines = self._collect_direct_knowledge_answer_lines(ordered_evidence_items) + primary_lines = self._collect_direct_knowledge_answer_lines( + ordered_evidence_items, + query_terms=query_terms, + ) lines: list[str] = [] if user_name: @@ -139,20 +141,42 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin): if primary_heading: source_prefix = f"{source_prefix}({primary_heading})" + conclusion_lines: list[str] = [] + evidence_lines: list[str] = [] if str(primary_item.get("kind") or "") == "table": - lines.append(f"{source_prefix},当前能直接确认的是:") - lines.append(self._extract_relevant_table_preview(str(primary_item.get("content") or ""), query_terms)) + table_content = str(primary_item.get("content") or "") + if self._question_requests_broad_knowledge_table(question): + table_preview = table_content.strip() + else: + table_preview = self._extract_relevant_table_preview( + table_content, + query_terms, + preferred_terms=self._build_knowledge_table_preferred_terms(payload), + ) + table_summary = self._summarize_knowledge_table_preview(table_preview) + conclusion_lines.append(f"{source_prefix},{table_summary}") + evidence_lines.append(table_preview) else: if not primary_lines: - lines.append( + summary = self._summarize_knowledge_evidence_content(primary_item, query_terms) + conclusion_lines.append( f"{source_prefix},当前能直接确认的是:" - f"{self._summarize_knowledge_evidence_content(primary_item, query_terms)}" + f"{summary}" ) elif len(primary_lines) == 1: - lines.append(f"{source_prefix},当前能直接确认的是:{primary_lines[0].strip()}") + conclusion_lines.append(f"{source_prefix},当前能直接确认的是:{primary_lines[0].strip()}") + evidence_lines.extend(primary_lines) else: - lines.append(f"{source_prefix},当前能直接确认的是:") - lines.extend(primary_lines) + subject = self._build_knowledge_answer_subject(question, primary_heading) + summary = self._summarize_knowledge_lines_conclusion( + primary_lines, + heading=subject, + ) + if summary: + conclusion_lines.append(f"{source_prefix},{summary}") + else: + conclusion_lines.append(f"{source_prefix},当前能直接确认的是:") + evidence_lines.extend(primary_lines) notes: list[str] = [] location_note = self._build_missing_location_grounding_note(question, evidence_items) @@ -161,14 +185,64 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin): if self._question_requires_explicit_condition(question) and not self._answer_evidence_has_numeric_or_condition(evidence_items): notes.append("当前命中的证据更偏规则说明或流程约束,还没有直接给出可立即套用的数值或完整条件。") + self._append_markdown_section(lines, "结论", conclusion_lines) + self._append_markdown_section(lines, "依据", evidence_lines) if notes: - lines.append("") - lines.append("说明:") - lines.extend(f"- {note}" for note in notes) + self._append_markdown_section(lines, "说明", [f"- {note}" for note in notes]) return "\n".join(line for line in lines if line is not None).strip() + @staticmethod + def _append_markdown_section(lines: list[str], title: str, body_lines: list[str]) -> None: + cleaned = [str(line or "").rstrip() for line in body_lines if str(line or "").strip()] + if not cleaned: + return + if lines and lines[-1] != "": + lines.append("") + lines.append(f"## {title}") + lines.append("") + lines.extend(cleaned) + + + @staticmethod + def _build_knowledge_answer_subject(question: str, heading: str = "") -> str: + clean_heading = str(heading or "").strip() + if clean_heading and not any( + marker in clean_heading + for marker in ("问答线索补充", "结构化表格补充", "重点章节摘录", "章节导航") + ): + return clean_heading + + normalized = re.sub(r"\s+", "", str(question or "").strip()) + normalized = re.sub(r"[??。.!!]+$", "", normalized) + normalized = re.sub(r"(是什么|有哪些|是多少|如何|怎么|吗|呢)$", "", normalized) + return normalized.strip("::,,。.") + + + @staticmethod + def _build_knowledge_table_preferred_terms(payload: UserAgentRequest) -> list[str]: + terms: list[str] = [] + context = payload.context_json or {} + for key in ("grade", "position", "job_grade", "rank", "level"): + value = str(context.get(key) or "").strip() + if value and value not in terms: + terms.append(value) + + grade_match = re.fullmatch(r"[Pp](\d{1,2})", str(context.get("grade") or "").strip()) + if grade_match: + grade = int(grade_match.group(1)) + for start in range(max(0, grade - 4), grade + 1): + for end in range(grade, min(12, grade + 4) + 1): + if start >= end: + continue + for separator in ("~", "~", "-", "至"): + term = f"P{start}{separator}P{end}" + if term not in terms: + terms.append(term) + return terms + + @staticmethod def _resolve_knowledge_question(payload: UserAgentRequest) -> str: return str(payload.context_json.get("user_input_text") or payload.message or "").strip() @@ -484,6 +558,8 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin): def _collect_direct_knowledge_answer_lines( self, ordered_evidence_items: list[dict[str, Any]], + *, + query_terms: list[str] | None = None, ) -> list[str]: if not ordered_evidence_items: return [] @@ -509,8 +585,18 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin): lines: list[str] = [] seen: set[str] = set() for item in related_items: - rendered = self._render_knowledge_evidence_text(item) - for line in rendered.splitlines(): + item_kind = str(item.get("kind") or "").strip() + item_content = str(item.get("content") or "") + if item_kind == "paragraph" or self._has_inline_numbered_knowledge_items(item_content): + rendered = self._focus_knowledge_segment_content( + item_content, + query_terms or [], + ) + rendered_lines = self._split_inline_numbered_knowledge_items(rendered) + else: + rendered = self._render_knowledge_evidence_text(item) + rendered_lines = rendered.splitlines() + for line in rendered_lines: normalized = str(line or "").strip() if not normalized or normalized in seen: continue @@ -573,13 +659,21 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin): or "相关制度" ).strip() user_name = str(payload.context_json.get("name") or "").strip() - prefix = f"{user_name},您好。\n" if user_name else "" + answer_lines: list[str] = [] + if user_name: + answer_lines.append(f"{user_name},您好。") if not hits: - return ( - f"{prefix}我已经从《{title}》中检索到与你这次问题相关的制度依据," - "但本次答案生成环节暂时没有成功返回。请稍后重试一次;如果仍然失败," - "建议先检查主对话模型的连通性。" + self._append_markdown_section( + answer_lines, + "结论", + [f"当前没有拿到可用于回答这个问题的《{title}》知识库命中。"], ) + self._append_markdown_section( + answer_lines, + "说明", + ["- 我不会用相似主题或外部常识硬凑答案;请补充更具体的关键词后再试一次。"], + ) + return "\n".join(answer_lines).strip() evidence_lines: list[str] = [] for item in evidence_items[:3]: @@ -614,19 +708,28 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin): evidence_lines.append(f"- **《{item_title}》**:{excerpt}") if not evidence_lines: - return ( - f"{prefix}当前《{title}》里可用于回答的关键条款还不够明确。" - "请补充费用类型、适用地区、职级或具体业务场景,我再继续帮你缩小范围。" + self._append_markdown_section( + answer_lines, + "结论", + [f"当前《{title}》里可用于回答这个问题的关键条款还不够明确。"], ) + self._append_markdown_section( + answer_lines, + "说明", + ["- 请补充费用类型、适用地区、职级或具体业务场景,我再继续帮你缩小范围。"], + ) + return "\n".join(answer_lines).strip() - return "\n".join( - [ - f"{prefix}我先根据当前制度依据给出可以确认的部分。", - "", - "**依据**:", - *evidence_lines, - "", - "**说明**:以上只使用当前命中的知识库证据;没有在证据中出现的适用条件或金额,我不会替你默认补齐。", - ] - ).strip() + self._append_markdown_section( + answer_lines, + "结论", + ["我先根据当前制度依据给出可以确认的部分。"], + ) + self._append_markdown_section(answer_lines, "依据", evidence_lines) + self._append_markdown_section( + answer_lines, + "说明", + ["- 以上只使用当前命中的知识库证据;没有在证据中出现的适用条件或金额,我不会替你默认补齐。"], + ) + return "\n".join(answer_lines).strip() diff --git a/server/src/app/services/user_agent_response.py b/server/src/app/services/user_agent_response.py index d25dd66..a315f73 100644 --- a/server/src/app/services/user_agent_response.py +++ b/server/src/app/services/user_agent_response.py @@ -280,10 +280,15 @@ class UserAgentResponseMixin: if payload.ontology.scenario == "knowledge": answer_style_instruction = ( "你是财务制度知识问答助手。只能依据 facts.tool_payload.hits、facts.knowledge_answer_evidence、citations 与 conversation_history 回答," - "不要扩展成通用助手。优先直接回答,不要复述思考过程,不要输出 JSON、代码块或 。" + "不要扩展成通用助手。优先直接回答,不要复述思考过程,不要输出 JSON、代码块或可见思考过程。" + "禁止使用“已命中”“答案整理阶段”“稍后重试”。" + "最终答复必须使用 Markdown,优先包含“## 结论”“## 依据”“## 说明”这三个二级标题;" + "如果某一部分没有内容,可以省略该标题。" "回答风格要像一位真正熟悉制度的财务伙伴:先直接回应用户的核心问题,再用一张简洁表格或短段落说明依据," "最后补充最重要的注意事项。不要写成“已检索到内容”的系统回执,也不要把命中片段连缀成答案。" "必须优先回答用户当前这句话本身,不能把制度标题、制度全文或完整标准表当成主答案。" + "回答前先判断召回内容是否真的能回答当前问题;如果不能,必须明确说当前知识库没有找到直接依据," + "不要改答相邻主题,也不要用相似条款硬凑答案。" "如果用户问的是某次具体行程“一共能报多少”,就先给“当前已能确认的金额”,再用一张很短的表说明项目、" "适用标准、计算式和结果;如果总额还缺少住宿晚数、实际票据或其他必要条件,就明确写出“暂不能确认的部分”。" "只有用户明确在问“标准有哪些”或“制度全文怎么规定”时,才展开完整标准表。" @@ -488,7 +493,7 @@ class UserAgentResponseMixin: citations: list[UserAgentCitation], ) -> str: if str(payload.tool_payload.get("result_type") or "").strip() == "knowledge_search": - if citations: + if citations or list(payload.tool_payload.get("hits") or []): return self._build_knowledge_search_answer(payload, citations) tool_message = str(payload.tool_payload.get("message") or "").strip() diff --git a/server/storage/knowledge/.index.json b/server/storage/knowledge/.index.json index 47aeb04..c75a27c 100644 --- a/server/storage/knowledge/.index.json +++ b/server/storage/knowledge/.index.json @@ -14,13 +14,13 @@ "updated_at": "2026-05-17T09:28:28.999515+00:00", "uploaded_by": "admin", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:08.579777+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-17T10:01:33.272539+00:00", "ingest_completed_at": "2026-05-17T10:01:33.272539+00:00", "ingest_document_name": "远光《公司支出管理办法(2024)》.pdf", "ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00", "ingest_document_sha256": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece", - "ingest_agent_run_id": "run_7236fb72747742a3" + "ingest_agent_run_id": "run_b5984bade5324755" }, { "id": "c7601043d9944ef2bcf4d3f67ed253f7", @@ -35,8 +35,8 @@ "updated_at": "2026-05-22T07:00:22.328877+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:09.863684+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T09:22:25.565409+00:00", "ingest_completed_at": "2026-05-22T09:22:25.565409+00:00", "ingest_document_name": "远光软件会计科目使用说明.xlsx", "ingest_document_updated_at": "2026-05-22T07:00:22.328877+00:00", @@ -56,8 +56,8 @@ "updated_at": "2026-05-22T07:00:22.011016+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:00:50.652735+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-23T14:30:33.605531+00:00", "ingest_completed_at": "2026-05-23T14:30:33.605531+00:00", "ingest_document_name": "远光软件财务基础知识手册.docx", "ingest_document_updated_at": "2026-05-22T07:00:22.011016+00:00", @@ -77,8 +77,8 @@ "updated_at": "2026-05-22T07:00:22.352133+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:00:51.908821+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T09:23:11.334499+00:00", "ingest_completed_at": "2026-05-22T09:23:11.334499+00:00", "ingest_document_name": "远光软件财务术语解释手册.docx", "ingest_document_updated_at": "2026-05-22T07:00:22.352133+00:00", @@ -98,8 +98,8 @@ "updated_at": "2026-05-22T07:00:22.304623+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:13.581834+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T09:24:18.933073+00:00", "ingest_completed_at": "2026-05-22T09:24:18.933073+00:00", "ingest_document_name": "远光软件高新技术企业税收优惠政策汇总.pdf", "ingest_document_updated_at": "2026-05-22T07:00:22.304623+00:00", @@ -119,8 +119,8 @@ "updated_at": "2026-05-22T07:00:18.153373+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:00:53.906324+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:01:43.168774+00:00", "ingest_completed_at": "2026-05-22T16:01:43.168774+00:00", "ingest_document_name": "远光软件公司内部控制基本规范.pdf", "ingest_document_updated_at": "2026-05-22T07:00:18.153373+00:00", @@ -140,8 +140,8 @@ "updated_at": "2026-05-22T07:00:18.190399+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:00:55.339114+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:03:00.735908+00:00", "ingest_completed_at": "2026-05-22T16:03:00.735908+00:00", "ingest_document_name": "远光软件公司合同管理制度.docx", "ingest_document_updated_at": "2026-05-22T07:00:18.190399+00:00", @@ -161,8 +161,8 @@ "updated_at": "2026-05-22T07:00:17.798679+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:00:56.741808+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:03:46.921675+00:00", "ingest_completed_at": "2026-05-22T16:03:46.921675+00:00", "ingest_document_name": "远光软件公司财务管理制度总则.docx", "ingest_document_updated_at": "2026-05-22T07:00:17.798679+00:00", @@ -182,8 +182,8 @@ "updated_at": "2026-05-22T07:00:18.531598+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:19.014702+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:04:58.719410+00:00", "ingest_completed_at": "2026-05-22T16:04:58.719410+00:00", "ingest_document_name": "远光软件公司资产管理制度.pdf", "ingest_document_updated_at": "2026-05-22T07:00:18.531598+00:00", @@ -203,8 +203,8 @@ "updated_at": "2026-05-22T07:00:18.221073+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:00:59.485821+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:06:08.172318+00:00", "ingest_completed_at": "2026-05-22T16:06:08.172318+00:00", "ingest_document_name": "远光软件公司采购管理办法.xlsx", "ingest_document_updated_at": "2026-05-22T07:00:18.221073+00:00", @@ -224,8 +224,8 @@ "updated_at": "2026-05-22T07:00:19.734422+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:00.774887+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:06:48.466110+00:00", "ingest_completed_at": "2026-05-22T16:06:48.466110+00:00", "ingest_document_name": "远光软件公司差旅费管理办法.docx", "ingest_document_updated_at": "2026-05-22T07:00:19.734422+00:00", @@ -245,8 +245,8 @@ "updated_at": "2026-05-22T07:00:20.095824+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:02.037101+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:07:23.262328+00:00", "ingest_completed_at": "2026-05-22T16:07:23.262328+00:00", "ingest_document_name": "远光软件出差审批流程说明.pdf", "ingest_document_updated_at": "2026-05-22T07:00:20.095824+00:00", @@ -266,8 +266,8 @@ "updated_at": "2026-05-22T07:00:20.128471+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:24.076574+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:08:02.190081+00:00", "ingest_completed_at": "2026-05-22T16:08:02.190081+00:00", "ingest_document_name": "远光软件国际出差管理规定.docx", "ingest_document_updated_at": "2026-05-22T07:00:20.128471+00:00", @@ -287,8 +287,8 @@ "updated_at": "2026-05-22T07:00:19.759954+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:25.270086+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:09:23.091744+00:00", "ingest_completed_at": "2026-05-22T16:09:23.091744+00:00", "ingest_document_name": "远光软件差旅费标准速查表.xlsx", "ingest_document_updated_at": "2026-05-22T07:00:19.759954+00:00", @@ -308,8 +308,8 @@ "updated_at": "2026-05-22T07:00:18.922298+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:26.510710+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:11:04.764727+00:00", "ingest_completed_at": "2026-05-22T16:11:04.764727+00:00", "ingest_document_name": "远光软件公司发票审核标准.xlsx", "ingest_document_updated_at": "2026-05-22T07:00:18.922298+00:00", @@ -329,8 +329,8 @@ "updated_at": "2026-05-22T07:00:18.560177+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:06.719118+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:11:54.017817+00:00", "ingest_completed_at": "2026-05-22T16:11:54.017817+00:00", "ingest_document_name": "远光软件公司发票管理规范.docx", "ingest_document_updated_at": "2026-05-22T07:00:18.560177+00:00", @@ -350,8 +350,8 @@ "updated_at": "2026-05-22T07:00:18.888128+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:28.865726+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:12:23.821434+00:00", "ingest_completed_at": "2026-05-22T16:12:23.821434+00:00", "ingest_document_name": "远光软件公司增值税发票操作指南.pdf", "ingest_document_updated_at": "2026-05-22T07:00:18.888128+00:00", @@ -371,8 +371,8 @@ "updated_at": "2026-05-22T07:00:18.953110+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:30.095619+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:13:15.450300+00:00", "ingest_completed_at": "2026-05-22T16:13:15.450300+00:00", "ingest_document_name": "远光软件公司电子发票管理办法.docx", "ingest_document_updated_at": "2026-05-22T07:00:18.953110+00:00", @@ -392,8 +392,8 @@ "updated_at": "2026-05-22T07:00:21.585718+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:09.790447+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:13:44.636629+00:00", "ingest_completed_at": "2026-05-22T16:13:44.636629+00:00", "ingest_document_name": "远光软件企业所得税汇算清缴操作手册.pdf", "ingest_document_updated_at": "2026-05-22T07:00:21.585718+00:00", @@ -413,8 +413,8 @@ "updated_at": "2026-05-22T07:00:20.881351+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:11.027818+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:14:50.092490+00:00", "ingest_completed_at": "2026-05-22T16:14:50.092490+00:00", "ingest_document_name": "远光软件公司税务管理制度.docx", "ingest_document_updated_at": "2026-05-22T07:00:20.881351+00:00", @@ -434,8 +434,8 @@ "updated_at": "2026-05-22T07:00:21.606227+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:33.826025+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:15:56.676286+00:00", "ingest_completed_at": "2026-05-22T16:15:56.676286+00:00", "ingest_document_name": "远光软件研发费用加计扣除管理办法.xlsx", "ingest_document_updated_at": "2026-05-22T07:00:21.606227+00:00", @@ -455,8 +455,8 @@ "updated_at": "2026-05-22T07:00:21.202633+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:13.991763+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:16:06.540773+00:00", "ingest_completed_at": "2026-05-22T16:16:06.540773+00:00", "ingest_document_name": "远光软件软件产品增值税即征即退操作指南.pdf", "ingest_document_updated_at": "2026-05-22T07:00:21.202633+00:00", @@ -476,8 +476,8 @@ "updated_at": "2026-05-22T07:00:22.379307+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:15.257700+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:23:24.252614+00:00", "ingest_completed_at": "2026-05-22T16:23:24.252614+00:00", "ingest_document_name": "远光软件公司预算管理制度.docx", "ingest_document_updated_at": "2026-05-22T07:00:22.379307+00:00", @@ -497,8 +497,8 @@ "updated_at": "2026-05-22T07:00:22.760169+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:16.510610+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:23:29.997956+00:00", "ingest_completed_at": "2026-05-22T16:23:29.997956+00:00", "ingest_document_name": "远光软件年度预算编制指南.pdf", "ingest_document_updated_at": "2026-05-22T07:00:22.760169+00:00", @@ -518,8 +518,8 @@ "updated_at": "2026-05-22T07:00:22.848272+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:38.728430+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:24:37.382612+00:00", "ingest_completed_at": "2026-05-22T16:24:37.382612+00:00", "ingest_document_name": "远光软件预算执行分析报告模板.docx", "ingest_document_updated_at": "2026-05-22T07:00:22.848272+00:00", @@ -539,8 +539,8 @@ "updated_at": "2026-05-22T07:00:22.803708+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:19.050297+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:24:45.161319+00:00", "ingest_completed_at": "2026-05-22T16:24:45.161319+00:00", "ingest_document_name": "远光软件预算编制模板.xlsx", "ingest_document_updated_at": "2026-05-22T07:00:22.803708+00:00", @@ -560,8 +560,8 @@ "updated_at": "2026-05-22T07:00:21.971983+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:20.323058+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:25:33.968414+00:00", "ingest_completed_at": "2026-05-22T16:25:33.968414+00:00", "ingest_document_name": "远光软件财务共享服务SLA标准.xlsx", "ingest_document_updated_at": "2026-05-22T07:00:21.971983+00:00", @@ -581,8 +581,8 @@ "updated_at": "2026-05-22T07:00:21.634300+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:21.585474+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:26:05.301987+00:00", "ingest_completed_at": "2026-05-22T16:26:05.301987+00:00", "ingest_document_name": "远光软件财务共享服务中心运营管理办法.docx", "ingest_document_updated_at": "2026-05-22T07:00:21.634300+00:00", @@ -602,8 +602,8 @@ "updated_at": "2026-05-22T07:00:21.945868+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:43.752235+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:26:54.048075+00:00", "ingest_completed_at": "2026-05-22T16:26:54.048075+00:00", "ingest_document_name": "远光软件财务共享服务操作手册.pdf", "ingest_document_updated_at": "2026-05-22T07:00:21.945868+00:00", @@ -623,8 +623,8 @@ "updated_at": "2026-05-22T07:00:19.662743+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:24.093834+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:27:31.775974+00:00", "ingest_completed_at": "2026-05-22T16:27:31.775974+00:00", "ingest_document_name": "远光软件报销流程培训手册.pdf", "ingest_document_updated_at": "2026-05-22T07:00:19.662743+00:00", @@ -644,8 +644,8 @@ "updated_at": "2026-05-22T07:00:19.323921+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:25.246857+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:27:44.244066+00:00", "ingest_completed_at": "2026-05-22T16:27:44.244066+00:00", "ingest_document_name": "远光软件新员工财务培训课件.pdf", "ingest_document_updated_at": "2026-05-22T07:00:19.323921+00:00", @@ -665,8 +665,8 @@ "updated_at": "2026-05-22T07:00:18.988700+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:26.471932+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:28:24.573683+00:00", "ingest_completed_at": "2026-05-22T16:28:24.573683+00:00", "ingest_document_name": "远光软件财务制度培训手册.docx", "ingest_document_updated_at": "2026-05-22T07:00:18.988700+00:00", @@ -686,8 +686,8 @@ "updated_at": "2026-05-22T07:00:19.686485+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:48.525207+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:29:03.349502+00:00", "ingest_completed_at": "2026-05-22T16:29:03.349502+00:00", "ingest_document_name": "远光软件财务培训课程安排.xlsx", "ingest_document_updated_at": "2026-05-22T07:00:19.686485+00:00", @@ -707,8 +707,8 @@ "updated_at": "2026-05-22T07:00:20.476077+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:49.746825+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:29:29.050791+00:00", "ingest_completed_at": "2026-05-22T16:29:29.050791+00:00", "ingest_document_name": "远光软件报销问题处理指引.xlsx", "ingest_document_updated_at": "2026-05-22T07:00:20.476077+00:00", @@ -728,8 +728,8 @@ "updated_at": "2026-05-22T07:00:20.453567+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:30.343781+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:35:03.548506+00:00", "ingest_completed_at": "2026-05-22T16:35:03.548506+00:00", "ingest_document_name": "远光软件财务制度问答汇总.pdf", "ingest_document_updated_at": "2026-05-22T07:00:20.453567+00:00", @@ -749,8 +749,8 @@ "updated_at": "2026-05-22T07:00:20.158497+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-26T16:01:31.573128+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-22T16:35:27.056080+00:00", "ingest_completed_at": "2026-05-22T16:35:27.056080+00:00", "ingest_document_name": "远光软件财务报销常见问题解答.docx", "ingest_document_updated_at": "2026-05-22T07:00:20.158497+00:00", diff --git a/server/tests/test_agent_runs_service.py b/server/tests/test_agent_runs_service.py index 3cc8fdd..fd4e542 100644 --- a/server/tests/test_agent_runs_service.py +++ b/server/tests/test_agent_runs_service.py @@ -49,6 +49,27 @@ def test_agent_run_service_marks_stale_knowledge_sync_run_failed_on_read() -> No assert all(item.run_id != created.run_id for item in running_runs) +def test_agent_run_service_marks_stale_llm_wiki_run_failed_on_read() -> None: + with build_session() as db: + service = AgentRunService(db) + created = service.create_run( + agent=AgentName.HERMES.value, + source=AgentRunSource.SCHEDULE.value, + status=AgentRunStatus.RUNNING.value, + route_json={ + "job_type": "llm_wiki_sync", + "heartbeat_at": (datetime.now(UTC) - timedelta(minutes=31)).isoformat(), + "requested_document_ids": [], + }, + ) + + fetched = service.get_run(created.run_id) + + assert fetched is not None + assert fetched.status == AgentRunStatus.FAILED.value + assert fetched.error_message == "Knowledge index heartbeat timed out." + + def test_agent_run_service_updates_existing_tool_call() -> None: with build_session() as db: service = AgentRunService(db) diff --git a/server/tests/test_applicant_expense_profile_algorithm.py b/server/tests/test_applicant_expense_profile_algorithm.py new file mode 100644 index 0000000..fd6d8c4 --- /dev/null +++ b/server/tests/test_applicant_expense_profile_algorithm.py @@ -0,0 +1,74 @@ +from decimal import Decimal + +from app.algorithem import ApplicantExpenseProfileInput, evaluate_applicant_expense_profile + + +def test_applicant_profile_recommends_review_and_days_cap() -> None: + result = evaluate_applicant_expense_profile( + ApplicantExpenseProfileInput( + applicant_claim_count_90d=6, + peer_claim_count_p75_90d=4, + applicant_amount_90d=Decimal("42800"), + available_peer_budget_90d=Decimal("150000"), + amount_percentile=Decimal("88"), + peer_amount_median_90d=Decimal("25000"), + adjusted_or_returned_count_180d=3, + approved_claim_count_180d=12, + requested_days=Decimal("5"), + peer_travel_days_p75=Decimal("3"), + business_buffer_days=Decimal("1"), + claim_amount=Decimal("8000"), + peer_daily_cost_baseline=Decimal("1400"), + tolerance_factor=Decimal("1.20"), + ) + ) + + assert result.profile_score == 68 + assert result.profile_level == "review" + assert result.recommendation_level == "review" + assert result.travel_days_ratio == Decimal("1.6667") + assert result.suggested_days == Decimal("4.0000") + assert result.suggested_amount_cap == Decimal("6720.00") + assert "applicant.amount_percentile.p85" in result.basis_codes + assert "travel.days_ratio.review" in result.basis_codes + + +def test_applicant_profile_handles_missing_baselines_without_false_risk() -> None: + result = evaluate_applicant_expense_profile( + ApplicantExpenseProfileInput( + applicant_claim_count_90d=1, + applicant_amount_90d=Decimal("800"), + requested_days=Decimal("2"), + claim_amount=Decimal("800"), + ) + ) + + assert result.profile_score == 0 + assert result.profile_level == "normal" + assert result.recommendation_level == "normal" + assert result.frequency_ratio == Decimal("0") + assert result.daily_cost_ratio == Decimal("0") + assert result.suggested_days == Decimal("2.0000") + assert result.suggested_amount_cap is None + assert result.basis_codes == [] + + +def test_entertainment_deviation_can_escalate_recommendation() -> None: + result = evaluate_applicant_expense_profile( + ApplicantExpenseProfileInput( + entertainment_amount=Decimal("3000"), + attendee_count=3, + entertainment_standard_cap=Decimal("600"), + same_customer_frequency_90d=3, + applicant_entertainment_percentile=Decimal("96"), + ) + ) + + assert result.entertainment_deviation_score == 100 + assert result.current_claim_deviation_score == 100 + assert result.profile_score == 15 + assert result.profile_level == "normal" + assert result.recommendation_level == "escalation" + assert "entertainment.per_capita.escalation" in result.basis_codes + assert "entertainment.same_customer.frequency_review" in result.basis_codes + assert "entertainment.amount_percentile.p95" in result.basis_codes diff --git a/server/tests/test_budget_endpoints.py b/server/tests/test_budget_endpoints.py index 46e65dd..dd6a6fe 100644 --- a/server/tests/test_budget_endpoints.py +++ b/server/tests/test_budget_endpoints.py @@ -13,6 +13,10 @@ from app.api.deps import get_db from app.db.base import Base from app.main import create_app from app.models.budget import BudgetAllocation, BudgetTransaction +from app.models.employee import Employee +from app.models.financial_record import ExpenseClaim +from app.models.organization import OrganizationUnit +from app.models.role import Role def build_session_factory() -> sessionmaker[Session]: @@ -109,6 +113,27 @@ def seed_budget_allocations(db: Session) -> None: db.commit() +def seed_market_budget_monitor(db: Session) -> tuple[Role, OrganizationUnit]: + role = Role(role_code="budget_monitor", name="预算监控员") + department = OrganizationUnit( + id="dept-market", + unit_code="MARKET-DEPT", + name="市场部", + unit_type="department", + ) + employee = Employee( + employee_no="E-BUDGET-MARKET-P8", + name="赵预算", + email="budget-monitor@example.com", + grade="P8", + organization_unit=department, + roles=[role], + ) + db.add_all([role, department, employee]) + db.flush() + return role, department + + def test_admin_can_view_all_budget_allocations_without_is_admin_header() -> None: client, session_factory = build_client() with session_factory() as db: @@ -281,3 +306,69 @@ def test_budget_monitor_cannot_edit_and_admin_can_edit() -> None: ) assert admin_response.status_code == 201 assert admin_response.json()["department_name"] == "销售部" + + +def test_budget_analysis_endpoint_is_limited_to_budget_roles() -> None: + client, session_factory = build_client() + with session_factory() as db: + seed_budget_allocations(db) + budget_role, market_department = seed_market_budget_monitor(db) + p6_budget_monitor = Employee( + employee_no="E-BUDGET-MARKET-P6", + name="低级预算", + email="p6-budget-monitor@example.com", + grade="P6", + organization_unit=market_department, + roles=[budget_role], + ) + db.add(p6_budget_monitor) + db.flush() + claim = ExpenseClaim( + claim_no="APP-BUDGET-ANALYSIS-001", + employee_id=p6_budget_monitor.id, + employee_name="低级预算", + department_id="dept-market", + department_name="市场部", + project_code=None, + expense_type="travel_application", + reason="客户现场交付预算申请", + location="上海", + amount=Decimal("12000.00"), + currency="CNY", + invoice_count=0, + 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 + + ordinary_response = client.get( + f"/api/v1/reimbursements/claims/{claim_id}/budget-analysis", + headers={ + "x-auth-username": "zhangsan@example.com", + "x-auth-role-codes": "employee", + }, + ) + monitor_response = client.get( + f"/api/v1/reimbursements/claims/{claim_id}/budget-analysis", + headers={ + "x-auth-username": "budget-monitor@example.com", + "x-auth-role-codes": "budget_monitor", + }, + ) + p6_monitor_response = client.get( + f"/api/v1/reimbursements/claims/{claim_id}/budget-analysis", + headers={ + "x-auth-username": "p6-budget-monitor@example.com", + "x-auth-role-codes": "budget_monitor", + }, + ) + + assert ordinary_response.status_code == 403 + assert p6_monitor_response.status_code == 403 + assert monitor_response.status_code == 200 + assert Decimal(monitor_response.json()["metrics"]["claim_amount_ratio"]) == Decimal("24.00") diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index 87b9526..2515ef2 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -16,10 +16,12 @@ from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransac from app.models.employee import Employee from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.organization import OrganizationUnit +from app.models.role import Role from app.schemas.ontology import OntologyParseRequest from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate, ExpenseClaimUpdate from app.services.agent_conversations import AgentConversationService +from app.services.budget import BudgetService from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage from app.services.expense_claims import ExpenseClaimService from app.services.ontology import SemanticOntologyService @@ -108,6 +110,16 @@ def _seed_budget_allocation( return allocation +def _seed_budget_monitor_role(db: Session) -> Role: + role = db.query(Role).filter(Role.role_code == "budget_monitor").one_or_none() + if role is not None: + return role + role = Role(role_code="budget_monitor", name="预算监控员") + db.add(role) + db.flush() + return role + + def test_validate_claim_for_submission_allows_office_claim_without_location() -> None: service = ExpenseClaimService.__new__(ExpenseClaimService) claim = build_claim(expense_type="office", location="待补充") @@ -3320,32 +3332,55 @@ def test_application_submit_skips_budget_for_non_demo_subject() -> None: ) -def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() -> None: - current_user = CurrentUserContext( +def test_direct_manager_can_route_application_claim_to_budget_approval_then_budget_manager_creates_draft() -> None: + manager_user = CurrentUserContext( username="manager-application-approve@example.com", name="李经理", role_codes=["manager"], is_admin=False, ) + budget_user = CurrentUserContext( + username="budget-p8-application-approve@example.com", + name="赵预算", + role_codes=["budget_monitor"], + is_admin=False, + ) with build_session() as db: + budget_role = _seed_budget_monitor_role(db) + department = OrganizationUnit( + unit_code="DELIVERY-BUDGET-APPROVE", + name="交付部", + unit_type="department", + ) manager = Employee( employee_no="E8112", name="李经理", email="manager-application-approve@example.com", + organization_unit=department, + ) + budget_manager = Employee( + employee_no="E8112-BUDGET", + name="赵预算", + email="budget-p8-application-approve@example.com", + grade="P8", + organization_unit=department, + roles=[budget_role], ) employee = Employee( employee_no="E8113", name="张三", email="zhangsan-application-approve@example.com", manager=manager, + organization_unit=department, ) - db.add_all([manager, employee]) + db.add_all([department, manager, budget_manager, employee]) db.flush() claim = ExpenseClaim( claim_no="APP-20260525-APPROVE", employee_id=employee.id, employee_name="张三", + department_id=department.id, department_name="交付部", project_code="PRJ-A", expense_type="travel_application", @@ -3364,10 +3399,33 @@ def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() - db.commit() claim_id = claim.id + leader_approved = ExpenseClaimService(db).approve_claim( + claim_id, + manager_user, + opinion="业务必要,同意申请。", + ) + + assert leader_approved is not None + assert leader_approved.status == "submitted" + assert leader_approved.approval_stage == "预算管理者审批" + assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0 + assert any( + isinstance(flag, dict) + and flag.get("source") == "manual_approval" + and flag.get("event_type") == "expense_application_approval" + and flag.get("opinion") == "业务必要,同意申请。" + and flag.get("previous_approval_stage") == "直属领导审批" + and flag.get("next_status") == "submitted" + and flag.get("next_approval_stage") == "预算管理者审批" + and flag.get("next_approver_name") == "赵预算" + and flag.get("next_approver_grade") == "P8" + for flag in leader_approved.risk_flags_json + ) + approved = ExpenseClaimService(db).approve_claim( claim_id, - current_user, - opinion="业务必要,同意申请。", + budget_user, + opinion="预算额度可承接,同意。", ) assert approved is not None @@ -3400,14 +3458,15 @@ def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() - and flag.get("event_type") == "expense_application_to_reimbursement_draft" and flag.get("application_claim_no") == "APP-20260525-APPROVE" and flag.get("leader_opinion") == "业务必要,同意申请。" + and flag.get("budget_opinion") == "预算额度可承接,同意。" for flag in generated_draft.risk_flags_json ) assert any( isinstance(flag, dict) - and flag.get("source") == "manual_approval" - and flag.get("event_type") == "expense_application_approval" - and flag.get("opinion") == "业务必要,同意申请。" - and flag.get("previous_approval_stage") == "直属领导审批" + and flag.get("source") == "budget_approval" + and flag.get("event_type") == "expense_application_budget_approval" + and flag.get("opinion") == "预算额度可承接,同意。" + and flag.get("previous_approval_stage") == "预算管理者审批" and flag.get("next_status") == "approved" and flag.get("next_approval_stage") == "审批完成" and flag.get("generated_draft_claim_id") == generated_draft.id @@ -3473,7 +3532,7 @@ def test_direct_manager_return_application_claim_records_return_node_and_opinion claim.id, manager_user, reason="预算说明不够清楚,请补充项目必要性。", - reason_codes=["application_business_need_unclear", "application_budget_basis_missing"], + reason_codes=["application_budget_basis_missing"], ) assert returned is not None @@ -3493,11 +3552,8 @@ def test_direct_manager_return_application_claim_records_return_node_and_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["reason_codes"] == ["application_budget_basis_missing"] + assert return_event["risk_points"] == ["预算测算依据不足"] assert return_event["next_status"] == "returned" assert return_event["next_approval_stage"] == "待提交" @@ -3515,20 +3571,43 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf role_codes=["manager"], is_admin=False, ) + budget_user = CurrentUserContext( + username="budget-p8-transfer@example.com", + name="赵预算", + role_codes=["budget_monitor"], + is_admin=False, + ) with build_session() as db: + budget_role = _seed_budget_monitor_role(db) + department = OrganizationUnit( + id="dept-budget-transfer", + unit_code="DELIVERY-BUDGET-TRANSFER", + name="交付部", + unit_type="department", + ) manager = Employee( employee_no="M-BUDGET-APP", name="李经理", email="manager-application-budget@example.com", + organization_unit=department, + ) + budget_manager = Employee( + employee_no="P8-BUDGET-APP", + name="赵预算", + email="budget-p8-transfer@example.com", + grade="P8", + organization_unit=department, + roles=[budget_role], ) employee = Employee( employee_no="E-BUDGET-APP", name="张三", email="application-budget-owner-approve@example.com", manager=manager, + organization_unit=department, ) - db.add_all([manager, employee]) + db.add_all([department, manager, budget_manager, employee]) db.flush() _seed_budget_allocation( db, @@ -3560,10 +3639,19 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf service = ExpenseClaimService(db) service.submit_claim(claim.id, owner) - approved = service.approve_claim(claim.id, manager_user, opinion="同意申请") - generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one() + leader_approved = service.approve_claim(claim.id, manager_user, opinion="同意申请") reservation = db.query(BudgetReservation).one() + assert leader_approved is not None + assert leader_approved.approval_stage == "预算管理者审批" + assert reservation.source_type == "application" + assert reservation.source_id == claim.id + assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0 + + approved = service.approve_claim(claim.id, budget_user, opinion="预算通过") + generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one() + db.refresh(reservation) + assert approved is not None assert reservation.source_type == "claim" assert reservation.source_id == generated_draft.id @@ -3576,7 +3664,7 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf ) -def test_direct_manager_approval_requires_leader_opinion() -> None: +def test_direct_manager_approval_defaults_blank_opinion_to_agree() -> None: current_user = CurrentUserContext( username="manager-application-required-opinion@example.com", name="李经理", @@ -3620,19 +3708,79 @@ def test_direct_manager_approval_requires_leader_opinion() -> None: db.commit() claim_id = claim.id - with pytest.raises(ValueError, match="领导审核意见不能为空"): - ExpenseClaimService(db).approve_claim( - claim_id, - current_user, - opinion=" ", - ) + approved = ExpenseClaimService(db).approve_claim( + claim_id, + current_user, + opinion=" ", + ) - db.refresh(claim) - assert claim.status == "submitted" - assert claim.approval_stage == "直属领导审批" + assert approved is not None + assert approved.status == "submitted" + assert approved.approval_stage == "预算管理者审批" + assert any( + isinstance(flag, dict) + and flag.get("event_type") == "expense_application_approval" + and flag.get("opinion") == "同意" + and flag.get("next_approval_stage") == "预算管理者审批" + for flag in approved.risk_flags_json + ) assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0 +def test_budget_analysis_uses_current_application_reservation_without_double_counting() -> None: + owner = CurrentUserContext( + username="application-budget-analysis-owner@example.com", + name="张三", + role_codes=["employee"], + is_admin=False, + ) + + with build_session() as db: + employee = Employee( + employee_no="E-BUDGET-ANALYSIS", + name="张三", + email="application-budget-analysis-owner@example.com", + ) + db.add(employee) + db.flush() + _seed_budget_allocation( + db, + department_id="dept-budget-analysis", + department_name="交付部", + amount=Decimal("50000.00"), + ) + claim = ExpenseClaim( + claim_no="APP-20260525-ANALYSIS", + employee_id=employee.id, + employee_name="张三", + department_id="dept-budget-analysis", + 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=None, + status="draft", + approval_stage="待提交", + risk_flags_json=[], + ) + db.add(claim) + db.commit() + + ExpenseClaimService(db).submit_claim(claim.id, owner) + analysis = BudgetService(db).analyze_claim_budget(claim) + + assert analysis["metrics"]["claim_amount_ratio"] == "24.00" + assert analysis["metrics"]["after_usage_rate"] == "24.00" + assert analysis["budget_context"]["current_reserved_amount"] == "12000.00" + assert analysis["score"] >= 70 + assert any("本次申请金额 12000.00 元,占预算 24.00%" in item for item in analysis["basis"]) + + def test_finance_approve_reimbursement_consumes_budget_reservation() -> None: current_user = CurrentUserContext( username="finance-budget-approve@example.com", @@ -4207,3 +4355,121 @@ def test_list_approval_claims_limits_finance_to_finance_stage_claims() -> None: claims = ExpenseClaimService(db).list_approval_claims(current_user) assert [claim.claim_no for claim in claims] == ["EXP-FIN-LIST-202"] + + +def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applications() -> None: + current_user = CurrentUserContext( + username="budget-p8-list@example.com", + name="赵预算", + role_codes=["budget_monitor"], + is_admin=False, + ) + p8_without_budget_role = CurrentUserContext( + username="budget-p8-list@example.com", + name="budget manager", + role_codes=["manager"], + is_admin=False, + ) + + with build_session() as db: + budget_role = _seed_budget_monitor_role(db) + delivery_department = OrganizationUnit( + unit_code="DELIVERY-BUDGET-LIST", + name="交付部", + unit_type="department", + ) + market_department = OrganizationUnit( + unit_code="MARKET-BUDGET-LIST", + name="市场部", + unit_type="department", + ) + budget_manager = Employee( + employee_no="E-P8-BUDGET-LIST", + name="赵预算", + email="budget-p8-list@example.com", + grade="P8", + organization_unit=delivery_department, + roles=[budget_role], + ) + employee = Employee( + employee_no="E-BUDGET-LIST-OWNER", + name="张三", + email="budget-list-owner@example.com", + organization_unit=delivery_department, + ) + market_employee = Employee( + employee_no="E-BUDGET-LIST-MARKET", + name="王五", + email="budget-list-market@example.com", + organization_unit=market_department, + ) + db.add_all([delivery_department, market_department, budget_manager, employee, market_employee]) + db.flush() + db.add_all( + [ + ExpenseClaim( + claim_no="APP-BUDGET-LIST-201", + employee_id=employee.id, + employee_name="张三", + department_id=delivery_department.id, + department_name="交付部", + project_code="PRJ-BUDGET", + expense_type="travel_application", + reason="预算待审申请", + location="上海", + amount=Decimal("12000.00"), + currency="CNY", + invoice_count=0, + 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=[], + ), + ExpenseClaim( + claim_no="APP-BUDGET-LIST-OTHER-DEPT", + employee_id=market_employee.id, + employee_name="王五", + department_id=market_department.id, + department_name="市场部", + project_code="PRJ-BUDGET", + expense_type="travel_application", + reason="其他部门预算待审申请", + location="上海", + amount=Decimal("13000.00"), + currency="CNY", + invoice_count=0, + 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=[], + ), + ExpenseClaim( + claim_no="EXP-BUDGET-LIST-202", + employee_id=employee.id, + employee_name="张三", + department_id=delivery_department.id, + department_name="交付部", + project_code="PRJ-BUDGET", + expense_type="transport", + reason="财务待审报销", + location="上海", + amount=Decimal("88.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.commit() + + claims = ExpenseClaimService(db).list_approval_claims(current_user) + + assert [claim.claim_no for claim in claims] == ["APP-BUDGET-LIST-201"] + claims_without_budget_role = ExpenseClaimService(db).list_approval_claims(p8_without_budget_role) + assert [claim.claim_no for claim in claims_without_budget_role] == [] diff --git a/server/tests/test_knowledge_rag_runtime.py b/server/tests/test_knowledge_rag_runtime.py new file mode 100644 index 0000000..392925b --- /dev/null +++ b/server/tests/test_knowledge_rag_runtime.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import pytest + +from app.services.knowledge_rag_runtime import ( + KnowledgeRagError, + RuntimeModelConfig, + _LightRagRuntime, +) + + +def test_embedding_probe_error_includes_model_context(monkeypatch) -> None: + runtime = _LightRagRuntime.__new__(_LightRagRuntime) + config = RuntimeModelConfig( + slot="embedding", + provider="GLM", + model="Embedding-3", + endpoint="https://open.bigmodel.cn/api/paas/v4/", + api_key="token", + capability="embedding", + ) + + def fail_embeddings(*_args, **_kwargs): + raise KnowledgeRagError("token expired") + + monkeypatch.setattr(runtime, "_request_embeddings", fail_embeddings) + + with pytest.raises(KnowledgeRagError) as exc_info: + runtime._probe_embedding_dimension(config) + + message = str(exc_info.value) + assert "slot=embedding" in message + assert "provider=GLM" in message + assert "model=Embedding-3" in message + assert "token expired" in message diff --git a/server/tests/test_knowledge_rag_service.py b/server/tests/test_knowledge_rag_service.py index f18ee0c..fd2941d 100644 --- a/server/tests/test_knowledge_rag_service.py +++ b/server/tests/test_knowledge_rag_service.py @@ -238,7 +238,7 @@ def test_resolve_default_qdrant_url_falls_back_to_loopback(monkeypatch) -> None: assert knowledge_rag_module._resolve_default_qdrant_url() == "http://127.0.0.1:6333" -def test_runtime_cache_is_isolated_by_thread(monkeypatch) -> None: +def test_runtime_cache_uses_dedicated_instance_across_calling_threads(monkeypatch) -> None: knowledge_rag_module.shutdown_knowledge_rag_runtime() created_runtimes = [] @@ -270,8 +270,8 @@ def test_runtime_cache_is_isolated_by_thread(monkeypatch) -> None: thread.start() thread.join(timeout=5) - assert len(created_runtimes) == 2 - assert worker_runtimes[0] is not main_runtime + assert len(created_runtimes) == 1 + assert worker_runtimes[0] is main_runtime knowledge_rag_module.shutdown_knowledge_rag_runtime() assert all(runtime.finalized for runtime in created_runtimes) diff --git a/server/tests/test_knowledge_service.py b/server/tests/test_knowledge_service.py index 7141e83..0867038 100644 --- a/server/tests/test_knowledge_service.py +++ b/server/tests/test_knowledge_service.py @@ -12,6 +12,7 @@ from app.db.base import Base from app.services.agent_runs import AgentRunService from app.services.knowledge import ( KNOWLEDGE_INGEST_STATUS_FAILED, + KNOWLEDGE_INGEST_STATUS_INGESTED, KNOWLEDGE_INGEST_STATUS_SYNCING, KnowledgeService, ) @@ -88,3 +89,41 @@ def test_reconcile_document_ingest_status_keeps_failed_when_linked_run_failed( entry = next(item for item in index["documents"] if item["id"] == uploaded.id) assert changed is True assert entry["ingest_status"] == KNOWLEDGE_INGEST_STATUS_FAILED + + +def test_reconcile_document_ingest_status_preserves_ingested_when_status_map_missing( + tmp_path, + monkeypatch, +) -> None: + service = KnowledgeService(storage_root=tmp_path) + uploaded = service.upload_document( + "报销制度", + "demo.txt", + b"hello", + CurrentUserContext( + username="admin", + name="管理员", + role_codes=["manager"], + is_admin=True, + ), + ) + service.set_document_ingest_statuses( + [uploaded.id], + KNOWLEDGE_INGEST_STATUS_INGESTED, + agent_run_id="run_missing_status_map", + ) + monkeypatch.setattr( + "app.services.knowledge_rag.KnowledgeRagService.get_document_status_map", + lambda self, _document_ids: {}, + ) + + index = service._load_index() + changed = service._reconcile_document_ingest_statuses( + index, + document_ids=[uploaded.id], + preserve_syncing=False, + ) + + entry = next(item for item in index["documents"] if item["id"] == uploaded.id) + assert changed is False + assert entry["ingest_status"] == KNOWLEDGE_INGEST_STATUS_INGESTED diff --git a/server/tests/test_knowledge_sync.py b/server/tests/test_knowledge_sync.py new file mode 100644 index 0000000..a43c30b --- /dev/null +++ b/server/tests/test_knowledge_sync.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.api.deps import CurrentUserContext +from app.db.base import Base +from app.services.knowledge import KNOWLEDGE_INGEST_STATUS_INGESTED, KnowledgeService +from app.services.knowledge_sync import KnowledgeSyncDispatchService + + +def build_session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + return session_factory() + + +def test_force_sync_queues_ingested_documents_and_creates_hermes_run(tmp_path, monkeypatch) -> None: + submitted: list[dict[str, object]] = [] + user = CurrentUserContext( + username="admin", + name="管理员", + role_codes=["manager"], + is_admin=True, + ) + + with build_session() as db: + knowledge_service = KnowledgeService(storage_root=tmp_path, db=db) + uploaded = knowledge_service.upload_document("报销制度", "demo.txt", b"hello", user) + document_id = uploaded.id + knowledge_service.set_document_ingest_statuses( + [document_id], + KNOWLEDGE_INGEST_STATUS_INGESTED, + agent_run_id="run_previous", + ) + monkeypatch.setattr( + "app.services.knowledge_rag.KnowledgeRagService.get_document_status_map", + lambda self, _document_ids: {}, + ) + monkeypatch.setattr( + "app.services.knowledge_sync.knowledge_index_task_manager.submit_sync", + lambda **kwargs: submitted.append(kwargs), + ) + + dispatch_service = KnowledgeSyncDispatchService(db) + dispatch_service.knowledge_service = knowledge_service + + result = dispatch_service.queue_sync( + current_user=user, + folder=None, + document_ids=[document_id], + force=True, + changed_only=True, + ) + + assert result.agent_run_id.startswith("run_") + assert document_id in result.document_ids + assert submitted + assert submitted[0]["agent_run_id"] == result.agent_run_id diff --git a/server/tests/test_settings_persistence.py b/server/tests/test_settings_persistence.py index 92c1fc7..66d1eb1 100644 --- a/server/tests/test_settings_persistence.py +++ b/server/tests/test_settings_persistence.py @@ -14,6 +14,7 @@ from app.core import admin_secret from app.core import secret_box from app.core.secret_box import encrypt_secret from app.db.base import Base +from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog from app.models.system_model_setting import SystemModelSetting from app.models.system_setting import SystemSetting from app.models.system_setting_secret import SystemSettingSecret @@ -27,9 +28,11 @@ def build_session(db_file: Path) -> Session: f"sqlite+pysqlite:///{db_file.as_posix()}", connect_args={"check_same_thread": False}, ) - SystemSetting.__table__.create(bind=engine) - SystemSettingSecret.__table__.create(bind=engine) - SystemModelSetting.__table__.create(bind=engine) + SystemSetting.__table__.create(bind=engine) + SystemSettingSecret.__table__.create(bind=engine) + SystemModelSetting.__table__.create(bind=engine) + HermesTaskConfig.__table__.create(bind=engine) + HermesTaskExecutionLog.__table__.create(bind=engine) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) return session_factory() @@ -45,9 +48,12 @@ def test_settings_service_persists_non_secret_and_secret_fields(monkeypatch) -> monkeypatch.setenv("HERMES_HOME", str(temp_dir / ".hermes")) with build_session(temp_dir / "settings.db") as db: - service = SettingsService(db) - initial_snapshot = service.get_settings_snapshot() - payload = initial_snapshot.model_dump() + service = SettingsService(db) + initial_snapshot = service.get_settings_snapshot() + assert initial_snapshot.llmForm.mainModel == "codex-mini-latest" + assert initial_snapshot.llmForm.mainEndpoint == "https://api.openai.com/v1" + assert initial_snapshot.llmForm.mainApiKey == "" + payload = initial_snapshot.model_dump() payload["companyForm"]["companyName"] = "YGSOFT" payload["companyForm"]["displayName"] = "云广软件" diff --git a/server/tests/test_user_agent_service.py b/server/tests/test_user_agent_service.py index d57d63e..7a3ff10 100644 --- a/server/tests/test_user_agent_service.py +++ b/server/tests/test_user_agent_service.py @@ -167,7 +167,9 @@ def test_user_agent_knowledge_prompt_enforces_knowledge_boundary() -> None: assert "不能用常识、外部知识或主观推断补齐缺失条件" in messages[0]["content"] assert "不能只依赖排在最前面的片段" in messages[0]["content"] assert "不能把第一列的数值直接套给后面的列名" in messages[0]["content"] - assert "最终答复必须像助手在认真回答问题" in messages[0]["content"] + assert "最终答复必须使用 Markdown" in messages[0]["content"] + assert "## 结论" in messages[0]["content"] + assert "如果不能,必须明确说当前知识库没有找到直接依据" in messages[0]["content"] assert "禁止使用“已命中”“答案整理阶段”“稍后重试”" in messages[0]["content"] assert "knowledge_evidence_blocks" in messages[0]["content"] assert '"knowledge_answer_evidence": []' in messages[1]["content"] @@ -435,6 +437,9 @@ def test_user_agent_knowledge_fallback_is_honest_and_personalized() -> None: ) assert answer.startswith("张三,您好。") + assert "## 结论" in answer + assert "## 依据" in answer + assert "## 说明" in answer assert "我先根据当前制度依据给出可以确认的部分" in answer assert "已命中" not in answer assert "答案整理阶段本轮没有及时返回" not in answer @@ -477,8 +482,8 @@ def test_user_agent_knowledge_answer_generation_uses_fast_timeouts(monkeypatch) ) assert answer == "测试回答" - assert captured["timeout_seconds"] == 5 - assert captured["slot_timeouts"] == {"main": 3, "backup": 5} + assert captured["timeout_seconds"] == 30 + assert captured["slot_timeouts"] == {"main": 20, "backup": 30} assert captured["max_attempts"] == 1 @@ -549,7 +554,7 @@ def test_user_agent_knowledge_terms_keep_accounting_subject_in_long_query() -> N assert "会计科目" in terms -def test_user_agent_uses_fast_knowledge_answer_without_model(monkeypatch) -> None: +def test_user_agent_knowledge_answer_uses_model_after_retrieval(monkeypatch) -> None: session_factory = build_session_factory() with session_factory() as db: ontology = SemanticOntologyService(db).parse( @@ -560,11 +565,14 @@ def test_user_agent_uses_fast_knowledge_answer_without_model(monkeypatch) -> Non ) ) service = UserAgentService(db) - monkeypatch.setattr( - service, - "_generate_answer_with_model", - lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("model should not be called")), - ) + captured: dict[str, object] = {} + + def fake_generate_answer(payload, **kwargs): + captured["payload"] = payload + captured.update(kwargs) + return "## 结论\n\n员工应在费用发生后 30 日内提交报销申请。" + + monkeypatch.setattr(service, "_generate_answer_with_model", fake_generate_answer) response = service.respond( UserAgentRequest( @@ -593,10 +601,11 @@ def test_user_agent_uses_fast_knowledge_answer_without_model(monkeypatch) -> Non ) ) - assert response.answer.startswith("张三,您好。") - assert "**结论**" in response.answer + assert captured["payload"].ontology.scenario == "knowledge" + assert "费用报销制度" in captured["fallback_answer"] + assert "## 依据" in captured["fallback_answer"] + assert response.answer.startswith("## 结论") assert "30 日内提交报销申请" in response.answer - assert "## 依据" not in response.answer assert "答案整理阶段本轮没有及时返回" not in response.answer @@ -804,7 +813,8 @@ def test_user_agent_fast_knowledge_answer_renders_relevant_table_preview() -> No assert "| 项目 | 港澳台 | 其他地区 | 国外 |" in answer assert "| 餐补 | 75 | 55 | 140 |" in answer assert "餐补的标准为" in answer - assert "## 依据" not in answer + assert "## 结论" in answer + assert "## 依据" in answer def test_user_agent_fast_knowledge_answer_uses_user_grade_for_table_row() -> None: @@ -906,8 +916,8 @@ def test_user_agent_fast_knowledge_answer_notes_missing_location_grounding() -> assert answer is not None assert "没有直接写出“北京”对应的地区档位或映射关系" in answer - assert "**说明**" in answer - assert "## 依据" not in answer + assert "## 说明" in answer + assert "## 依据" in answer def test_user_agent_fast_knowledge_answer_expands_lead_in_list_items() -> None: @@ -952,12 +962,12 @@ def test_user_agent_fast_knowledge_answer_expands_lead_in_list_items() -> None: ) assert answer is not None - assert "**结论**" in answer + assert "## 结论" in answer assert "登机牌、高速道路通行记录" in answer assert "支付记录" in answer assert "出差审批邮件、短信、微信等" in answer assert "(3)" not in answer - assert "## 依据" not in answer + assert "## 依据" in answer def test_user_agent_model_prompt_supports_contextual_personalization() -> None: diff --git a/web/src/assets/styles/components/sidebar-rail.css b/web/src/assets/styles/components/sidebar-rail.css index 6e6d207..dcbc8d3 100644 --- a/web/src/assets/styles/components/sidebar-rail.css +++ b/web/src/assets/styles/components/sidebar-rail.css @@ -506,6 +506,29 @@ width: 100%; } +:global(.rail-tooltip-popper) { + max-width: 180px; + padding: 7px 10px !important; + border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22) !important; + border-radius: 4px !important; + background: rgba(255, 255, 255, 0.98) !important; + color: #1f2937 !important; + box-shadow: 0 12px 28px rgba(15, 23, 42, 0.14) !important; + font-size: 12px; + font-weight: 700; + line-height: 1.4; + letter-spacing: 0; +} + +:global(.rail-tooltip-popper.el-popper.is-light) { + border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22) !important; +} + +:global(.rail-tooltip-popper .el-popper__arrow::before) { + border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22) !important; + background: rgba(255, 255, 255, 0.98) !important; +} + @keyframes railUserMenuIn { from { opacity: 0; diff --git a/web/src/assets/styles/components/top-bar.css b/web/src/assets/styles/components/top-bar.css index 94c838d..601bb99 100644 --- a/web/src/assets/styles/components/top-bar.css +++ b/web/src/assets/styles/components/top-bar.css @@ -270,6 +270,12 @@ font-size: 14px; } +.detail-alert-pill.neutral { + border-color: #d7e0ea; + background: #f8fafc; + color: #334155; +} + .detail-alert-pill.success { border-color: var(--success-line); background: var(--success-soft); diff --git a/web/src/assets/styles/components/travel-reimbursement-insight-panel.css b/web/src/assets/styles/components/travel-reimbursement-insight-panel.css index 5680ec9..339affb 100644 --- a/web/src/assets/styles/components/travel-reimbursement-insight-panel.css +++ b/web/src/assets/styles/components/travel-reimbursement-insight-panel.css @@ -508,3 +508,17 @@ max-height: min(34dvh, 360px); } } + +.review-insight-title-copy { + display: flex; + align-items: center; + gap: 8px; +} + +.review-insight-title-copy .title-icon { + font-size: 20px; + color: var(--theme-primary, #3a7ca5); + display: inline-flex; + align-items: center; + justify-content: center; +} diff --git a/web/src/assets/styles/detail-page-corners.css b/web/src/assets/styles/detail-page-corners.css new file mode 100644 index 0000000..352898a --- /dev/null +++ b/web/src/assets/styles/detail-page-corners.css @@ -0,0 +1,210 @@ +:root { + --enterprise-detail-radius: 4px; +} + +.approval-page.approval-page .approval-detail :is( + .detail-hero, + .progress-card, + .detail-card, + .detail-side-card, + .detail-note, + .detail-note-editor textarea, + .detail-expense-table, + .detail-attachment, + .risk-list, + .application-detail-fact, + .application-budget-analysis__state, + .application-budget-analysis__metrics article, + .application-budget-analysis__summary, + .application-budget-analysis__lists > div, + .application-leader-opinion, + .application-leader-opinion-event, + .draft-blocking-note, + .draft-blocking-issue, + .expense-file-chip, + .expense-editor-panel, + .expense-editor-grid input, + .expense-editor-grid select, + .expense-total-under-table, + .attachment-insight-pane, + .attachment-source-pane, + .attachment-preview-card, + .attachment-preview-nav, + .attachment-preview-close, + .attachment-preview-alert, + .attachment-preview-action, + .attachment-preview-empty, + .attachment-risk-card, + .attachment-insight-section, + .risk-summary-card, + .risk-detail-card, + .risk-advice-card, + .risk-advice-meta > div, + .risk-override-card, + .risk-override-nav-btn, + .system-row-lock, + .system-attachment-note, + .submit-confirm-summary, + .smart-entry-btn, + .icon-action, + .inline-action +) { + border-radius: var(--enterprise-detail-radius); +} + +.approval-page.approval-page .ai-entry-modal :is( + .detail-modal, + .modal-card, + .close-btn, + .ai-chat-card, + .ai-preview-card, + .ai-chat-content, + .ai-composer, + .ai-composer-surface, + .ai-tool-btn, + .ai-upload-btn, + .ai-send-btn, + .preview-field, + .preview-empty, + .ai-preview-secondary, + .ai-preview-primary, + .modal-action +) { + border-radius: var(--enterprise-detail-radius); +} + +.approval-page.approval-page :is( + .approval-opinion-field textarea, + .return-reason-option, + .return-reason-section textarea +) { + border-radius: var(--enterprise-detail-radius); +} + +.log-detail-page.log-detail-page :is( + .detail-state, + .detail-state button, + .detail-hero, + .refresh-btn, + .detail-card, + .info-grid > div, + .feedback-grid > div, + .trace-step, + .code-block, + .inline-empty, + .detail-actions button, + .knowledge-ingest-panel, + .ingest-run-info, + .info-item, + .run-info-card, + .run-stat-card, + .graph-search, + .graph-toolbar button, + .graph-theater, + .graph-stage, + .graph-toolbar, + .graph-inspector, + .node-facts > div, + .node-meta, + .node-detail-panel, + .detail-section, + .evidence-document, + .evidence-chunk, + .evidence-empty, + .node-evidence-card, + .relation-detail-list button, + .detail-empty +) { + border-radius: var(--enterprise-detail-radius); +} + +.skill-detail.skill-detail :is( + .detail-hero, + .detail-inline-state, + .detail-loading-state, + .review-note-block, + .hero-stat, + .detail-card, + .side-card, + .field input, + .field textarea, + .prompt-block textarea, + .json-editor, + .markdown-editor, + .spreadsheet-editor-shell, + .spreadsheet-workbench, + .spreadsheet-change-center, + .version-pair-card, + .change-center-item, + .change-record-preview, + .spreadsheet-meta-strip span, + .json-risk-editor-shell, + .json-risk-generation-failure, + .json-risk-meta-item, + .json-risk-description-text, + .json-risk-description-source, + .json-risk-flow-card, + .diagram-zoom-controls, + .rule-spreadsheet-stage, + .compare-panel, + .compare-summary-grid article, + .compare-sheet-list article, + .change-detail-meta article, + .compare-sheet-list span, + .compare-table-wrap, + .subtle-banner, + .preview-mode-note, + .prompt-block, + .contract-panel, + .version-row, + .version-modal-summary div, + .version-modal-note, + .review-submit-test-state, + .risk-rule-action-confirm, + .risk-rule-action-note, + .risk-rule-action-note textarea, + .review-submit-form input:not([type='checkbox']), + .review-submit-form select, + .review-submit-form textarea, + .review-submit-hint, + .publish-summary, + .empty-side-note, + .back-action, + .minor-action, + .major-action, + .mini-btn, + .risk-level-menu, + .risk-level-option +) { + border-radius: var(--enterprise-detail-radius); +} + +.skill-detail.skill-detail .rule-drawer-backdrop :is( + .rule-drawer, + .rule-drawer-state, + .change-detail-meta article, + .compare-panel, + .compare-sheet-list span, + .compare-table-wrap +) { + border-radius: var(--enterprise-detail-radius); +} + +.employee-center.employee-center .employee-detail :is( + .detail-hero, + .hero-profile, + .hero-tag, + .hero-stat, + .detail-card, + .side-card, + .history-row, + .field input, + .field textarea, + .role-option, + .sync-card, + .permission-pill, + .detail-actions button, + .detail-action-group +) { + border-radius: var(--enterprise-detail-radius); +} diff --git a/web/src/assets/styles/views/documents-center-view.css b/web/src/assets/styles/views/documents-center-view.css index abf7691..1354182 100644 --- a/web/src/assets/styles/views/documents-center-view.css +++ b/web/src/assets/styles/views/documents-center-view.css @@ -420,7 +420,7 @@ table { width: 100%; - min-width: 1320px; + min-width: 1420px; border-collapse: collapse; table-layout: fixed; } @@ -429,8 +429,9 @@ table { .col-created { width: 10%; } .col-stay { width: 9%; } .col-doc-type { width: 9%; } -.col-scene { width: 10%; } -.col-title { width: 18%; } +.col-scene { width: 9%; } +.col-initiator { width: 8%; } +.col-title { width: 16%; } .col-amount { width: 9%; } .col-node { width: 12%; } .col-status { width: 8%; } diff --git a/web/src/assets/styles/views/logs-view.css b/web/src/assets/styles/views/logs-view.css index b238bac..7984053 100644 --- a/web/src/assets/styles/views/logs-view.css +++ b/web/src/assets/styles/views/logs-view.css @@ -85,6 +85,7 @@ } .console-toolbar { + --logs-filter-control-height: 38px; display: grid; grid-template-columns: minmax(220px, 1.35fr) repeat(2, minmax(138px, 0.7fr)) auto; gap: 10px; @@ -103,9 +104,9 @@ } .field-input { - min-height: 38px; + min-height: var(--logs-filter-control-height); border: 1px solid #d8e1eb; - border-radius: 8px; + border-radius: 4px; background: #fff; } @@ -130,10 +131,38 @@ outline: none; } +.console-toolbar :deep(.enterprise-select) { + width: 100%; +} + +.console-toolbar :deep(.el-select__wrapper) { + min-height: var(--logs-filter-control-height); + height: var(--logs-filter-control-height); + padding: 0 12px; + border-radius: 4px; + box-shadow: 0 0 0 1px #d8e1eb inset; +} + +.console-toolbar :deep(.el-select__wrapper:hover) { + box-shadow: 0 0 0 1px #b8c2d2 inset; +} + +.console-toolbar :deep(.el-select__wrapper.is-focused) { + box-shadow: + 0 0 0 1px var(--theme-primary) inset, + 0 0 0 3px var(--theme-focus-ring); +} + +.console-toolbar :deep(.el-select__placeholder), +.console-toolbar :deep(.el-select__selected-item) { + font-size: 13px; + line-height: var(--logs-filter-control-height); +} + .toolbar-btn { - min-height: 38px; + min-height: var(--logs-filter-control-height); padding: 0 14px; - border-radius: 8px; + border-radius: 4px; border: 1px solid #d8e1eb; font-size: 13px; font-weight: 700; diff --git a/web/src/assets/styles/views/policies-view.css b/web/src/assets/styles/views/policies-view.css index 3c14120..f0fd85e 100644 --- a/web/src/assets/styles/views/policies-view.css +++ b/web/src/assets/styles/views/policies-view.css @@ -36,12 +36,12 @@ overflow: hidden; } -.panel-title { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; -} +.panel-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} .panel-title h2, .preview-head h2 { @@ -86,9 +86,17 @@ background: transparent; } -.file-search input:focus { - outline: none; -} +.file-search input:focus { + outline: none; +} + +.panel-tools { + min-width: min(470px, 100%); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; +} .library-body { min-height: 0; @@ -101,11 +109,11 @@ .folder-rail { min-height: 0; display: grid; - grid-template-rows: minmax(0, 1fr) auto; + grid-template-rows: minmax(0, 1fr); gap: 12px; - border-right: 1px solid #edf2f7; - padding-right: 12px; -} + border-right: 1px solid #edf2f7; + padding-right: 12px; +} .folder-tree { min-height: 0; @@ -148,45 +156,38 @@ font-size: 11px; } -.folder-sync-block { - display: grid; - gap: 8px; -} - -.new-folder-btn { +.knowledge-sync-btn { min-height: 36px; display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - border: 1px solid rgba(var(--theme-primary-rgb), .28); - border-radius: 8px; - background: var(--theme-primary-light-9); - color: var(--theme-primary-active); - font-size: 13px; - font-weight: 850; -} - -.new-folder-btn.fixed { - border-color: rgba(148, 163, 184, 0.3); - background: #f8fafc; - color: #64748b; -} - -.knowledge-sync-btn:not(:disabled) { + align-items: center; + justify-content: center; + gap: 7px; + padding: 0 14px; + border: 1px solid #1d4ed8; + border-radius: 8px; + background: #2563eb; + color: #fff; + font-size: 13px; + font-weight: 850; + white-space: nowrap; + box-shadow: 0 8px 18px rgba(37, 99, 235, 0.18); cursor: pointer; + transition: background 160ms ease, border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; } -.knowledge-sync-btn:not(:disabled):hover { - border-color: rgba(var(--theme-primary-rgb), 0.38); - background: var(--theme-primary-light-9); - color: var(--theme-primary-active); +.knowledge-sync-btn:hover:not(:disabled) { + border-color: #1e40af; + background: #1d4ed8; + box-shadow: 0 10px 22px rgba(37, 99, 235, 0.24); + transform: translateY(-1px); } -.folder-sync-meta { +.knowledge-sync-btn:disabled { + cursor: not-allowed; + border-color: #cbd5e1; + background: #e2e8f0; color: #64748b; - font-size: 12px; - line-height: 1.6; + box-shadow: none; } .document-area { @@ -1179,6 +1180,12 @@ th { align-items: stretch; } + .panel-tools, + .file-search, + .knowledge-sync-btn { + width: 100%; + } + .summary-grid, .list-foot { grid-template-columns: 1fr; diff --git a/web/src/assets/styles/views/settings-view-form.css b/web/src/assets/styles/views/settings-view-form.css index 6cdddf9..2568541 100644 --- a/web/src/assets/styles/views/settings-view-form.css +++ b/web/src/assets/styles/views/settings-view-form.css @@ -99,7 +99,8 @@ font-style: normal; } -.field input { +.field input, +.field :deep(.el-select__wrapper) { width: 100%; min-height: 44px; padding: 0 14px; @@ -116,7 +117,8 @@ color: #94a3b8; } -.field input:focus { +.field input:focus, +.field :deep(.el-select__wrapper.is-focused) { outline: none; border-color: var(--theme-primary); box-shadow: 0 0 0 3px var(--theme-focus-ring); diff --git a/web/src/components/layout/SidebarRail.vue b/web/src/components/layout/SidebarRail.vue index 4446327..c3c38a1 100644 --- a/web/src/components/layout/SidebarRail.vue +++ b/web/src/components/layout/SidebarRail.vue @@ -9,33 +9,52 @@ {{ displayCompanyName }} - + +
-
- {{ displayUser.avatar }} - - {{ displayUser.name }} - {{ displayUser.role }} - - -
+ +
+ {{ displayUser.avatar }} + + {{ displayUser.name }} + {{ displayUser.role }} + + +
+
diff --git a/web/src/components/travel/TravelReimbursementInsightPanel.vue b/web/src/components/travel/TravelReimbursementInsightPanel.vue index 4935326..9dfac94 100644 --- a/web/src/components/travel/TravelReimbursementInsightPanel.vue +++ b/web/src/components/travel/TravelReimbursementInsightPanel.vue @@ -16,6 +16,7 @@
+

{{ ui.reviewDrawerTitle }}

@@ -23,7 +24,7 @@

{{ ui.currentInsight.summary }}

-
+
- +
+ + +
@@ -30,19 +41,7 @@ -
- -

{{ knowledgeSyncHint }}

-
- +
+ +
领导意见 @@ -760,10 +765,6 @@ :confirm-text="approveConfirmText" :busy-text="approveBusyText" :busy="approveBusy" - :document-no="request.documentNo || request.id" - :node="request.node" - :summary-label="approvalConfirmSummaryLabel" - :next-stage="approvalNextStage" :opinion-title="approvalOpinionTitle" v-model:opinion="leaderOpinion" :opinion-placeholder="approvalOpinionPlaceholder" diff --git a/web/src/views/scripts/EmployeeManagementView.js b/web/src/views/scripts/EmployeeManagementView.js index 98bb6d6..d119995 100644 --- a/web/src/views/scripts/EmployeeManagementView.js +++ b/web/src/views/scripts/EmployeeManagementView.js @@ -372,7 +372,7 @@ function matchKeyword(employee, keyword) { return true } - const haystack = [ + const fields = [ employee.name, employee.employeeNo, employee.department, @@ -380,9 +380,13 @@ function matchKeyword(employee, keyword) { employee.email, employee.manager, employee.financeOwner, - employee.syncState, - ...(employee.roles || []) + employee.syncState ] + + const roles = Array.isArray(employee.roles) ? employee.roles : [] + + const haystack = [...fields, ...roles] + .map((val) => String(val || '').trim()) .filter(Boolean) .join(' ') .toLowerCase() diff --git a/web/src/views/scripts/PoliciesView.js b/web/src/views/scripts/PoliciesView.js index 874c787..09540e0 100644 --- a/web/src/views/scripts/PoliciesView.js +++ b/web/src/views/scripts/PoliciesView.js @@ -167,31 +167,15 @@ export default { } return stats }) - const knowledgeSyncButtonLabel = computed(() => { - if (syncingFolder.value || activeFolderIngestStats.value.syncing > 0) { - return '归纳中...' - } - return '知识归纳' - }) - const knowledgeSyncHint = computed(() => { - const stats = activeFolderIngestStats.value - if (!activeFolder.value) { - return '请选择一个固定知识目录后再触发归纳。' - } - if (!stats.total) { - return '当前目录暂无文档,上传后即可进行知识归纳。' - } - if (stats.syncing > 0) { - return `当前目录有 ${stats.syncing} 份文档正在归纳,完成后会自动刷新状态。` - } - if (stats.pending > 0 || stats.failed > 0) { - return `当前目录待归纳 ${stats.pending} 份,需重试 ${stats.failed} 份。` - } - return `当前目录 ${stats.ingested} 份文档已归纳,可手动触发一次增量检查。` - }) - const canTriggerKnowledgeSync = computed( - () => - isAdmin.value + const knowledgeSyncButtonLabel = computed(() => { + if (syncingFolder.value || activeFolderIngestStats.value.syncing > 0) { + return '归纳中...' + } + return '知识归纳' + }) + const canTriggerKnowledgeSync = computed( + () => + isAdmin.value && Boolean(activeFolder.value) && activeFolderIngestStats.value.total > 0 && !syncingFolder.value @@ -445,11 +429,11 @@ export default { syncingFolder.value = true try { - const payload = await syncKnowledgeLibrary({ - folder: activeFolder.value, - documentIds: [], - force: false - }) + const payload = await syncKnowledgeLibrary({ + folder: activeFolder.value, + documentIds: [], + force: true + }) const queuedIds = Array.isArray(payload?.document_ids) ? payload.document_ids : [] for (const documentId of queuedIds) { @@ -461,8 +445,9 @@ export default { }) } - await loadLibrary({ preserveSelection: true }) - toast(payload?.summary || '\u77e5\u8bc6\u5f52\u7eb3\u4efb\u52a1\u5df2\u63d0\u4ea4\u3002') + await loadLibrary({ preserveSelection: true }) + const runHint = payload?.agent_run_id ? `日志编号:${payload.agent_run_id}` : '' + toast([payload?.summary || '知识归纳任务已提交。', runHint].filter(Boolean).join(' ')) } catch (error) { await loadLibrary({ preserveSelection: true }) toast(error.message || '\u77e5\u8bc6\u5f52\u7eb3\u89e6\u53d1\u5931\u8d25\u3002') @@ -647,9 +632,8 @@ export default { handleFileInput, handleKnowledgeSync, isAdmin, - knowledgeSyncButtonLabel, - knowledgeSyncHint, - loading, + knowledgeSyncButtonLabel, + loading, pageSize, pageSizeOptions, pageSizes, diff --git a/web/src/views/scripts/TravelRequestDetailView.js b/web/src/views/scripts/TravelRequestDetailView.js index d5f0a71..417e997 100644 --- a/web/src/views/scripts/TravelRequestDetailView.js +++ b/web/src/views/scripts/TravelRequestDetailView.js @@ -5,6 +5,7 @@ import { useToast } from '../../composables/useToast.js' import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue' import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' import TravelRequestApprovalDialog from '../../components/travel/TravelRequestApprovalDialog.vue' +import TravelRequestBudgetAnalysis from '../../components/travel/TravelRequestBudgetAnalysis.vue' import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDeleteDialog.vue' import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue' import { @@ -22,6 +23,7 @@ import { updateExpenseClaimItem } from '../../services/reimbursements.js' import { + canApproveBudgetExpenseApplications, canApproveLeaderExpenseClaims, canDeleteArchivedExpenseClaims, canManageExpenseClaims, @@ -369,6 +371,7 @@ export default { ConfirmDialog, EnterpriseSelect, TravelRequestApprovalDialog, + TravelRequestBudgetAnalysis, TravelRequestDeleteDialog, TravelRequestReturnDialog }, @@ -490,6 +493,10 @@ export default { const node = String(request.value.node || request.value.approvalStage || '').trim() return node === '财务审批' }) + const isBudgetApprovalStage = computed(() => { + const node = String(request.value.node || request.value.approvalStage || '').trim() + return node === '预算管理者审批' + }) const isCurrentApplicant = computed(() => isCurrentRequestApplicant(request.value, currentUser.value)) const isCurrentDirectManagerApprover = computed(() => ( canApproveLeaderExpenseClaims(currentUser.value) @@ -501,6 +508,18 @@ export default { && isFinanceUser(currentUser.value) && !isCurrentApplicant.value )) + const canProcessBudgetApprovalStage = computed(() => ( + isApplicationDocument.value + && isBudgetApprovalStage.value + && canApproveBudgetExpenseApplications(currentUser.value, request.value) + && !isCurrentApplicant.value + )) + const showBudgetAnalysis = computed(() => ( + isApplicationDocument.value + && isBudgetApprovalStage.value + && canApproveBudgetExpenseApplications(currentUser.value, request.value) + && !isCurrentApplicant.value + )) const canReturnRequest = computed(() => { if (request.value.approvalKey !== 'in_progress' || !request.value.claimId || !canReturnExpenseClaims(currentUser.value)) { return false @@ -508,6 +527,9 @@ export default { if (isDirectManagerApprovalStage.value) { return isCurrentDirectManagerApprover.value } + if (isBudgetApprovalStage.value) { + return canProcessBudgetApprovalStage.value + } return canProcessFinanceApprovalStage.value }) const canApproveRequest = computed(() => @@ -520,6 +542,7 @@ export default { && isCurrentDirectManagerApprover.value ) || canProcessFinanceApprovalStage.value + || canProcessBudgetApprovalStage.value ) ) const leaderApprovalInfo = computed(() => buildLeaderApprovalInfo(request.value)) @@ -536,39 +559,43 @@ export default { isApplicationDocument.value && hasLeaderApprovalEvents.value )) - const requiresApprovalOpinion = computed(() => isDirectManagerApprovalStage.value) - const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '领导意见')) + const requiresApprovalOpinion = computed(() => false) + const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '附加意见')) const approvalOpinionPlaceholder = computed(() => { if (isFinanceApprovalStage.value) { return '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。' } if (isApplicationDocument.value) { - return '请输入审批意见,可补充业务必要性、预算合理性或执行要求。' + return '可选填审批补充说明,例如业务必要性、预算合理性或执行要求;不填写默认为同意。' } - return '请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。' + return '可选填审批补充说明,例如核实情况、费用合理性或后续财务关注点;不填写默认为同意。' }) const approvalOpinionHint = computed(() => { if (isFinanceApprovalStage.value) { return '审核通过后将进入归档入账。' } - return isApplicationDocument.value ? '领导意见为必填,确认后会生成报销草稿。' : '领导意见为必填,审批通过后将流转至财务审批。' + if (isBudgetApprovalStage.value) { + return '不填写附加意见则默认同意,确认后会归档申请单并生成报销草稿。' + } + return isApplicationDocument.value ? '不填写附加意见则默认同意,确认后会流转至预算管理者审批。' : '不填写附加意见则默认同意,审批通过后将流转至财务审批。' + }) + const approvalConfirmBadge = computed(() => { + if (isFinanceApprovalStage.value) { + return '财务终审' + } + return isBudgetApprovalStage.value ? '预算审核' : '领导审批' }) - const approvalConfirmBadge = computed(() => (isFinanceApprovalStage.value ? '财务终审' : '领导审批')) const approvalConfirmDescription = computed(() => { if (isFinanceApprovalStage.value) { return '确认后该报销单会完成财务终审并进入归档入账,请确认票据、金额与财务意见无误。' } if (isApplicationDocument.value) { - return '确认后该申请单会完成直属领导审批,并自动进入申请人的报销草稿中。' + return isBudgetApprovalStage.value + ? '确认后该申请单会完成预算审核,归档申请单,并自动进入申请人的报销草稿中。' + : '确认后该申请单会完成直属领导审批,并流转给预算管理者进一步审核。' } return '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。' }) - const approvalNextStage = computed(() => { - if (isFinanceApprovalStage.value) { - return '归档入账' - } - return isApplicationDocument.value ? '报销草稿' : '财务审批' - }) const approveActionLabel = computed(() => (isApplicationDocument.value ? '确认审核' : '审批通过')) const approveBusyLabel = computed(() => (isApplicationDocument.value ? '确认中' : '通过中')) const approveConfirmTitle = computed(() => ( @@ -581,15 +608,14 @@ export default { ? '退回后该申请单会回到待提交状态,申请人需要调整后重新提交。' : '退回后该单据会回到待提交状态,申请人需要调整后重新提交并再次经过 AI 预审。' )) - const approvalConfirmSummaryLabel = computed(() => ( - isApplicationDocument.value ? '生成结果' : '下一节点' - )) const approvalSuccessToast = computed(() => { if (isFinanceApprovalStage.value) { return `${request.value.id} 已完成财务终审,进入归档入账。` } return isApplicationDocument.value - ? `${request.value.id} 已确认审核,正在生成报销草稿。` + ? isBudgetApprovalStage.value + ? `${request.value.id} 已完成预算审核,正在生成报销草稿。` + : `${request.value.id} 已确认审核,已流转至预算管理者审批。` : `${request.value.id} 已审批通过,流转至财务审批。` }) const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据')) @@ -1751,15 +1777,10 @@ export default { return } - if (requiresApprovalOpinion.value && !leaderOpinion.value.trim()) { - toast('请先填写领导意见,填写后才能确认审核。') - return - } - approveBusy.value = true try { const responsePayload = await approveExpenseClaim(request.value.claimId, { - opinion: leaderOpinion.value.trim() + opinion: leaderOpinion.value.trim() || '同意' }) const generatedDraftClaimNo = resolveGeneratedDraftClaimNo(responsePayload) approveConfirmDialogOpen.value = false @@ -1805,7 +1826,7 @@ export default { emit, actionBusy, aiAdvice, aiAdviceHint, aiAdviceTitle, attachmentPreviewError, attachmentPreviewIndexLabel, attachmentPreviewLoading, attachmentPreviewMediaType, attachmentPreviewName, attachmentPreviewOpen, attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge, - approvalConfirmDescription, approvalConfirmSummaryLabel, approvalNextStage, approvalOpinionHint, + approvalConfirmDescription, approvalOpinionHint, approvalOpinionPlaceholder, approvalOpinionTitle, approveActionLabel, approveBusyLabel, applicationDetailFactItems, approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim, @@ -1836,6 +1857,7 @@ export default { requiresApprovalOpinion, riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId, showAiAdvicePanel, showApplicationLeaderOpinion, + showBudgetAnalysis, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen, submitRiskWarnings, triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit diff --git a/web/src/views/scripts/auditViewDigitalEmployeeModel.js b/web/src/views/scripts/auditViewDigitalEmployeeModel.js new file mode 100644 index 0000000..3249fa5 --- /dev/null +++ b/web/src/views/scripts/auditViewDigitalEmployeeModel.js @@ -0,0 +1,263 @@ +const DIGITAL_EMPLOYEE_AGENT = 'hermes' + +const TASK_TYPE_LABELS = { + daily_risk_scan: '每日风险巡检', + global_risk_scan: '全局风险巡检', + weekly_ar_summary: '周度应收账龄汇总', + weekly_expense_report: '周度费用洞察', + rule_review_digest: '规则待审摘要', + knowledge_index_sync: '知识库归集', + x_financial_callback: '任务回调上报' +} + +const CONTENT_LABELS = { + task_type: '技能类型', + schedule: '执行计划', + cron: '调度表达式', + folder: '归集范围', + changed_only: '仅处理变更', + force: '强制重建', + index_engine: '索引引擎', + callback_type: '回调类型', + status: '回写状态', + summary: '结果摘要' +} + +const HIDDEN_CONTENT_KEYS = new Set([ + 'agent', + 'target_agent', + 'callback_token', + 'token', + 'api_key', + 'authorization' +]) + +export function normalizeDigitalEmployeeText(value) { + return String(value ?? '').trim() +} + +export function sanitizeDigitalEmployeeText(value, fallback = '') { + const text = normalizeDigitalEmployeeText(value) + .replace(/hermes/gi, '数字员工') + .replace(/赫尔墨斯/g, '数字员工') + .replace(/\s+/g, ' ') + .trim() + return text || fallback +} + +export function sanitizeDigitalEmployeeName(value, fallback = '数字员工技能') { + const text = sanitizeDigitalEmployeeText(value, fallback) + .replace(/^数字员工[\s·::-]*/i, '') + .trim() + return text || fallback +} + +export function parseDigitalEmployeeContent(value) { + if (!value) { + return {} + } + if (typeof value === 'object' && !Array.isArray(value)) { + return value + } + if (typeof value !== 'string') { + return {} + } + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {} + } catch { + return {} + } +} + +export function resolveDigitalEmployeeTaskType(source = {}, content = {}) { + const config = source.config_json || source.configJson || {} + const raw = + normalizeDigitalEmployeeText(content.task_type) || + normalizeDigitalEmployeeText(config.task_type) || + normalizeDigitalEmployeeText(source.task_type) || + normalizeDigitalEmployeeText(source.code).replace(/^task\.hermes\./i, '') + return raw.replace(/[-.]/g, '_') +} + +export function isDigitalEmployeeAsset(source = {}) { + const config = source.config_json || source.configJson || {} + const haystack = [ + source.asset_type, + source.code, + source.name, + source.description, + config.agent, + config.target_agent, + config.worker, + config.runtime_agent + ] + .map((item) => normalizeDigitalEmployeeText(item).toLowerCase()) + .filter(Boolean) + .join(' ') + + return ( + normalizeDigitalEmployeeText(source.asset_type) === 'task' && + (haystack.includes(DIGITAL_EMPLOYEE_AGENT) || haystack.includes('task.hermes.')) + ) +} + +export function formatDigitalEmployeeCron(value) { + const raw = normalizeDigitalEmployeeText(value) + if (!raw) { + return '手动触发' + } + + const parts = raw.split(/\s+/) + if (parts.length < 5) { + return sanitizeDigitalEmployeeText(raw) + } + + const [minute, hour, dayOfMonth, month, dayOfWeek] = parts + const hourNumber = Number(hour) + const minuteNumber = Number(minute) + const timeLabel = + Number.isFinite(hourNumber) && Number.isFinite(minuteNumber) + ? `${String(hourNumber).padStart(2, '0')}:${String(minuteNumber).padStart(2, '0')}` + : `${hour}:${minute}` + + if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') { + return `每天 ${timeLabel}` + } + + if (dayOfMonth === '*' && month === '*' && dayOfWeek !== '*') { + const weekdayLabels = { + '0': '周日', + '1': '周一', + '2': '周二', + '3': '周三', + '4': '周四', + '5': '周五', + '6': '周六', + '7': '周日' + } + return `每${weekdayLabels[dayOfWeek] || `周${dayOfWeek}`} ${timeLabel}` + } + + return sanitizeDigitalEmployeeText(raw) +} + +export function resolveDigitalEmployeeSchedule(source = {}, content = {}) { + const config = source.config_json || source.configJson || {} + const raw = + normalizeDigitalEmployeeText(content.schedule) || + normalizeDigitalEmployeeText(config.cron) || + normalizeDigitalEmployeeText(config.schedule) || + normalizeDigitalEmployeeText(config.cron_expression) + return { + value: raw, + label: formatDigitalEmployeeCron(raw) + } +} + +export function resolveDigitalEmployeeEnabled(source = {}) { + const config = source.config_json || source.configJson || {} + if (config.enabled === false || config.is_enabled === false) { + return false + } + if (source.enabled === false || source.is_enabled === false) { + return false + } + return normalizeDigitalEmployeeText(source.status || 'active') === 'active' +} + +export function resolveDigitalEmployeeDisplayCode(source = {}, content = {}) { + const taskType = resolveDigitalEmployeeTaskType(source, content) + return taskType ? `digital.${taskType}` : 'digital.skill' +} + +function formatDigitalEmployeeValue(value) { + if (typeof value === 'boolean') { + return value ? '是' : '否' + } + if (Array.isArray(value)) { + return value.map((item) => sanitizeDigitalEmployeeText(item)).filter(Boolean).join('、') || '-' + } + if (value && typeof value === 'object') { + return sanitizeDigitalEmployeeText(JSON.stringify(value, null, 2)) + } + return sanitizeDigitalEmployeeText(value, '-') +} + +export function buildDigitalEmployeeContentRows(content = {}) { + return Object.entries(content) + .filter(([key]) => !HIDDEN_CONTENT_KEYS.has(normalizeDigitalEmployeeText(key).toLowerCase())) + .map(([key, value]) => ({ + key, + label: CONTENT_LABELS[key] || key, + value: formatDigitalEmployeeValue(value) + })) +} + +export function buildDigitalEmployeeContentPreview(content = {}) { + const visiblePayload = {} + for (const [key, value] of Object.entries(content)) { + if (HIDDEN_CONTENT_KEYS.has(normalizeDigitalEmployeeText(key).toLowerCase())) { + continue + } + visiblePayload[key] = value + } + return sanitizeDigitalEmployeeText(JSON.stringify(visiblePayload, null, 2)) +} + +export function buildDigitalEmployeeListMeta(source = {}) { + const content = parseDigitalEmployeeContent(source.current_version_content) + const taskType = resolveDigitalEmployeeTaskType(source, content) + const schedule = resolveDigitalEmployeeSchedule(source, content) + const enabled = resolveDigitalEmployeeEnabled(source) + const fallbackName = TASK_TYPE_LABELS[taskType] || '数字员工技能' + + return { + name: sanitizeDigitalEmployeeName(source.name, fallbackName), + code: resolveDigitalEmployeeDisplayCode(source, content), + summary: sanitizeDigitalEmployeeText(source.description, '面向后台自动执行的数字员工技能。'), + category: '数字员工', + owner: sanitizeDigitalEmployeeText(source.owner, '平台运营'), + reviewer: sanitizeDigitalEmployeeText(source.reviewer, '系统'), + scope: schedule.label, + scheduleLabel: schedule.label, + executionMode: schedule.value ? '定时执行' : '手动触发', + enabled, + enabledLabel: enabled ? '已启动' : '未启动', + enabledTone: enabled ? 'success' : 'disabled', + taskType + } +} + +export function buildDigitalEmployeeDetailMeta(source = {}) { + const content = parseDigitalEmployeeContent(source.current_version_content) + const listMeta = buildDigitalEmployeeListMeta({ + ...source, + current_version_content: content + }) + const schedule = resolveDigitalEmployeeSchedule(source, content) + const contentRows = buildDigitalEmployeeContentRows(content) + + return { + ...listMeta, + rawCode: normalizeDigitalEmployeeText(source.code), + description: sanitizeDigitalEmployeeText( + source.description, + '该技能由后台数字员工按计划执行,并把结果沉淀到对应业务资产或运行日志中。' + ), + contentRows, + contentPreview: buildDigitalEmployeeContentPreview(content), + scheduleRows: [ + { label: '执行计划', value: schedule.label }, + { label: '调度表达式', value: schedule.value || '手动触发' }, + { label: '启动状态', value: listMeta.enabledLabel, tone: listMeta.enabledTone }, + { label: '执行方式', value: listMeta.executionMode } + ], + overviewRows: [ + { label: '能力编号', value: listMeta.code }, + { label: '业务归口', value: listMeta.owner }, + { label: '当前版本', value: source.working_version || source.current_version || '-' }, + { label: '最近更新', value: source.updated_at || '-' } + ] + } +} diff --git a/web/src/views/scripts/auditViewMetadata.js b/web/src/views/scripts/auditViewMetadata.js index ea08d5f..d611114 100644 --- a/web/src/views/scripts/auditViewMetadata.js +++ b/web/src/views/scripts/auditViewMetadata.js @@ -96,6 +96,32 @@ export const TAB_META = { ...TYPE_META.mcp, typeKey: 'mcp', badgeTone: 'amber' + }, + digitalWorkers: { + assetType: 'task', + typeKey: 'digitalWorkers', + label: '数字员工', + typeLabel: '数字员工', + createButtonLabel: '数字员工已接入', + hintText: '归集后台自动执行的数字员工技能,可查看技能内容、执行计划、启动状态和最近版本。', + searchPlaceholder: '搜索数字员工技能、编号、执行计划或维护人', + showMetricColumn: true, + showRuntimeColumn: true, + showVersionColumn: true, + showStatusColumn: true, + showEnabledColumn: true, + tableColumns: { + name: '技能名称', + category: '归集标签', + owner: '维护归口', + scope: '执行计划', + runtime: '触发方式', + version: '当前版本', + status: '资产状态', + metric: '运行方式', + updatedAt: '最近更新' + }, + badgeTone: 'violet' } } @@ -208,6 +234,24 @@ export const DETAIL_TITLES = { historyDesc: '最近版本记录', publishTitle: '服务状态', publishDesc: 'MCP 资产已接入规则中心,但真实外部调用仍以后续链路集成为准。' + }, + digitalWorkers: { + configTitle: '技能档案', + configDesc: '展示数字员工技能的编号、归口、执行计划和启停状态。', + detailTitle: '技能内容', + detailDesc: '展示当前版本记录的任务类型、调度范围和执行参数。', + outputTitle: '执行安排', + outputDesc: '展示什么时候执行、是否启动,以及当前运行方式。', + ruleListTitle: '技能参数', + checkListTitle: '启动状态', + triggerTitle: '执行计划', + triggerDesc: '当前技能的计划执行时间或触发方式。', + toolTitle: '运行归口', + toolDesc: '数字员工技能由后台调度执行,运行结果进入对应日志或业务资产。', + historyTitle: '版本记录', + historyDesc: '最近的技能配置快照。', + publishTitle: '启动状态', + publishDesc: '数字员工技能由资产状态和调度配置共同决定是否启动。' } } diff --git a/web/src/views/scripts/auditViewModel.js b/web/src/views/scripts/auditViewModel.js index 1e1f3fa..17463c8 100644 --- a/web/src/views/scripts/auditViewModel.js +++ b/web/src/views/scripts/auditViewModel.js @@ -34,6 +34,13 @@ import { resolveRiskRuleSeverity, resolveRiskRuleSeverityLabel } from './auditViewRiskRuleModel.js' +import { + buildDigitalEmployeeContentRows, + buildDigitalEmployeeDetailMeta, + buildDigitalEmployeeListMeta, + isDigitalEmployeeAsset, + sanitizeDigitalEmployeeText +} from './auditViewDigitalEmployeeModel.js' const EXPENSE_TYPE_SCENARIO_LABELS = { travel: '差旅费', @@ -335,6 +342,9 @@ export function resolveTabId(source, typeKey) { if (typeKey === 'rules') { return resolveRuleTabId(source) } + if (typeKey === 'digitalWorkers') { + return isDigitalEmployeeAsset(source) ? 'digitalWorkers' : '' + } return typeKey } @@ -895,6 +905,9 @@ export function resolveTypeKey(assetType) { if (assetType === 'mcp') { return 'mcp' } + if (assetType === 'task') { + return 'digitalWorkers' + } return '' } @@ -958,6 +971,9 @@ export function buildRowRuntime(asset, typeKey) { if (typeKey === 'mcp') { return normalizeText(asset.config_json?.endpoint) || '未配置地址' } + if (typeKey === 'digitalWorkers') { + return buildDigitalEmployeeListMeta(asset).executionMode + } return '' } @@ -971,6 +987,9 @@ export function buildRowMetric(asset, typeKey) { if (typeKey === 'mcp') { return asset.config_json?.timeout_ms ? `${asset.config_json.timeout_ms} ms` : '未配置超时' } + if (typeKey === 'digitalWorkers') { + return buildDigitalEmployeeListMeta(asset).executionMode + } return '' } @@ -1042,6 +1061,19 @@ export function buildListItem(asset) { ? resolveRiskRuleScoreLabel(asset.config_json, asset.config_json) || resolveRiskRuleSeverityLabel(asset.config_json) : resolveRiskRuleSeverityLabel(asset.config_json) : '' + const digitalMeta = typeKey === 'digitalWorkers' ? buildDigitalEmployeeListMeta(asset) : null + const displayName = digitalMeta?.name || asset.name + const displayCode = digitalMeta?.code || asset.code + const displaySummary = digitalMeta?.summary || listSubtitle + const displayOwner = digitalMeta?.owner || (isRiskRule ? creator : asset.owner) + const displayReviewer = digitalMeta?.reviewer || reviewer + const displayCategory = digitalMeta?.category || resolveDomainLabel(asset.domain) + const displayScope = + digitalMeta?.scope || + (typeKey === 'rules' ? ruleScenarioCategory || '閫氱敤' : formatScenarioList(asset.scenario_json)) + const displayEnabledValue = digitalMeta ? digitalMeta.enabled : isEnabledValue + const displayEnabledLabel = digitalMeta?.enabledLabel || (isEnabledValue ? '鏄? : '鍚?) + const displayEnabledTone = digitalMeta?.enabledTone || (isEnabledValue ? 'success' : 'disabled') return { id: asset.id, @@ -1052,15 +1084,17 @@ export function buildListItem(asset) { usesJsonRiskRule, ruleDocument, typeLabel: tabMeta.typeLabel, - short: makeShort(asset.name), - name: asset.name, - code: asset.code, - summary: listSubtitle, - listSubtitle, - category: resolveDomainLabel(asset.domain), - owner: isRiskRule ? creator : asset.owner, - reviewer, + short: makeShort(displayName), + name: displayName, + code: displayCode, + rawCode: asset.code, + summary: displaySummary, + listSubtitle: displaySummary, + category: displayCategory, + owner: displayOwner, + reviewer: displayReviewer, scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(asset.scenario_json), + scope: displayScope, riskCategory: ruleScenarioCategory, scenarioList: ruleScenarioList, businessStageValue: businessStage.value, @@ -1086,6 +1120,9 @@ export function buildListItem(asset) { isEnabledValue, isEnabledLabel: isEnabledValue ? '是' : '否', isEnabledTone: isEnabledValue ? 'success' : 'disabled', + isEnabledValue: displayEnabledValue, + isEnabledLabel: displayEnabledLabel, + isEnabledTone: displayEnabledTone, modifiedBy, changeCount, updatedAt: isRiskRule ? riskRuleCreatedAt : formatDateTime(asset.updated_at), @@ -1417,6 +1454,25 @@ export function buildDetailViewModel(detail, runs) { const initialRiskRuleScore = resolveRiskRuleScore(configJson, configJson) const initialRiskRuleScoreLevel = resolveRiskRuleScoreLevel(configJson, configJson) const initialRiskRuleSeverity = initialRiskRuleScoreLevel || resolveRiskRuleSeverity(configJson) + const digitalMeta = typeKey === 'digitalWorkers' + ? buildDigitalEmployeeDetailMeta({ + ...detail, + updated_at: formatDateTime(detail.updated_at) + }) + : null + const detailName = digitalMeta?.name || detail.name + const detailCode = digitalMeta?.code || detail.code + const detailSummary = digitalMeta?.description || + (usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : detail.description) + const detailOwner = digitalMeta?.owner || detail.owner + const detailReviewer = digitalMeta?.reviewer || detail.reviewer || detail.latest_review?.reviewer || '寰呭垎閰? + const detailCategory = digitalMeta?.category || resolveDomainLabel(detail.domain) + const detailScope = + digitalMeta?.scope || + (typeKey === 'rules' ? ruleScenarioCategory || '閫氱敤' : formatScenarioList(detail.scenario_json)) + const detailEnabledValue = digitalMeta ? digitalMeta.enabled : isEnabledValue + const detailEnabledLabel = digitalMeta?.enabledLabel || (isEnabledValue ? '鏄? : '鍚?) + const detailEnabledTone = digitalMeta?.enabledTone || (isEnabledValue ? 'success' : 'disabled') return { id: detail.id, diff --git a/web/tests/accessControl.test.mjs b/web/tests/accessControl.test.mjs index 7769b0a..9479727 100644 --- a/web/tests/accessControl.test.mjs +++ b/web/tests/accessControl.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import { + canApproveBudgetExpenseApplications, canApproveLeaderExpenseClaims, canAccessAppView, canDeleteArchivedExpenseClaims, @@ -22,6 +23,24 @@ test('direct approvers can return claims without receiving delete permissions', assert.equal(canReturnExpenseClaims(approverUser), true) assert.equal(canApproveLeaderExpenseClaims(managerUser), true) assert.equal(canApproveLeaderExpenseClaims(approverUser), true) + assert.equal(canApproveBudgetExpenseApplications({ roleCodes: ['budget_monitor'], grade: 'P6' }), false) + assert.equal(canApproveBudgetExpenseApplications({ roleCodes: ['budget_monitor'], grade: 'P8' }), true) + assert.equal( + canApproveBudgetExpenseApplications( + { roleCodes: ['budget_monitor'], grade: 'P8', departmentName: '交付部' }, + { departmentName: '交付部' } + ), + true + ) + assert.equal( + canApproveBudgetExpenseApplications( + { roleCodes: ['budget_monitor'], grade: 'P8', departmentName: '财务部' }, + { departmentName: '交付部' } + ), + false + ) + assert.equal(canApproveBudgetExpenseApplications({ roleCodes: [], grade: 'P8' }), false) + assert.equal(canApproveBudgetExpenseApplications({ roleCodes: ['executive'] }), true) assert.equal(canManageExpenseClaims(managerUser), false) assert.equal(canManageExpenseClaims(approverUser), false) }) @@ -81,6 +100,37 @@ test('finance approval inbox only processes finance-stage requests', () => { ) }) +test('budget approval inbox only processes budget-stage requests for budget monitor or senior finance roles', () => { + const budgetUser = { roleCodes: ['budget_monitor'], grade: 'P8', name: '赵预算', departmentName: '交付部' } + const otherDepartmentBudgetUser = { roleCodes: ['budget_monitor'], grade: 'P8', name: '王预算', departmentName: '财务部' } + const seniorFinanceUser = { roleCodes: ['executive'], grade: 'P7', name: '高级财务' } + const p8WithoutBudgetRole = { roleCodes: ['manager'], grade: 'P8', name: '高职级经理' } + + assert.equal( + canProcessApprovalRequest({ workflowNode: '预算管理者审批', person: '张三', departmentName: '交付部' }, budgetUser), + true + ) + assert.equal( + canProcessApprovalRequest({ workflowNode: '预算管理者审批', person: '张三', departmentName: '交付部' }, seniorFinanceUser), + true + ) + assert.equal( + canProcessApprovalRequest( + { workflowNode: '预算管理者审批', person: '张三', departmentName: '交付部' }, + otherDepartmentBudgetUser + ), + false + ) + assert.equal( + canProcessApprovalRequest({ workflowNode: '预算管理者审批', person: '张三' }, p8WithoutBudgetRole), + false + ) + assert.equal( + canProcessApprovalRequest({ workflowNode: '财务审批', person: '张三' }, budgetUser), + false + ) +}) + test('users with both finance and manager roles can process both relevant stages', () => { const financeManagerUser = { roleCodes: ['finance', 'manager'], name: '李经理' } diff --git a/web/tests/app-shell-detail-alerts.test.mjs b/web/tests/app-shell-detail-alerts.test.mjs index 6b862a3..3153ee2 100644 --- a/web/tests/app-shell-detail-alerts.test.mjs +++ b/web/tests/app-shell-detail-alerts.test.mjs @@ -57,7 +57,7 @@ test('detail topbar ignores system allowance rows when checking missing tickets' assert.equal(hasMissingAttachment(request), false) assert.equal(hasPendingInfo(request), false) - assert.deepEqual(alerts, ['直属领导审批']) + assert.deepEqual(alerts, ['SLA 催单次数 0']) }) test('detail topbar still flags real manual rows without required ticket info', () => { @@ -96,7 +96,7 @@ test('detail topbar still flags real manual rows without required ticket info', assert.equal(hasMissingAttachment(request), true) assert.equal(hasPendingInfo(request), true) - assert.deepEqual(alerts, ['待提交', '缺少票据', '待补信息']) + assert.deepEqual(alerts, ['SLA 催单次数 0', '缺少票据', '待补信息']) }) test('application detail topbar does not ask for receipt attachments', () => { @@ -122,5 +122,29 @@ test('application detail topbar does not ask for receipt attachments', () => { assert.equal(hasMissingAttachment(request), false) assert.equal(alerts.includes('缺少票据'), false) - assert.deepEqual(alerts, ['直属领导审批']) + assert.deepEqual(alerts, ['SLA 催单次数 0']) +}) + +test('detail topbar shows SLA reminder count from direct fields and reminder events', () => { + const directAlerts = buildDetailAlerts({ + node: '直属领导审批', + approvalKey: 'in_progress', + slaReminderCount: 2, + expenseItems: [] + }) + + const eventAlerts = buildDetailAlerts({ + node: '直属领导审批', + approvalKey: 'in_progress', + riskFlags: [ + { source: 'sla_reminder', message: '下属已催单' }, + { event_type: 'urge', message: '再次催单' } + ], + expenseItems: [] + }) + + assert.equal(directAlerts[0].label, 'SLA 催单次数 2') + assert.equal(directAlerts[0].tone, 'warning') + assert.equal(directAlerts[0].icon, 'mdi mdi-bell-ring-outline') + assert.equal(eventAlerts[0].label, 'SLA 催单次数 2') }) diff --git a/web/tests/documents-center-status-filter.test.mjs b/web/tests/documents-center-status-filter.test.mjs index 68619b5..4ff0ee7 100644 --- a/web/tests/documents-center-status-filter.test.mjs +++ b/web/tests/documents-center-status-filter.test.mjs @@ -85,15 +85,20 @@ test('documents center list shows created time and conditional stay time columns assert.match(documentsCenterView, /import \{[\s\S]*formatDocumentListTime[\s\S]*resolveDocumentStayTimeDisplay[\s\S]*\} from '..\/utils\/documentCenterTime\.js'/) assert.match(documentsCenterView, //) assert.match(documentsCenterView, //) + assert.match(documentsCenterView, //) assert.match(documentsCenterView, /单号<\/th>[\s\S]*创建时间<\/th>[\s\S]*停留时间<\/th>/) + assert.match(documentsCenterView, /费用场景<\/th>[\s\S]*发起人<\/th>[\s\S]*事项<\/th>/) assert.match(documentsCenterView, /\{\{ row\.createdAtDisplay \}\}<\/td>/) assert.match(documentsCenterView, /\{\{ row\.stayTimeDisplay \}\}<\/td>/) + assert.match(documentsCenterView, /\{\{ row\.initiatorName \}\}<\/td>/) assert.match( documentsCenterView, /const showStayTimeColumn = computed\(\(\) =>[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REVIEW/ ) assert.match(documentsCenterView, /createdAtDisplay: formatDocumentListTime\(createdAtSource\)/) assert.match(documentsCenterView, /stayTimeDisplay: resolveDocumentStayTimeDisplay\(normalized\)/) + assert.match(documentsCenterView, /initiatorName,/) + assert.match(documentsCenterView, /row\.initiatorName/) }) test('documents center action buttons are scoped to application and reimbursement tabs', () => { @@ -225,9 +230,10 @@ test('documents center status dropdown uses compact filter styling', () => { assert.match(documentsCenterStyles, /\.documents-list\s*\{[\s\S]*grid-template-rows:\s*auto auto minmax\(0,\s*1fr\) auto;/) assert.match(documentsCenterStyles, /\.status-tabs button\s*\{[\s\S]*display:\s*inline-flex;/) assert.match(documentsCenterStyles, /\.scope-tab-badge\s*\{[\s\S]*border-radius:\s*999px;/) - assert.match(documentsCenterStyles, /min-width:\s*1320px;/) + assert.match(documentsCenterStyles, /min-width:\s*1420px;/) assert.match(documentsCenterStyles, /\.col-created\s*\{\s*width:\s*10%;\s*\}/) assert.match(documentsCenterStyles, /\.col-stay\s*\{\s*width:\s*9%;\s*\}/) + assert.match(documentsCenterStyles, /\.col-initiator\s*\{\s*width:\s*8%;\s*\}/) assert.match(documentsCenterStyles, /\.document-status-filter\s*\{[\s\S]*display:\s*inline-flex;/) assert.match(documentsCenterStyles, /\.document-status-filter\s*\{[\s\S]*min-height:\s*38px;/) assert.match(documentsCenterStyles, /\.status-filter-trigger\s*\{[\s\S]*min-width:\s*154px;/) diff --git a/web/tests/requestProgressSteps.test.mjs b/web/tests/requestProgressSteps.test.mjs index 62fe3f6..d2c91ab 100644 --- a/web/tests/requestProgressSteps.test.mjs +++ b/web/tests/requestProgressSteps.test.mjs @@ -5,10 +5,12 @@ import { mapExpenseClaimToRequest } from '../src/composables/useRequests.js' const CREATE_APPLICATION = '\u521b\u5efa\u7533\u8bf7' const DIRECT_MANAGER_APPROVAL = '\u76f4\u5c5e\u9886\u5bfc\u5ba1\u6279' +const BUDGET_MANAGER_APPROVAL = '\u9884\u7b97\u7ba1\u7406\u8005\u5ba1\u6279' const APPROVAL_COMPLETED = '\u5ba1\u6279\u5b8c\u6210' const RETURNED = '\u9000\u56de' const WAIT_SUBMIT = '\u5f85\u63d0\u4ea4' const WAIT_LEADER_LI_APPROVAL = '\u7b49\u5f85 Leader Li \u6279\u590d' +const WAIT_BUDGET_ZHAO_APPROVAL = '\u7b49\u5f85 \u8d75\u9884\u7b97 \u6279\u590d' const LEADER_RETURNED_STATUS = '\u9886\u5bfc\u5df2\u9000\u56de\uff0c\u5f85\u91cd\u65b0\u63d0\u4ea4' test('application claims are mapped as application documents', () => { @@ -41,7 +43,7 @@ test('application claims are mapped as application documents', () => { assert.equal(request.expenseTableSummary, '预计金额已随申请提交') assert.deepEqual( request.progressSteps.map((step) => step.label), - [CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, APPROVAL_COMPLETED] + [CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, BUDGET_MANAGER_APPROVAL, APPROVAL_COMPLETED] ) assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false) assert.equal(request.progressSteps.some((step) => step.label === '财务审批'), false) @@ -50,6 +52,47 @@ test('application claims are mapped as application documents', () => { assert.equal(request.progressSteps.find((step) => step.label === WAIT_LEADER_LI_APPROVAL)?.current, true) }) +test('application claims wait for department P8 budget monitor after leader approval', () => { + const request = mapExpenseClaimToRequest({ + id: 'claim-application-budget', + claim_no: 'AP-20260525103145-BUDGET', + employee_name: '张三', + department_name: '交付部', + manager_name: 'Leader Li', + expense_type: 'travel_application', + reason: '支撑国网服务器上线部署', + location: '上海', + amount: 12000, + invoice_count: 0, + occurred_at: '2026-05-25T00:00:00.000Z', + submitted_at: '2026-05-25T02:00:00.000Z', + created_at: '2026-05-25T01:30:00.000Z', + updated_at: '2026-05-25T03:00:00.000Z', + status: 'submitted', + approval_stage: BUDGET_MANAGER_APPROVAL, + risk_flags_json: [ + { + source: 'manual_approval', + event_type: 'expense_application_approval', + operator: 'Leader Li', + previous_approval_stage: DIRECT_MANAGER_APPROVAL, + next_approval_stage: BUDGET_MANAGER_APPROVAL, + next_approver_name: '赵预算', + next_approver_grade: 'P8', + created_at: '2026-05-25T03:00:00.000Z' + } + ], + items: [] + }) + + assert.deepEqual( + request.progressSteps.map((step) => step.label), + [CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_ZHAO_APPROVAL, APPROVAL_COMPLETED] + ) + assert.equal(request.progressSteps.find((step) => step.label === WAIT_BUDGET_ZHAO_APPROVAL)?.current, true) + assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, 'Leader Li通过') +}) + test('returned application claims include leader return node and supplement status', () => { const request = mapExpenseClaimToRequest({ id: 'claim-application-returned', @@ -86,7 +129,7 @@ test('returned application claims include leader return node and supplement stat assert.deepEqual( request.progressSteps.map((step) => step.label), - [CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, RETURNED, WAIT_SUBMIT] + [CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, RETURNED, WAIT_SUBMIT] ) assert.equal(request.progressSteps.find((step) => step.label === RETURNED)?.time, 'Leader Li\u9000\u56de') assert.match(request.progressSteps.find((step) => step.label === RETURNED)?.detail, /2026-05-25/) @@ -96,7 +139,7 @@ test('returned application claims include leader return node and supplement stat assert.equal(request.progressSteps.some((step) => step.label === APPROVAL_COMPLETED), false) }) -test('approved application claims complete after direct manager approval only', () => { +test('approved application claims complete after budget approval', () => { const request = mapExpenseClaimToRequest({ id: 'claim-application-approved', claim_no: 'AP-20260525113045-HGFEDCBA', @@ -120,6 +163,16 @@ test('approved application claims complete after direct manager approval only', event_type: 'expense_application_approval', operator: '李经理', previous_approval_stage: '直属领导审批', + next_approval_stage: '预算管理者审批', + next_approver_name: '赵预算', + next_approver_grade: 'P8', + created_at: '2026-05-25T03:00:00.000Z' + }, + { + source: 'budget_approval', + event_type: 'expense_application_budget_approval', + operator: '赵预算', + previous_approval_stage: '预算管理者审批', next_approval_stage: '审批完成', created_at: '2026-05-25T03:00:00.000Z' } @@ -131,10 +184,11 @@ test('approved application claims complete after direct manager approval only', assert.equal(request.workflowNode, '审批完成') assert.deepEqual( request.progressSteps.map((step) => step.label), - ['创建申请', '直属领导审批', '审批完成'] + ['创建申请', '直属领导审批', '预算管理者审批', '审批完成'] ) assert.equal(request.progressSteps.every((step) => step.done), true) assert.equal(request.progressSteps.find((step) => step.label === '直属领导审批')?.time, '李经理通过') + assert.equal(request.progressSteps.find((step) => step.label === '预算管理者审批')?.time, '赵预算通过') }) test('progress steps show approval operator time and current stay duration', () => { diff --git a/web/tests/travel-request-detail-leader-approval.test.mjs b/web/tests/travel-request-detail-leader-approval.test.mjs index aa0e502..10197e9 100644 --- a/web/tests/travel-request-detail-leader-approval.test.mjs +++ b/web/tests/travel-request-detail-leader-approval.test.mjs @@ -19,6 +19,10 @@ const approvalDialog = readFileSync( fileURLToPath(new URL('../src/components/travel/TravelRequestApprovalDialog.vue', import.meta.url)), 'utf8' ) +const budgetAnalysisComponent = readFileSync( + fileURLToPath(new URL('../src/components/travel/TravelRequestBudgetAnalysis.vue', import.meta.url)), + 'utf8' +) const reimbursementService = readFileSync( fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)), 'utf8' @@ -53,18 +57,23 @@ test('approval-mode detail collects leader opinion inside confirm dialog before assert.match(detailScript, /const approveConfirmDialogOpen = ref\(false\)/) assert.match(detailScript, /const canApproveRequest = computed/) assert.match(detailScript, /canApproveLeaderExpenseClaims/) + assert.match(detailScript, /canApproveBudgetExpenseApplications/) assert.match(detailScript, /isCurrentDirectManagerForRequest/) assert.match(detailScript, /isCurrentRequestApplicant/) assert.match(detailScript, /isFinanceApprovalStage/) + assert.match(detailScript, /const isBudgetApprovalStage = computed/) + assert.match(detailScript, /const showBudgetAnalysis = computed/) assert.match(detailScript, /const isCurrentApplicant = computed/) assert.match(detailScript, /const isCurrentDirectManagerApprover = computed/) assert.match(detailScript, /const canProcessFinanceApprovalStage = computed/) + assert.match(detailScript, /const canProcessBudgetApprovalStage = computed/) assert.match(detailScript, /approvalOpinionTitle/) assert.match(detailScript, /approvalConfirmDescription/) - assert.match(detailScript, /approvalNextStage/) + assert.doesNotMatch(detailScript, /approvalNextStage/) assert.doesNotMatch(detailScript, /showApplicationLeaderOpinionInput/) assert.doesNotMatch(detailScript, /showLeaderApprovalPanel/) - assert.match(detailScript, /const requiresApprovalOpinion = computed\(\(\) => isDirectManagerApprovalStage\.value\)/) + assert.match(detailScript, /const requiresApprovalOpinion = computed\(\(\) => false\)/) + assert.match(detailScript, /approvalOpinionTitle = computed\(\(\) => \(isFinanceApprovalStage\.value \? '财务意见' : '附加意见'\)\)/) assert.match(detailScript, /buildLeaderApprovalEvents/) assert.match(detailScript, /buildLeaderApprovalInfo/) assert.match(detailScript, /const leaderApprovalEvents = computed/) @@ -76,11 +85,13 @@ test('approval-mode detail collects leader opinion inside confirm dialog before assert.match(detailScript, /isDirectManagerApprovalStage\.value\)[\s\S]*return isCurrentDirectManagerApprover\.value/) assert.match(detailScript, /isDirectManagerApprovalStage\.value[\s\S]*&& isCurrentDirectManagerApprover\.value/) assert.match(detailScript, /canProcessFinanceApprovalStage\.value/) + assert.match(detailScript, /canProcessBudgetApprovalStage\.value/) assert.doesNotMatch(detailScript, /leaderApprovalReadonlyText/) assert.match(detailScript, /resolveGeneratedDraftClaimNo/) assert.match(detailScript, /approveActionLabel/) - assert.match(detailScript, /approveExpenseClaim\(request\.value\.claimId, \{[\s\S]*opinion: leaderOpinion\.value\.trim\(\)/) + assert.match(detailScript, /approveExpenseClaim\(request\.value\.claimId, \{[\s\S]*opinion: leaderOpinion\.value\.trim\(\) \|\| '同意'/) assert.match(detailScript, /报销草稿 \$\{generatedDraftClaimNo\} 已生成/) + assert.match(detailScript, /流转至预算管理者审批/) assert.doesNotMatch(detailTemplate, /v-if="showLeaderApprovalPanel"/) assert.doesNotMatch(detailTemplate, /showApplicationLeaderOpinionInput/) @@ -96,6 +107,7 @@ test('approval-mode detail collects leader opinion inside confirm dialog before assert.doesNotMatch(detailTemplate, /leaderApprovalReadonlyText/) assert.doesNotMatch(detailTemplate, /\u5f85\u76f4\u5c5e\u9886\u5bfc\u586b\u5199\u5ba1\u6279\u610f\u89c1/) assert.match(detailTemplate, /领导意见/) + assert.match(detailTemplate, /