From e7bef0883d9a9777e77bb86e21bf4e7960d9ca74 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Tue, 26 May 2026 17:29:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=A2=84=E7=AE=97?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E6=9C=8D=E5=8A=A1=E4=B8=8E=E5=B7=AE=E6=97=85?= =?UTF-8?q?=E9=A3=8E=E9=99=A9=E8=A7=84=E5=88=99=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端新增预算模型、端点和服务模块,支持预算 CRUD 和余额 查询,清理旧生成规则文件并替换为按严重等级分类的差旅风 险规则库,优化认证权限和报销单访问策略,新增财务规则目 录和演示数据构建脚本,前端预算中心增加对话框交互,完善 审计页面运行时模型和元数据展示,补充单元测试。 --- .../01_finance-rules.md | 35 + .../02_risk-rules.md | 39 + .../expense-control-demo-data/TODO.md | 20 + ...travel.generated_20260525155459576686.json | 178 ---- ...travel.generated_20260526101144234987.json | 180 ---- ...travel.generated_20260526101531166364.json | 179 ---- ...travel.generated_20260526101826456519.json | 179 ---- ...travel.generated_20260526101912249313.json | 213 ----- ...travel.generated_20260526101948535257.json | 179 ---- .../risk.travel.high.city_mismatch.json | 173 ++++ .../risk.travel.high.date_outside_trip.json | 161 ++++ .../risk.travel.high.personal_purpose.json | 135 +++ .../risk.travel.high.preapproval_absent.json | 135 +++ ...travel.low.application_fields_missing.json | 138 ++++ ...isk.travel.low.attachment_ocr_missing.json | 109 +++ ...el.low.local_transport_detail_missing.json | 161 ++++ .../risk.travel.low.vague_ticket_content.json | 107 +++ .../risk.travel.medium.duplicate_ticket.json | 107 +++ ...sk.travel.medium.multi_city_no_reason.json | 125 +++ .../risk.travel.medium.reason_too_brief.json | 108 +++ .../risk.travel.medium.title_mismatch.json | 118 +++ .../build_expense_control_demo_risk_rules.py | 704 ++++++++++++++++ server/scripts/sync_finance_rule_assets.py | 28 + server/src/app/api/deps.py | 162 +++- .../src/app/api/v1/endpoints/agent_assets.py | 10 +- server/src/app/api/v1/endpoints/budgets.py | 338 ++++++++ .../app/api/v1/endpoints/reimbursements.py | 4 +- server/src/app/api/v1/router.py | 2 + server/src/app/db/base.py | 4 + server/src/app/models/__init__.py | 4 + server/src/app/models/budget.py | 115 +++ server/src/app/schemas/budget.py | 120 +++ server/src/app/schemas/settings.py | 2 +- .../app/services/agent_asset_spreadsheet.py | 4 + .../services/agent_foundation_asset_seed.py | 39 +- .../services/agent_foundation_asset_topup.py | 41 +- .../services/agent_foundation_constants.py | 4 +- .../services/agent_foundation_risk_rules.py | 102 ++- .../services/agent_foundation_spreadsheets.py | 139 +++- server/src/app/services/auth.py | 5 +- server/src/app/services/budget.py | 776 ++++++++++++++++++ server/src/app/services/budget_support.py | 623 ++++++++++++++ server/src/app/services/budget_types.py | 54 ++ server/src/app/services/employee.py | 10 + .../src/app/services/employee_seed_part1.py | 6 +- .../src/app/services/employee_seed_part2.py | 8 +- .../src/app/services/employee_seed_roles.py | 20 +- .../services/expense_claim_access_policy.py | 2 +- .../app/services/expense_claim_budget_flow.py | 96 +++ .../services/expense_claim_platform_risk.py | 48 +- server/src/app/services/expense_claims.py | 48 +- .../src/app/services/finance_rule_catalog.py | 20 + .../services/risk_rule_template_executor.py | 15 + .../app/services/user_agent_application.py | 2 +- server/tests/test_agent_asset_service.py | 64 +- server/tests/test_budget_endpoints.py | 199 +++++ server/tests/test_expense_claim_service.py | 324 +++++++- server/tests/test_risk_rule_generation.py | 110 ++- .../styles/views/budget-center-dialog.css | 35 +- .../styles/views/budget-center-view.css | 4 + .../shared/ExpenseApplicationDialog.vue | 2 +- web/src/composables/useAppShell.js | 2 +- web/src/composables/useRequests.js | 2 +- web/src/composables/useSystemState.js | 41 +- web/src/services/api.js | 23 +- web/src/services/budgets.js | 30 + web/src/utils/accessControl.js | 104 ++- web/src/utils/budgetOntology.js | 19 +- web/src/utils/expenseApplicationOntology.js | 2 +- web/src/views/AppShellRouteView.vue | 2 +- web/src/views/AuditView.vue | 89 +- web/src/views/BudgetCenterView.vue | 47 +- web/src/views/EmployeeManagementView.vue | 2 +- .../views/TravelReimbursementCreateView.vue | 2 +- web/src/views/scripts/AuditView.js | 41 +- web/src/views/scripts/BudgetCenterView.js | 271 +++++- .../views/scripts/EmployeeManagementView.js | 14 +- .../views/scripts/TravelRequestDetailView.js | 2 +- web/src/views/scripts/auditViewMetadata.js | 8 +- web/src/views/scripts/auditViewModel.js | 119 ++- .../views/scripts/auditViewRuntimeModel.js | 10 +- web/tests/accessControl.test.mjs | 23 +- web/tests/budget-ontology.test.mjs | 11 +- ...e-application-submit-rich-confirm.test.mjs | 2 +- web/tests/requestProgressSteps.test.mjs | 2 +- 85 files changed, 6443 insertions(+), 1497 deletions(-) create mode 100644 document/development/expense-control-demo-data/01_finance-rules.md create mode 100644 document/development/expense-control-demo-data/02_risk-rules.md create mode 100644 document/development/expense-control-demo-data/TODO.md delete mode 100644 server/rules/risk-rules/risk.expense.travel.generated_20260525155459576686.json delete mode 100644 server/rules/risk-rules/risk.expense.travel.generated_20260526101144234987.json delete mode 100644 server/rules/risk-rules/risk.expense.travel.generated_20260526101531166364.json delete mode 100644 server/rules/risk-rules/risk.expense.travel.generated_20260526101826456519.json delete mode 100644 server/rules/risk-rules/risk.expense.travel.generated_20260526101912249313.json delete mode 100644 server/rules/risk-rules/risk.expense.travel.generated_20260526101948535257.json create mode 100644 server/rules/risk-rules/risk.travel.high.city_mismatch.json create mode 100644 server/rules/risk-rules/risk.travel.high.date_outside_trip.json create mode 100644 server/rules/risk-rules/risk.travel.high.personal_purpose.json create mode 100644 server/rules/risk-rules/risk.travel.high.preapproval_absent.json create mode 100644 server/rules/risk-rules/risk.travel.low.application_fields_missing.json create mode 100644 server/rules/risk-rules/risk.travel.low.attachment_ocr_missing.json create mode 100644 server/rules/risk-rules/risk.travel.low.local_transport_detail_missing.json create mode 100644 server/rules/risk-rules/risk.travel.low.vague_ticket_content.json create mode 100644 server/rules/risk-rules/risk.travel.medium.duplicate_ticket.json create mode 100644 server/rules/risk-rules/risk.travel.medium.multi_city_no_reason.json create mode 100644 server/rules/risk-rules/risk.travel.medium.reason_too_brief.json create mode 100644 server/rules/risk-rules/risk.travel.medium.title_mismatch.json create mode 100644 server/scripts/build_expense_control_demo_risk_rules.py create mode 100644 server/scripts/sync_finance_rule_assets.py create mode 100644 server/src/app/api/v1/endpoints/budgets.py create mode 100644 server/src/app/models/budget.py create mode 100644 server/src/app/schemas/budget.py create mode 100644 server/src/app/services/budget.py create mode 100644 server/src/app/services/budget_support.py create mode 100644 server/src/app/services/budget_types.py create mode 100644 server/src/app/services/expense_claim_budget_flow.py create mode 100644 server/src/app/services/finance_rule_catalog.py create mode 100644 server/tests/test_budget_endpoints.py create mode 100644 web/src/services/budgets.js diff --git a/document/development/expense-control-demo-data/01_finance-rules.md b/document/development/expense-control-demo-data/01_finance-rules.md new file mode 100644 index 0000000..e5ade37 --- /dev/null +++ b/document/development/expense-control-demo-data/01_finance-rules.md @@ -0,0 +1,35 @@ +# 财务规则表补齐开发记录 + +## 目标 + +财务规则中心只维护真正具备制度标准、且需要按职级/职务或明确人均标准执行的规则表。没有实际金额分档的费用类型,不在财务规则中心单独生成 Excel 表;其额度控制进入预算中心,申请前置和材料完整性进入风险规则。 + +## 本次范围调整 + +- 保留《公司差旅费报销规则》。 +- 保留《公司通信费报销规则》。 +- 删除独立《公司交通住宿费细分规则》,交通/住宿标准统一并入差旅规则。 +- 删除业务招待费、市场推广费、会务费、办公用品费、培训费、软件服务费、福利费这 7 张口径型规则表。 +- 不再为“申请、附件、合同/验收、预算归集口径”单独创建财务规则表。 +- 规则中心中如已存在上述口径型资产,统一标记为废弃规则,不再作为财务规则展示。 + +## 字段口径 + +- 金额标准:只在真实制度表中维护。 +- 职级/职务分档:没有实际标准时不造字段、不造表。 +- 预算额度:进入预算中心和预算执行规则。 +- 申请前置:进入风险规则的申请前置类。 +- 附件/合同/验收:进入风险规则的材料完整性类。 +- 费用类型归类:进入风险规则或本体费用类型映射,不通过财务规则表承载。 + +## 当前交付物 + +- `server/rules/finance-rules/公司差旅费报销规则.xlsx` +- `server/rules/finance-rules/公司通信费报销规则.xlsx` + +## 验证方式 + +- 规则中心只展示真实财务标准表。 +- 被删除的口径型规则资产不会被重新创建。 +- 历史口径型规则资产如已存在,会被同步为 `废弃规则`。 +- 风险规则不再引用已删除的口径型财务规则表 code。 diff --git a/document/development/expense-control-demo-data/02_risk-rules.md b/document/development/expense-control-demo-data/02_risk-rules.md new file mode 100644 index 0000000..bfff5b1 --- /dev/null +++ b/document/development/expense-control-demo-data/02_risk-rules.md @@ -0,0 +1,39 @@ +# 风险规则补齐开发记录 + +## 目标 + +补齐预算、申请前置、报销偏差、费用标准、材料完整性类风险规则,让后续 demo 数据可以形成“预算-申请-报销-风控”的闭环。 + +## 本次范围 + +- 第一批新增 30 条左右平台 JSON 风险规则。 +- 风险规则必须能通过现有 `risk-rules` JSON 规则库同步到规则中心。 +- 规则中保留口径引用字段;只有存在真实职级/职务金额分档的费用才引用财务规则表。 +- 没有独立财务标准表的费用,引用申请制度、材料完整性、预算执行或费用归类口径。 +- 规则中心的适用场景必须来自 `expense_types`,展示为具体费用类型,而不是统一显示通用。 +- 预算类规则先预留预算字段和口径,不在本阶段新增预算流水表。 + +## 规则分类 + +- 预算类:预算不足、80% 预警、100% 超预算、冻结预算、跨部门预算、跨季度预算。 +- 申请前置类:大额费用无申请,推广/培训/会务/软件/办公采购/招待无事前申请。 +- 申请报销偏差类:金额超申请、超 10%、科目不一致、部门不一致、周期不一致、重复报销。 +- 费用标准类:差旅、通信等真实标准;其他费用不伪造职级限额。 +- 费用归类类:固定资产伪装为办公用品等科目错配风险。 +- 材料完整性类:合同、方案、验收、签到、参与人、客户说明等材料缺失。 + +## 风险规则扩展字段 + +- `finance_rule_code`:可指向真实财务规则表,也可指向申请/预算/材料/归类制度口径。 +- `finance_rule_sheet`:真实表时记录工作表名称,制度口径时记录口径名称。 +- `business_stage` +- `expense_types`:用于意图识别后的费用类型匹配,也是规则中心适用场景的来源。 +- `budget_required` + +## 验证方式 + +- `AgentFoundationRiskRuleMixin` 能同步新增 JSON 规则。 +- 新增规则不被识别为自然语言生成草稿并跳过。 +- 规则资产的 `config_json` 能保留口径引用字段,且不指向已删除的口径型财务规则表。 +- 规则资产的 `scenario_json` 能从 `expense_types` 生成具体费用场景。 +- 至少验证预算类、申请前置类、费用标准类、材料完整性类各有规则同步成功。 diff --git a/document/development/expense-control-demo-data/TODO.md b/document/development/expense-control-demo-data/TODO.md new file mode 100644 index 0000000..0872a70 --- /dev/null +++ b/document/development/expense-control-demo-data/TODO.md @@ -0,0 +1,20 @@ +# 费用管控 Demo 数据规则补齐 TODO + +## 2026-05-26 + +- [x] 建立开发记录目录。 +- [x] 编写财务规则表开发记录。 +- [x] 编写风险规则开发记录。 +- [x] 设计费用类型财务规则定义。 +- [x] 生成第一版财务规则 Excel 文件。 +- [x] 让第一版财务规则表进入规则中心资产同步。 +- [x] 补充规则中心同步测试。 +- [x] 新增预算/申请/报销风险 JSON 规则。 +- [x] 补充风险规则同步测试。 +- [x] 补充财务规则资产同步脚本并同步演示库。 +- [x] 纠正财务规则表口径:删除独立交通住宿细分表,非制度标准费用不再维护限额表。 +- [x] 按真实职务金额分档口径二次纠正:删除 7 张没有实际金额分档的口径型财务规则表。 +- [x] 调整风险规则引用,避免指向已删除的口径型财务规则表。 +- [x] 修正规则中心适用场景:按 `expense_types` 展示具体费用类型,不再统一落为通用。 +- [x] 运行后端定向测试。 +- [x] 核对交付物和 TODO。 diff --git a/server/rules/risk-rules/risk.expense.travel.generated_20260525155459576686.json b/server/rules/risk-rules/risk.expense.travel.generated_20260525155459576686.json deleted file mode 100644 index c86d4ee..0000000 --- a/server/rules/risk-rules/risk.expense.travel.generated_20260525155459576686.json +++ /dev/null @@ -1,178 +0,0 @@ -{ - "schema_version": "2.0", - "rule_code": "risk.expense.travel.generated_20260525155459576686", - "name": "住宿日期与差旅行程不匹配", - "description": "面向业务用户的说明", - "enabled": true, - "requires_attachment": true, - "risk_dimension": "natural_language_rule", - "risk_category": "差旅费", - "ontology_signal": "natural_language_risk", - "evaluator": "template_rule", - "template_key": "field_required_v1", - "applies_to": { - "domains": [ - "expense" - ], - "expense_categories": [ - "travel" - ] - }, - "inputs": { - "fields": [ - { - "key": "attachment.hotel_city", - "label": "住宿城市", - "type": "text", - "source": "attachment" - }, - { - "key": "claim.location", - "label": "申报地点", - "type": "text", - "source": "claim" - }, - { - "key": "attachment.route_cities", - "label": "行程城市", - "type": "list", - "source": "attachment" - }, - { - "key": "claim.reason", - "label": "报销事由", - "type": "text", - "source": "claim" - } - ] - }, - "params": { - "template_key": "field_required_v1", - "field_keys": [ - "attachment.hotel_city", - "claim.location", - "attachment.route_cities", - "claim.reason" - ], - "condition_summary": "检查住宿城市、申报地点、行程城市是否满足必填和完整性要求", - "natural_language": "差旅住宿报销时,先确认已上传住宿发票或酒店水单;再读取报销事由、申报目的地、住宿城市、开票日期和行程城市。若住宿票据显示的城市不在本次差旅行程范围内,或住宿发生时间明显早于出差开始、晚于出差结束,且没有延期、改签、临时任务等说明,则标记为高风险,要求补充住宿原因、行程证明或重新提交票据。", - "required_fields": [ - "attachment.hotel_city", - "claim.location", - "attachment.route_cities", - "claim.reason" - ] - }, - "outcomes": { - "pass": { - "severity": "none", - "action": "continue" - }, - "fail": { - "severity": "high", - "action": "manual_review", - "risk_score": 77 - } - }, - "metadata": { - "owner": "admin", - "stability": "generated_draft", - "source_ref": "自然语言风险规则", - "created_at": "2026-05-25T15:54:59.576686+00:00", - "created_by": "admin", - "requires_attachment": true, - "rule_title": "住宿日期与差旅行程不匹配", - "expense_category": "travel", - "expense_category_label": "差旅费", - "natural_language": "差旅住宿报销时,先确认已上传住宿发票或酒店水单;再读取报销事由、申报目的地、住宿城市、开票日期和行程城市。若住宿票据显示的城市不在本次差旅行程范围内,或住宿发生时间明显早于出差开始、晚于出差结束,且没有延期、改签、临时任务等说明,则标记为高风险,要求补充住宿原因、行程证明或重新提交票据。", - "business_explanation": "面向业务用户的说明", - "condition_summary": "检查住宿城市、申报地点、行程城市是否满足必填和完整性要求", - "flow": { - "start": "提交业务单据", - "evidence": "读取住宿城市、申报地点、行程城市", - "decision": "检查住宿城市、申报地点、行程城市是否满足必填和完整性要求", - "pass": "继续流转", - "fail": "提示风险" - }, - "risk_level": "high", - "risk_level_label": "高风险", - "risk_level_updated_at": "2026-05-25T16:05:15.691638+00:00", - "risk_score": 77, - "risk_score_model": "risk_score_v3", - "risk_score_detail": { - "score": 77, - "level": "high", - "level_label": "高风险", - "model": "risk_score_v3", - "weights": { - "impact": 0.35, - "certainty": 0.25, - "evidence": 0.15, - "exception": 0.1, - "action": 0.1, - "sensitivity": 0.05 - }, - "components": { - "impact": 78, - "certainty": 86, - "evidence": 82, - "exception": 35, - "action": 78, - "sensitivity": 88 - }, - "calibration": { - "raw_score": 77, - "rules": [] - }, - "ai_evidence": {}, - "basis": { - "template_key": "field_required_v1", - "field_count": 4, - "condition_count": 0, - "expense_category": "travel", - "expense_category_label": "差旅费", - "requires_attachment": true - } - } - }, - "flow_diagram_svg": "\n 住宿日期与差旅行程不匹配流程说明\n 风险规则只读流程图,展示从业务单据提交到风险复核的判断路径。\n \n \n \n \n \n \n \n RULE FLOW\n \n \n \n 业务输入\n 提交业务单据\n \n \n \n \n 字段取数\n 读取字段证据\n \n \n \n 判断依据\n 检查住宿城市、申\n 报地点、行程城…\n \n \n \n \n 继续流转\n 继续流转\n \n \n \n \n 进入复核\n 提示风险\n \n \n \n BASIS\n 检查住宿城市、申报地点、行程城市是否满足必…\n \n \n \n \n \n \n \n", - "severity": "high", - "risk_score": 77, - "risk_level": "high", - "risk_level_label": "高风险", - "risk_score_detail": { - "score": 77, - "level": "high", - "level_label": "高风险", - "model": "risk_score_v3", - "weights": { - "impact": 0.35, - "certainty": 0.25, - "evidence": 0.15, - "exception": 0.1, - "action": 0.1, - "sensitivity": 0.05 - }, - "components": { - "impact": 78, - "certainty": 86, - "evidence": 82, - "exception": 35, - "action": 78, - "sensitivity": 88 - }, - "calibration": { - "raw_score": 77, - "rules": [] - }, - "ai_evidence": {}, - "basis": { - "template_key": "field_required_v1", - "field_count": 4, - "condition_count": 0, - "expense_category": "travel", - "expense_category_label": "差旅费", - "requires_attachment": true - } - } -} diff --git a/server/rules/risk-rules/risk.expense.travel.generated_20260526101144234987.json b/server/rules/risk-rules/risk.expense.travel.generated_20260526101144234987.json deleted file mode 100644 index 613b00e..0000000 --- a/server/rules/risk-rules/risk.expense.travel.generated_20260526101144234987.json +++ /dev/null @@ -1,180 +0,0 @@ -{ - "schema_version": "2.0", - "rule_code": "risk.expense.travel.generated_20260526101144234987", - "name": "差旅目的地与票据城市不一致", - "description": "当差旅费业务满足“差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;再读取申报目的地、明细发生地点、交通票行程城市和住宿发票城市。若交通票或住宿票据中的城市均无法与申报目的地、明细地点形成一致关系,且报销事由中没有说明绕行、跨城办事或临时改签原因,则标记为高风险,要求补充行程说明或退回修改。”时,系统会按高风险进行提示,并要求经办人或审核人补充核对依据。", - "enabled": true, - "requires_attachment": false, - "risk_dimension": "natural_language_rule", - "risk_category": "差旅费", - "ontology_signal": "natural_language_risk", - "evaluator": "template_rule", - "template_key": "field_compare_v1", - "semantic_type": null, - "applies_to": { - "domains": [ - "expense" - ], - "expense_categories": [ - "travel" - ] - }, - "inputs": { - "fields": [ - { - "key": "attachment.hotel_city", - "label": "住宿城市", - "type": "text", - "source": "attachment" - }, - { - "key": "claim.location", - "label": "申报地点", - "type": "text", - "source": "claim" - }, - { - "key": "attachment.route_cities", - "label": "行程城市", - "type": "list", - "source": "attachment" - }, - { - "key": "item.item_location", - "label": "明细地点", - "type": "text", - "source": "item" - } - ] - }, - "params": { - "template_key": "field_compare_v1", - "field_keys": [ - "attachment.hotel_city", - "claim.location", - "attachment.route_cities", - "item.item_location" - ], - "condition_summary": "对比住宿城市、申报地点、行程城市之间是否一致或存在交集", - "natural_language": "差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;再读取申报目的地、明细发生地点、交通票行程城市和住宿发票城市。若交通票或住宿票据中的城市均无法与申报目的地、明细地点形成一致关系,且报销事由中没有说明绕行、跨城办事或临时改签原因,则标记为高风险,要求补充行程说明或退回修改。", - "conditions": [ - { - "left": "attachment.hotel_city", - "operator": "overlap", - "right": "claim.location" - } - ] - }, - "outcomes": { - "pass": { - "severity": "none", - "action": "continue" - }, - "fail": { - "severity": "high", - "action": "manual_review", - "risk_score": 78 - } - }, - "metadata": { - "owner": "admin", - "stability": "generated_draft", - "source_ref": "自然语言风险规则", - "created_at": "2026-05-26T10:11:44.234987+08:00", - "created_by": "admin", - "requires_attachment": false, - "risk_score": 78, - "risk_level": "high", - "risk_level_label": "高风险", - "risk_score_model": "risk_score_v3", - "risk_score_detail": { - "score": 78, - "level": "high", - "level_label": "高风险", - "model": "risk_score_v3", - "weights": { - "impact": 0.35, - "certainty": 0.25, - "evidence": 0.15, - "exception": 0.1, - "action": 0.1, - "sensitivity": 0.05 - }, - "components": { - "impact": 78, - "certainty": 80, - "evidence": 82, - "exception": 66, - "action": 78, - "sensitivity": 88 - }, - "calibration": { - "raw_score": 78, - "rules": [] - }, - "ai_evidence": {}, - "basis": { - "template_key": "field_compare_v1", - "field_count": 4, - "condition_count": 1, - "expense_category": "travel", - "expense_category_label": "差旅费", - "requires_attachment": false - } - }, - "rule_title": "差旅目的地与票据城市不一致", - "expense_category": "travel", - "expense_category_label": "差旅费", - "natural_language": "差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;再读取申报目的地、明细发生地点、交通票行程城市和住宿发票城市。若交通票或住宿票据中的城市均无法与申报目的地、明细地点形成一致关系,且报销事由中没有说明绕行、跨城办事或临时改签原因,则标记为高风险,要求补充行程说明或退回修改。", - "business_explanation": "当差旅费业务满足“差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;再读取申报目的地、明细发生地点、交通票行程城市和住宿发票城市。若交通票或住宿票据中的城市均无法与申报目的地、明细地点形成一致关系,且报销事由中没有说明绕行、跨城办事或临时改签原因,则标记为高风险,要求补充行程说明或退回修改。”时,系统会按高风险进行提示,并要求经办人或审核人补充核对依据。", - "condition_summary": "对比住宿城市、申报地点、行程城市之间是否一致或存在交集", - "rule_ir": {}, - "flow": { - "start": "差旅费单据提交", - "evidence": "读取住宿城市、申报地点、行程城市", - "decision": "对比住宿城市、申报地点、行程城市之间是否一致或存在交集", - "pass": "未命中风险,继续业务流转", - "fail": "命中高风险,提示复核" - } - }, - "flow_diagram_svg": "\n 差旅目的地与票据城市不一致流程说明\n 风险规则只读流程图,展示字段事实、集合交集、日期范围、例外说明和命中路径。\n \n \n \n \n \n \n \n \n \n \n RULE FLOW\n \n \n \n 业务输入\n 差旅费单据提交\n \n \n \n 字段事实\n A=住宿城市[attachment.hotel_city]\n B=申报地点[claim.location]\n C=行程城市[attachment.route_cities]\n D=明细地点[item.item_location]\n \n \n \n 判断条件\n C1: 字段集合 ∩ 字段集合 ≠ ∅\n \n \n \n 命中逻辑\n 对比住宿城市、申\n 报地点、行程城…\n \n \n \n \n 继续流转\n 未命中风险,继续业…\n \n \n \n \n 进入复核\n 命中高风险,提示复核\n \n \n \n \n \n \n \n \n \n", - "severity": "high", - "risk_score": 78, - "risk_level": "high", - "risk_level_label": "高风险", - "risk_score_detail": { - "score": 78, - "level": "high", - "level_label": "高风险", - "model": "risk_score_v3", - "weights": { - "impact": 0.35, - "certainty": 0.25, - "evidence": 0.15, - "exception": 0.1, - "action": 0.1, - "sensitivity": 0.05 - }, - "components": { - "impact": 78, - "certainty": 80, - "evidence": 82, - "exception": 66, - "action": 78, - "sensitivity": 88 - }, - "calibration": { - "raw_score": 78, - "rules": [] - }, - "ai_evidence": {}, - "basis": { - "template_key": "field_compare_v1", - "field_count": 4, - "condition_count": 1, - "expense_category": "travel", - "expense_category_label": "差旅费", - "requires_attachment": false - } - } -} diff --git a/server/rules/risk-rules/risk.expense.travel.generated_20260526101531166364.json b/server/rules/risk-rules/risk.expense.travel.generated_20260526101531166364.json deleted file mode 100644 index 0e1ef18..0000000 --- a/server/rules/risk-rules/risk.expense.travel.generated_20260526101531166364.json +++ /dev/null @@ -1,179 +0,0 @@ -{ - "schema_version": "2.0", - "rule_code": "risk.expense.travel.generated_20260526101531166364", - "name": "住宿日期与差旅行程不匹配", - "description": "当差旅费业务满足“差旅住宿报销时,先确认已上传住宿发票或酒店水单;再读取报销事由、申报目的地、住宿城市、开票日期和行程城市。若住宿票据显示的城市不在本次差旅行程范围内,或住宿发生时间明显早于出差开始、晚于出差结束,且没有延期、改签、临时任务等说明,则标记为高风险,要求补充住宿原因、行程证明或重新提交票据。”时,系统会按高风险进行提示,并要求经办人或审核人补充核对依据。", - "enabled": true, - "requires_attachment": false, - "risk_dimension": "natural_language_rule", - "risk_category": "差旅费", - "ontology_signal": "natural_language_risk", - "evaluator": "template_rule", - "template_key": "field_required_v1", - "semantic_type": null, - "applies_to": { - "domains": [ - "expense" - ], - "expense_categories": [ - "travel" - ] - }, - "inputs": { - "fields": [ - { - "key": "attachment.hotel_city", - "label": "住宿城市", - "type": "text", - "source": "attachment" - }, - { - "key": "claim.location", - "label": "申报地点", - "type": "text", - "source": "claim" - }, - { - "key": "attachment.route_cities", - "label": "行程城市", - "type": "list", - "source": "attachment" - }, - { - "key": "claim.reason", - "label": "报销事由", - "type": "text", - "source": "claim" - } - ] - }, - "params": { - "template_key": "field_required_v1", - "field_keys": [ - "attachment.hotel_city", - "claim.location", - "attachment.route_cities", - "claim.reason" - ], - "condition_summary": "检查住宿城市、申报地点、行程城市是否满足必填和完整性要求", - "natural_language": "差旅住宿报销时,先确认已上传住宿发票或酒店水单;再读取报销事由、申报目的地、住宿城市、开票日期和行程城市。若住宿票据显示的城市不在本次差旅行程范围内,或住宿发生时间明显早于出差开始、晚于出差结束,且没有延期、改签、临时任务等说明,则标记为高风险,要求补充住宿原因、行程证明或重新提交票据。", - "required_fields": [ - "attachment.hotel_city", - "claim.location", - "attachment.route_cities", - "claim.reason" - ] - }, - "outcomes": { - "pass": { - "severity": "none", - "action": "continue" - }, - "fail": { - "severity": "high", - "action": "manual_review", - "risk_score": 77 - } - }, - "metadata": { - "owner": "admin", - "stability": "generated_draft", - "source_ref": "自然语言风险规则", - "created_at": "2026-05-26T10:15:31.166364+08:00", - "created_by": "admin", - "requires_attachment": false, - "risk_score": 77, - "risk_level": "high", - "risk_level_label": "高风险", - "risk_score_model": "risk_score_v3", - "risk_score_detail": { - "score": 77, - "level": "high", - "level_label": "高风险", - "model": "risk_score_v3", - "weights": { - "impact": 0.35, - "certainty": 0.25, - "evidence": 0.15, - "exception": 0.1, - "action": 0.1, - "sensitivity": 0.05 - }, - "components": { - "impact": 78, - "certainty": 86, - "evidence": 82, - "exception": 35, - "action": 78, - "sensitivity": 88 - }, - "calibration": { - "raw_score": 77, - "rules": [] - }, - "ai_evidence": {}, - "basis": { - "template_key": "field_required_v1", - "field_count": 4, - "condition_count": 0, - "expense_category": "travel", - "expense_category_label": "差旅费", - "requires_attachment": false - } - }, - "rule_title": "住宿日期与差旅行程不匹配", - "expense_category": "travel", - "expense_category_label": "差旅费", - "natural_language": "差旅住宿报销时,先确认已上传住宿发票或酒店水单;再读取报销事由、申报目的地、住宿城市、开票日期和行程城市。若住宿票据显示的城市不在本次差旅行程范围内,或住宿发生时间明显早于出差开始、晚于出差结束,且没有延期、改签、临时任务等说明,则标记为高风险,要求补充住宿原因、行程证明或重新提交票据。", - "business_explanation": "当差旅费业务满足“差旅住宿报销时,先确认已上传住宿发票或酒店水单;再读取报销事由、申报目的地、住宿城市、开票日期和行程城市。若住宿票据显示的城市不在本次差旅行程范围内,或住宿发生时间明显早于出差开始、晚于出差结束,且没有延期、改签、临时任务等说明,则标记为高风险,要求补充住宿原因、行程证明或重新提交票据。”时,系统会按高风险进行提示,并要求经办人或审核人补充核对依据。", - "condition_summary": "检查住宿城市、申报地点、行程城市是否满足必填和完整性要求", - "rule_ir": {}, - "flow": { - "start": "差旅费单据提交", - "evidence": "读取住宿城市、申报地点、行程城市", - "decision": "检查住宿城市、申报地点、行程城市是否满足必填和完整性要求", - "pass": "未命中风险,继续业务流转", - "fail": "命中高风险,提示复核" - } - }, - "flow_diagram_svg": "\n 住宿日期与差旅行程不匹配流程说明\n 风险规则只读流程图,展示字段事实、集合交集、日期范围、例外说明和命中路径。\n \n \n \n \n \n \n \n \n \n \n RULE FLOW\n \n \n \n 业务输入\n 差旅费单据提交\n \n \n \n 字段事实\n A=住宿城市[attachment.hotel_city]\n B=申报地点[claim.location]\n C=行程城市[attachment.route_cities]\n D=报销事由[claim.reason]\n \n \n \n 判断条件\n 检查住宿城市、申报地点、行程城市是否满足必填和完整性要求\n \n \n \n 命中逻辑\n 检查住宿城市、申\n 报地点、行程城…\n \n \n \n \n 继续流转\n 未命中风险,继续业…\n \n \n \n \n 进入复核\n 命中高风险,提示复核\n \n \n \n \n \n \n \n \n \n", - "severity": "high", - "risk_score": 77, - "risk_level": "high", - "risk_level_label": "高风险", - "risk_score_detail": { - "score": 77, - "level": "high", - "level_label": "高风险", - "model": "risk_score_v3", - "weights": { - "impact": 0.35, - "certainty": 0.25, - "evidence": 0.15, - "exception": 0.1, - "action": 0.1, - "sensitivity": 0.05 - }, - "components": { - "impact": 78, - "certainty": 86, - "evidence": 82, - "exception": 35, - "action": 78, - "sensitivity": 88 - }, - "calibration": { - "raw_score": 77, - "rules": [] - }, - "ai_evidence": {}, - "basis": { - "template_key": "field_required_v1", - "field_count": 4, - "condition_count": 0, - "expense_category": "travel", - "expense_category_label": "差旅费", - "requires_attachment": false - } - } -} diff --git a/server/rules/risk-rules/risk.expense.travel.generated_20260526101826456519.json b/server/rules/risk-rules/risk.expense.travel.generated_20260526101826456519.json deleted file mode 100644 index 86f2947..0000000 --- a/server/rules/risk-rules/risk.expense.travel.generated_20260526101826456519.json +++ /dev/null @@ -1,179 +0,0 @@ -{ - "schema_version": "2.0", - "rule_code": "risk.expense.travel.generated_20260526101826456519", - "name": "差旅事由过于笼统", - "description": "当差旅费业务满足“差旅费报销时,先读取报销事由、明细事由、申报目的地、费用类型和明细地点;再判断是否能说明出差目的、客户或项目背景、发生城市和费用构成。若报销事由只填写“出差”“差旅”“项目支持”“工作安排”等笼统描述,且明细事由也无法补充业务背景,则标记为高风险,提示经办人补充出差目的、项目名称或业务对象。”时,系统会按高风险进行提示,并要求经办人或审核人补充核对依据。", - "enabled": true, - "requires_attachment": false, - "risk_dimension": "natural_language_rule", - "risk_category": "差旅费", - "ontology_signal": "natural_language_risk", - "evaluator": "template_rule", - "template_key": "field_required_v1", - "semantic_type": null, - "applies_to": { - "domains": [ - "expense" - ], - "expense_categories": [ - "travel" - ] - }, - "inputs": { - "fields": [ - { - "key": "claim.location", - "label": "申报地点", - "type": "text", - "source": "claim" - }, - { - "key": "item.item_type", - "label": "费用类型", - "type": "enum", - "source": "item" - }, - { - "key": "claim.reason", - "label": "报销事由", - "type": "text", - "source": "claim" - }, - { - "key": "item.item_reason", - "label": "明细事由", - "type": "text", - "source": "item" - } - ] - }, - "params": { - "template_key": "field_required_v1", - "field_keys": [ - "claim.location", - "item.item_type", - "claim.reason", - "item.item_reason" - ], - "condition_summary": "检查申报地点、费用类型、报销事由是否满足必填和完整性要求", - "natural_language": "差旅费报销时,先读取报销事由、明细事由、申报目的地、费用类型和明细地点;再判断是否能说明出差目的、客户或项目背景、发生城市和费用构成。若报销事由只填写“出差”“差旅”“项目支持”“工作安排”等笼统描述,且明细事由也无法补充业务背景,则标记为中风险,提示经办人补充出差目的、项目名称或业务对象。", - "required_fields": [ - "claim.location", - "item.item_type", - "claim.reason", - "item.item_reason" - ] - }, - "outcomes": { - "pass": { - "severity": "none", - "action": "continue" - }, - "fail": { - "severity": "high", - "action": "manual_review", - "risk_score": 72 - } - }, - "metadata": { - "owner": "admin", - "stability": "generated_draft", - "source_ref": "自然语言风险规则", - "created_at": "2026-05-26T10:18:26.456519+08:00", - "created_by": "admin", - "requires_attachment": false, - "risk_score": 72, - "risk_level": "high", - "risk_level_label": "高风险", - "risk_score_model": "risk_score_v3", - "risk_score_detail": { - "score": 72, - "level": "high", - "level_label": "高风险", - "model": "risk_score_v3", - "weights": { - "impact": 0.35, - "certainty": 0.25, - "evidence": 0.15, - "exception": 0.1, - "action": 0.1, - "sensitivity": 0.05 - }, - "components": { - "impact": 78, - "certainty": 86, - "evidence": 62, - "exception": 35, - "action": 65, - "sensitivity": 70 - }, - "calibration": { - "raw_score": 72, - "rules": [] - }, - "ai_evidence": {}, - "basis": { - "template_key": "field_required_v1", - "field_count": 4, - "condition_count": 0, - "expense_category": "travel", - "expense_category_label": "差旅费", - "requires_attachment": false - } - }, - "rule_title": "差旅事由过于笼统", - "expense_category": "travel", - "expense_category_label": "差旅费", - "natural_language": "差旅费报销时,先读取报销事由、明细事由、申报目的地、费用类型和明细地点;再判断是否能说明出差目的、客户或项目背景、发生城市和费用构成。若报销事由只填写“出差”“差旅”“项目支持”“工作安排”等笼统描述,且明细事由也无法补充业务背景,则标记为中风险,提示经办人补充出差目的、项目名称或业务对象。", - "business_explanation": "当差旅费业务满足“差旅费报销时,先读取报销事由、明细事由、申报目的地、费用类型和明细地点;再判断是否能说明出差目的、客户或项目背景、发生城市和费用构成。若报销事由只填写“出差”“差旅”“项目支持”“工作安排”等笼统描述,且明细事由也无法补充业务背景,则标记为高风险,提示经办人补充出差目的、项目名称或业务对象。”时,系统会按高风险进行提示,并要求经办人或审核人补充核对依据。", - "condition_summary": "检查申报地点、费用类型、报销事由是否满足必填和完整性要求", - "rule_ir": {}, - "flow": { - "start": "差旅费单据提交", - "evidence": "读取申报地点、费用类型、报销事由", - "decision": "检查申报地点、费用类型、报销事由是否满足必填和完整性要求", - "pass": "未命中风险,继续业务流转", - "fail": "命中高风险,提示复核" - } - }, - "flow_diagram_svg": "\n 差旅事由过于笼统流程说明\n 风险规则只读流程图,展示字段事实、集合交集、日期范围、例外说明和命中路径。\n \n \n \n \n \n \n \n \n \n \n RULE FLOW\n \n \n \n 业务输入\n 差旅费单据提交\n \n \n \n 字段事实\n A=申报地点[claim.location]\n B=费用类型[item.item_type]\n C=报销事由[claim.reason]\n D=明细事由[item.item_reason]\n \n \n \n 判断条件\n 检查申报地点、费用类型、报销事由是否满足必填和完整性要求\n \n \n \n 命中逻辑\n 检查申报地点、费\n 用类型、报销事…\n \n \n \n \n 继续流转\n 未命中风险,继续业…\n \n \n \n \n 进入复核\n 命中高风险,提示复核\n \n \n \n \n \n \n \n \n \n", - "severity": "high", - "risk_score": 72, - "risk_level": "high", - "risk_level_label": "高风险", - "risk_score_detail": { - "score": 72, - "level": "high", - "level_label": "高风险", - "model": "risk_score_v3", - "weights": { - "impact": 0.35, - "certainty": 0.25, - "evidence": 0.15, - "exception": 0.1, - "action": 0.1, - "sensitivity": 0.05 - }, - "components": { - "impact": 78, - "certainty": 86, - "evidence": 62, - "exception": 35, - "action": 65, - "sensitivity": 70 - }, - "calibration": { - "raw_score": 72, - "rules": [] - }, - "ai_evidence": {}, - "basis": { - "template_key": "field_required_v1", - "field_count": 4, - "condition_count": 0, - "expense_category": "travel", - "expense_category_label": "差旅费", - "requires_attachment": false - } - } -} diff --git a/server/rules/risk-rules/risk.expense.travel.generated_20260526101912249313.json b/server/rules/risk-rules/risk.expense.travel.generated_20260526101912249313.json deleted file mode 100644 index e8b76a4..0000000 --- a/server/rules/risk-rules/risk.expense.travel.generated_20260526101912249313.json +++ /dev/null @@ -1,213 +0,0 @@ -{ - "schema_version": "2.0", - "rule_code": "risk.expense.travel.generated_20260526101912249313", - "name": "差旅基础字段缺失提醒", - "description": "检查差旅报销的报销事由、申报目的地、明细发生地点和明细事由是否填写完整,确保能说明出差目的、发生城市和费用内容。缺少这些字段但无其他风险迹象时标记为中风险,提示补齐。", - "enabled": true, - "requires_attachment": false, - "risk_dimension": "natural_language_rule", - "risk_category": "差旅费", - "ontology_signal": "natural_language_risk", - "evaluator": "template_rule", - "template_key": "field_required_v1", - "semantic_type": "travel_info_completeness", - "applies_to": { - "domains": [ - "expense" - ], - "expense_categories": [ - "travel" - ] - }, - "inputs": { - "fields": [ - { - "key": "claim.location", - "label": "申报地点", - "type": "text", - "source": "claim" - }, - { - "key": "claim.reason", - "label": "报销事由", - "type": "text", - "source": "claim" - }, - { - "key": "item.item_location", - "label": "明细地点", - "type": "text", - "source": "item" - }, - { - "key": "item.item_reason", - "label": "明细事由", - "type": "text", - "source": "item" - } - ] - }, - "params": { - "template_key": "field_required_v1", - "field_keys": [ - "claim.location", - "claim.reason", - "item.item_location", - "item.item_reason" - ], - "condition_summary": "检查申报地点、报销事由、明细地点是否满足必填和完整性要求", - "natural_language": "差旅费报销提交时,先读取报销事由、申报目的地、费用类型、明细发生地点和明细事由;再判断这些字段是否能完整说明出差目的、发生城市和费用内容。若缺少申报目的地、明细地点或明细事由,但暂未发现票据城市冲突、金额异常或重复报销迹象,则标记为低风险,提示经办人补齐基础差旅信息后继续提交。", - "semantic_type": "travel_info_completeness", - "required_fields": [ - "claim.location", - "claim.reason", - "item.item_location", - "item.item_reason" - ] - }, - "outcomes": { - "pass": { - "severity": "none", - "action": "continue" - }, - "fail": { - "severity": "low", - "action": "manual_review", - "risk_score": 30 - } - }, - "metadata": { - "owner": "admin", - "stability": "generated_draft", - "source_ref": "自然语言风险规则", - "created_at": "2026-05-26T10:19:12.249313+08:00", - "created_by": "admin", - "requires_attachment": false, - "risk_score": 30, - "risk_level": "low", - "risk_level_label": "低风险", - "risk_score_model": "risk_score_v3", - "risk_score_detail": { - "score": 30, - "level": "low", - "level_label": "低风险", - "model": "risk_score_v3", - "weights": { - "impact": 0.35, - "certainty": 0.25, - "evidence": 0.15, - "exception": 0.1, - "action": 0.1, - "sensitivity": 0.05 - }, - "components": { - "impact": 48, - "certainty": 86, - "evidence": 62, - "exception": 35, - "action": 35, - "sensitivity": 70 - }, - "calibration": { - "raw_score": 58, - "rules": [ - { - "name": "explicit_low_control_cap", - "score_before": 58, - "score_after": 30, - "reason": "规则语义明确为低风险,且控制动作仅为提醒、提示、补齐或补充说明。" - } - ] - }, - "ai_evidence": {}, - "basis": { - "template_key": "field_required_v1", - "field_count": 4, - "condition_count": 0, - "expense_category": "travel", - "expense_category_label": "差旅费", - "requires_attachment": false - } - }, - "rule_title": "差旅基础字段缺失提醒", - "expense_category": "travel", - "expense_category_label": "差旅费", - "natural_language": "差旅费报销提交时,先读取报销事由、申报目的地、费用类型、明细发生地点和明细事由;再判断这些字段是否能完整说明出差目的、发生城市和费用内容。若缺少申报目的地、明细地点或明细事由,但暂未发现票据城市冲突、金额异常或重复报销迹象,则标记为低风险,提示经办人补齐基础差旅信息后继续提交。", - "business_explanation": "检查差旅报销的报销事由、申报目的地、明细发生地点和明细事由是否填写完整,确保能说明出差目的、发生城市和费用内容。缺少这些字段但无其他风险迹象时标记为中风险,提示补齐。", - "condition_summary": "检查申报地点、报销事由、明细地点是否满足必填和完整性要求", - "rule_ir": { - "facts": [ - "A = claim.reason (报销事由)", - "B = claim.location (申报目的地)", - "C = item.item_location (明细发生地点)", - "D = item.item_reason (明细事由)" - ], - "conditions": [ - { - "id": "missing_travel_info", - "operator": "not_exists_any", - "fields": [ - "B", - "C", - "D" - ] - } - ], - "hit_logic": "missing_travel_info AND (A exists) → 低风险,提示补齐申报目的地、明细地点或明细事由" - }, - "flow": { - "start": "提交差旅报销单", - "evidence": "读取申报地点、报销事由、明细地点", - "decision": "检查申报地点、报销事由、明细地点是否满足必填和完整性要求", - "pass": "所有基础差旅信息完整,无风险提示", - "fail": "缺少申报目的地/明细发生地点/明细事三者之一,标记为中风险,提示经办人补齐后继续提交" - } - }, - "flow_diagram_svg": "\n 差旅基础字段缺失提醒流程说明\n 风险规则只读流程图,展示字段事实、集合交集、日期范围、例外说明和命中路径。\n \n \n \n \n \n \n \n \n \n \n RULE FLOW\n \n \n \n 业务输入\n 提交差旅报销单\n \n \n \n 字段事实\n A=申报地点[claim.location]\n B=报销事由[claim.reason]\n C=明细地点[item.item_location]\n D=明细事由[item.item_reason]\n \n \n \n 判断条件\n 检查申报地点、报销事由、明细地点是否满足必填和完整性要求\n \n \n \n 命中逻辑\n 检查申报地点、报\n 销事由、明细地…\n \n \n \n \n 继续流转\n 所有基础差旅信息完…\n \n \n \n \n 进入复核\n 缺少申报目的地/明…\n \n \n \n \n \n \n \n \n \n", - "severity": "low", - "risk_score": 30, - "risk_level": "low", - "risk_level_label": "低风险", - "risk_score_detail": { - "score": 30, - "level": "low", - "level_label": "低风险", - "model": "risk_score_v3", - "weights": { - "impact": 0.35, - "certainty": 0.25, - "evidence": 0.15, - "exception": 0.1, - "action": 0.1, - "sensitivity": 0.05 - }, - "components": { - "impact": 48, - "certainty": 86, - "evidence": 62, - "exception": 35, - "action": 35, - "sensitivity": 70 - }, - "calibration": { - "raw_score": 58, - "rules": [ - { - "name": "explicit_low_control_cap", - "score_before": 58, - "score_after": 30, - "reason": "规则语义明确为低风险,且控制动作仅为提醒、提示、补齐或补充说明。" - } - ] - }, - "ai_evidence": {}, - "basis": { - "template_key": "field_required_v1", - "field_count": 4, - "condition_count": 0, - "expense_category": "travel", - "expense_category_label": "差旅费", - "requires_attachment": false - } - } -} diff --git a/server/rules/risk-rules/risk.expense.travel.generated_20260526101948535257.json b/server/rules/risk-rules/risk.expense.travel.generated_20260526101948535257.json deleted file mode 100644 index e176945..0000000 --- a/server/rules/risk-rules/risk.expense.travel.generated_20260526101948535257.json +++ /dev/null @@ -1,179 +0,0 @@ -{ - "schema_version": "2.0", - "rule_code": "risk.expense.travel.generated_20260526101948535257", - "name": "差旅附件要素不完整提示", - "description": "当差旅费业务满足“差旅报销时,先检查是否上传了交通票据、住宿票据或其他差旅附件;再读取发票号码、购买方名称、商品服务名称、票据全文、报销人和部门。若票据已上传但发票号码、购买方名称或商品服务名称缺失,且报销事由、人员和部门信息能够说明费用归属,则标记为高风险,提醒补齐票据要素或重新上传清晰附件。”时,系统会按高风险进行提示,并要求经办人或审核人补充核对依据。", - "enabled": false, - "requires_attachment": true, - "risk_dimension": "natural_language_rule", - "risk_category": "差旅费", - "ontology_signal": "natural_language_risk", - "evaluator": "template_rule", - "template_key": "field_required_v1", - "semantic_type": null, - "applies_to": { - "domains": [ - "expense" - ], - "expense_categories": [ - "travel" - ] - }, - "inputs": { - "fields": [ - { - "key": "attachment.hotel_city", - "label": "住宿城市", - "type": "text", - "source": "attachment" - }, - { - "key": "attachment.route_cities", - "label": "行程城市", - "type": "list", - "source": "attachment" - }, - { - "key": "attachment.invoice_no", - "label": "发票号码", - "type": "text", - "source": "attachment" - }, - { - "key": "attachment.goods_name", - "label": "商品服务名称", - "type": "text", - "source": "attachment" - } - ] - }, - "params": { - "template_key": "field_required_v1", - "field_keys": [ - "attachment.hotel_city", - "attachment.route_cities", - "attachment.invoice_no", - "attachment.goods_name" - ], - "condition_summary": "检查住宿城市、行程城市、发票号码是否满足必填和完整性要求", - "natural_language": "差旅报销时,先检查是否上传了交通票据、住宿票据或其他差旅附件;再读取发票号码、购买方名称、商品服务名称、票据全文、报销人和部门。若票据已上传但发票号码、购买方名称或商品服务名称缺失,且报销事由、人员和部门信息能够说明费用归属,则标记为低风险,提醒补齐票据要素或重新上传清晰附件。", - "required_fields": [ - "attachment.hotel_city", - "attachment.route_cities", - "attachment.invoice_no", - "attachment.goods_name" - ] - }, - "outcomes": { - "pass": { - "severity": "none", - "action": "continue" - }, - "fail": { - "severity": "high", - "action": "manual_review", - "risk_score": 76 - } - }, - "metadata": { - "owner": "admin", - "stability": "generated_draft", - "source_ref": "自然语言风险规则", - "created_at": "2026-05-26T10:19:48.535257+08:00", - "created_by": "admin", - "requires_attachment": true, - "risk_score": 76, - "risk_level": "high", - "risk_level_label": "高风险", - "risk_score_model": "risk_score_v3", - "risk_score_detail": { - "score": 76, - "level": "high", - "level_label": "高风险", - "model": "risk_score_v3", - "weights": { - "impact": 0.35, - "certainty": 0.25, - "evidence": 0.15, - "exception": 0.1, - "action": 0.1, - "sensitivity": 0.05 - }, - "components": { - "impact": 78, - "certainty": 86, - "evidence": 82, - "exception": 35, - "action": 65, - "sensitivity": 88 - }, - "calibration": { - "raw_score": 76, - "rules": [] - }, - "ai_evidence": {}, - "basis": { - "template_key": "field_required_v1", - "field_count": 4, - "condition_count": 0, - "expense_category": "travel", - "expense_category_label": "差旅费", - "requires_attachment": true - } - }, - "rule_title": "差旅附件要素不完整提示", - "expense_category": "travel", - "expense_category_label": "差旅费", - "natural_language": "差旅报销时,先检查是否上传了交通票据、住宿票据或其他差旅附件;再读取发票号码、购买方名称、商品服务名称、票据全文、报销人和部门。若票据已上传但发票号码、购买方名称或商品服务名称缺失,且报销事由、人员和部门信息能够说明费用归属,则标记为低风险,提醒补齐票据要素或重新上传清晰附件。", - "business_explanation": "当差旅费业务满足“差旅报销时,先检查是否上传了交通票据、住宿票据或其他差旅附件;再读取发票号码、购买方名称、商品服务名称、票据全文、报销人和部门。若票据已上传但发票号码、购买方名称或商品服务名称缺失,且报销事由、人员和部门信息能够说明费用归属,则标记为高风险,提醒补齐票据要素或重新上传清晰附件。”时,系统会按高风险进行提示,并要求经办人或审核人补充核对依据。", - "condition_summary": "检查住宿城市、行程城市、发票号码是否满足必填和完整性要求", - "rule_ir": {}, - "flow": { - "start": "差旅费单据提交", - "evidence": "读取住宿城市、行程城市、发票号码", - "decision": "检查住宿城市、行程城市、发票号码是否满足必填和完整性要求", - "pass": "未命中风险,继续业务流转", - "fail": "命中高风险,提示复核" - } - }, - "flow_diagram_svg": "\n 差旅附件要素不完整提示流程说明\n 风险规则只读流程图,展示字段事实、集合交集、日期范围、例外说明和命中路径。\n \n \n \n \n \n \n \n \n \n \n RULE FLOW\n \n \n \n 业务输入\n 差旅费单据提交\n \n \n \n 字段事实\n A=住宿城市[attachment.hotel_city]\n B=行程城市[attachment.route_cities]\n C=发票号码[attachment.invoice_no]\n D=商品服务名称[attachment.goods_name]\n \n \n \n 判断条件\n 检查住宿城市、行程城市、发票号码是否满足必填和完整性要求\n \n \n \n 命中逻辑\n 检查住宿城市、行\n 程城市、发票号…\n \n \n \n \n 继续流转\n 未命中风险,继续业…\n \n \n \n \n 进入复核\n 命中高风险,提示复核\n \n \n \n \n \n \n \n \n \n", - "severity": "high", - "risk_score": 76, - "risk_level": "high", - "risk_level_label": "高风险", - "risk_score_detail": { - "score": 76, - "level": "high", - "level_label": "高风险", - "model": "risk_score_v3", - "weights": { - "impact": 0.35, - "certainty": 0.25, - "evidence": 0.15, - "exception": 0.1, - "action": 0.1, - "sensitivity": 0.05 - }, - "components": { - "impact": 78, - "certainty": 86, - "evidence": 82, - "exception": 35, - "action": 65, - "sensitivity": 88 - }, - "calibration": { - "raw_score": 76, - "rules": [] - }, - "ai_evidence": {}, - "basis": { - "template_key": "field_required_v1", - "field_count": 4, - "condition_count": 0, - "expense_category": "travel", - "expense_category_label": "差旅费", - "requires_attachment": true - } - } -} diff --git a/server/rules/risk-rules/risk.travel.high.city_mismatch.json b/server/rules/risk-rules/risk.travel.high.city_mismatch.json new file mode 100644 index 0000000..c00f055 --- /dev/null +++ b/server/rules/risk-rules/risk.travel.high.city_mismatch.json @@ -0,0 +1,173 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.travel.high.city_mismatch", + "name": "差旅目的地与票据城市不一致高风险", + "description": "交通票、住宿票识别出的城市与申报目的地或明细地点不一致,且事由未说明绕行、多地拜访或改签原因。", + "enabled": true, + "requires_attachment": true, + "risk_dimension": "travel_reimbursement_control", + "risk_category": "差旅费-行程一致性", + "ontology_signal": "travel_city_mismatch", + "evaluator": "template_rule", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "applies_to": { + "domains": [ + "expense", + "travel" + ], + "expense_types": [ + "travel" + ], + "business_stages": [ + "expense_application", + "reimbursement" + ] + }, + "inputs": { + "fields": [ + { + "key": "claim.location", + "label": "申报地点", + "type": "text", + "source": "claim" + }, + { + "key": "item.item_location", + "label": "明细发生地点", + "type": "text", + "source": "item" + }, + { + "key": "employee.location", + "label": "员工常驻地", + "type": "text", + "source": "employee" + }, + { + "key": "attachment.route_cities", + "label": "交通票行程城市", + "type": "list", + "source": "attachment" + }, + { + "key": "attachment.hotel_city", + "label": "住宿城市", + "type": "text", + "source": "attachment" + }, + { + "key": "claim.reason", + "label": "报销/申请事由", + "type": "text", + "source": "claim" + }, + { + "key": "item.item_reason", + "label": "明细说明", + "type": "text", + "source": "item" + } + ] + }, + "params": { + "template_key": "field_compare_v1", + "semantic_type": "travel_route_city_consistency", + "field_keys": [ + "claim.location", + "item.item_location", + "employee.location", + "attachment.route_cities", + "attachment.hotel_city", + "claim.reason", + "item.item_reason" + ], + "reference_city_fields": [ + "claim.location", + "item.item_location" + ], + "attachment_city_fields": [ + "attachment.route_cities", + "attachment.hotel_city" + ], + "home_city_fields": [ + "employee.location" + ], + "exception_fields": [ + "claim.reason", + "item.item_reason" + ], + "exception_keywords": [ + "中转", + "改签", + "绕行", + "多地", + "临时变更", + "客户拜访", + "项目现场" + ], + "condition_summary": "票据城市未覆盖申报目的地,或路线出现常驻地/目的地以外城市且无合理说明。", + "message_template": "差旅票据城市与申报目的地不一致,请补充多地出差、改签或异地住宿说明。" + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "high", + "action": "block", + "risk_score": 90 + } + }, + "metadata": { + "owner": "admin", + "stability": "admin_configured", + "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "created_at": "2026-05-26T07:06:27.746703+00:00", + "created_by": "admin", + "risk_score": 90, + "risk_level": "high", + "rule_title": "差旅目的地与票据城市不一致高风险", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "implementation_note": "使用当前规则中心 JSON 风险规则执行器可识别的字段与模板配置。", + "risk_level_label": "高风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 90, + "level": "high", + "level_label": "高风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } + }, + "severity": "high", + "risk_score": 90, + "risk_level": "high", + "template_key": "field_compare_v1", + "risk_level_label": "高风险", + "risk_score_detail": { + "score": 90, + "level": "high", + "level_label": "高风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } +} diff --git a/server/rules/risk-rules/risk.travel.high.date_outside_trip.json b/server/rules/risk-rules/risk.travel.high.date_outside_trip.json new file mode 100644 index 0000000..65ffdba --- /dev/null +++ b/server/rules/risk-rules/risk.travel.high.date_outside_trip.json @@ -0,0 +1,161 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.travel.high.date_outside_trip", + "name": "票据日期超出差旅行程高风险", + "description": "票据日期、住宿日期或明细发生日期超出申报出差起止日期,允许 1 天交通衔接容差。", + "enabled": true, + "requires_attachment": true, + "risk_dimension": "travel_reimbursement_control", + "risk_category": "差旅费-日期一致性", + "ontology_signal": "travel_date_outside_trip_window", + "evaluator": "template_rule", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "applies_to": { + "domains": [ + "expense", + "travel" + ], + "expense_types": [ + "travel" + ], + "business_stages": [ + "expense_application", + "reimbursement" + ] + }, + "inputs": { + "fields": [ + { + "key": "claim.trip_start_date", + "label": "出差开始日期", + "type": "date", + "source": "claim" + }, + { + "key": "claim.trip_end_date", + "label": "出差结束日期", + "type": "date", + "source": "claim" + }, + { + "key": "item.item_date", + "label": "明细发生日期", + "type": "date", + "source": "item" + }, + { + "key": "attachment.issue_date", + "label": "票据日期", + "type": "date", + "source": "attachment" + }, + { + "key": "attachment.stay_start_date", + "label": "住宿开始日期", + "type": "date", + "source": "attachment" + }, + { + "key": "attachment.stay_end_date", + "label": "住宿结束日期", + "type": "date", + "source": "attachment" + } + ] + }, + "params": { + "template_key": "composite_rule_v1", + "field_keys": [ + "claim.trip_start_date", + "claim.trip_end_date", + "item.item_date", + "attachment.issue_date", + "attachment.stay_start_date", + "attachment.stay_end_date" + ], + "conditions": [ + { + "id": "ticket_date_outside_trip", + "operator": "date_outside_range", + "date_fields": [ + "item.item_date", + "attachment.issue_date", + "attachment.stay_start_date", + "attachment.stay_end_date" + ], + "range_start_fields": [ + "claim.trip_start_date" + ], + "range_end_fields": [ + "claim.trip_end_date" + ], + "tolerance_days": 1 + } + ], + "hit_logic": "ticket_date_outside_trip", + "condition_summary": "任一票据/明细日期早于出差开始日前 1 天或晚于结束日后 1 天。", + "message_template": "票据日期超出申报差旅行程,请补充改签/延期说明或更正行程日期。" + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "high", + "action": "block", + "risk_score": 88 + } + }, + "metadata": { + "owner": "admin", + "stability": "admin_configured", + "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "created_at": "2026-05-26T07:06:27.746703+00:00", + "created_by": "admin", + "risk_score": 88, + "risk_level": "high", + "rule_title": "票据日期超出差旅行程高风险", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "implementation_note": "使用当前规则中心 JSON 风险规则执行器可识别的字段与模板配置。", + "risk_level_label": "高风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 88, + "level": "high", + "level_label": "高风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } + }, + "severity": "high", + "risk_score": 88, + "risk_level": "high", + "template_key": "composite_rule_v1", + "risk_level_label": "高风险", + "risk_score_detail": { + "score": 88, + "level": "high", + "level_label": "高风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } +} diff --git a/server/rules/risk-rules/risk.travel.high.personal_purpose.json b/server/rules/risk-rules/risk.travel.high.personal_purpose.json new file mode 100644 index 0000000..b13c80b --- /dev/null +++ b/server/rules/risk-rules/risk.travel.high.personal_purpose.json @@ -0,0 +1,135 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.travel.high.personal_purpose", + "name": "个人旅游或非公务目的高风险", + "description": "差旅申请或报销文本中出现旅游、探亲、休假、私人行程等非公务目的表达。", + "enabled": true, + "requires_attachment": false, + "risk_dimension": "travel_reimbursement_control", + "risk_category": "差旅费-真实性", + "ontology_signal": "travel_personal_purpose", + "evaluator": "template_rule", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "applies_to": { + "domains": [ + "expense", + "travel" + ], + "expense_types": [ + "travel" + ], + "business_stages": [ + "expense_application", + "reimbursement" + ] + }, + "inputs": { + "fields": [ + { + "key": "claim.reason", + "label": "报销/申请事由", + "type": "text", + "source": "claim" + }, + { + "key": "item.item_reason", + "label": "明细说明", + "type": "text", + "source": "item" + }, + { + "key": "attachment.ocr_text", + "label": "票据 OCR 全文", + "type": "text", + "source": "attachment" + } + ] + }, + "params": { + "field_keys": [ + "claim.reason", + "item.item_reason", + "attachment.ocr_text" + ], + "search_fields": [ + "claim.reason", + "item.item_reason", + "attachment.ocr_text" + ], + "keywords": [ + "旅游", + "探亲", + "休假", + "度假", + "私人", + "个人行程", + "家属", + "亲友" + ], + "condition_summary": "差旅事由或票据文本命中个人旅游/私人目的关键词。", + "message_template": "识别到个人旅游或非公务目的表达,请确认是否属于公司差旅范围。", + "template_key": "keyword_match_v1" + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "high", + "action": "block", + "risk_score": 86 + } + }, + "metadata": { + "owner": "admin", + "stability": "admin_configured", + "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "created_at": "2026-05-26T07:06:27.746703+00:00", + "created_by": "admin", + "risk_score": 86, + "risk_level": "high", + "rule_title": "个人旅游或非公务目的高风险", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "implementation_note": "使用当前规则中心 JSON 风险规则执行器可识别的字段与模板配置。", + "risk_level_label": "高风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 86, + "level": "high", + "level_label": "高风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } + }, + "severity": "high", + "risk_score": 86, + "risk_level": "high", + "template_key": "keyword_match_v1", + "risk_level_label": "高风险", + "risk_score_detail": { + "score": 86, + "level": "high", + "level_label": "高风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } +} diff --git a/server/rules/risk-rules/risk.travel.high.preapproval_absent.json b/server/rules/risk-rules/risk.travel.high.preapproval_absent.json new file mode 100644 index 0000000..2017331 --- /dev/null +++ b/server/rules/risk-rules/risk.travel.high.preapproval_absent.json @@ -0,0 +1,135 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.travel.high.preapproval_absent", + "name": "差旅未申请或事后补申请高风险", + "description": "申请或报销事由出现未申请、未审批、先报销后补申请等表达时,判定为差旅事前审批缺失风险。", + "enabled": true, + "requires_attachment": false, + "risk_dimension": "travel_reimbursement_control", + "risk_category": "差旅费-申请审批", + "ontology_signal": "travel_preapproval_absent", + "evaluator": "template_rule", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "applies_to": { + "domains": [ + "expense", + "travel" + ], + "expense_types": [ + "travel" + ], + "business_stages": [ + "expense_application", + "reimbursement" + ] + }, + "inputs": { + "fields": [ + { + "key": "claim.reason", + "label": "报销/申请事由", + "type": "text", + "source": "claim" + }, + { + "key": "item.item_reason", + "label": "明细说明", + "type": "text", + "source": "item" + }, + { + "key": "attachment.ocr_text", + "label": "票据 OCR 全文", + "type": "text", + "source": "attachment" + } + ] + }, + "params": { + "field_keys": [ + "claim.reason", + "item.item_reason", + "attachment.ocr_text" + ], + "search_fields": [ + "claim.reason", + "item.item_reason", + "attachment.ocr_text" + ], + "keywords": [ + "未申请", + "无申请", + "未审批", + "未批准", + "先报销", + "事后申请", + "补申请", + "补报" + ], + "condition_summary": "差旅申请/报销文本命中未申请、未审批或事后补申请关键词。", + "message_template": "识别到差旅未事前申请或事后补申请迹象,请补齐已审批的差旅申请后再提交。", + "template_key": "keyword_match_v1" + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "high", + "action": "block", + "risk_score": 92 + } + }, + "metadata": { + "owner": "admin", + "stability": "admin_configured", + "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "created_at": "2026-05-26T07:06:27.746703+00:00", + "created_by": "admin", + "risk_score": 92, + "risk_level": "high", + "rule_title": "差旅未申请或事后补申请高风险", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "implementation_note": "使用当前规则中心 JSON 风险规则执行器可识别的字段与模板配置。", + "risk_level_label": "高风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 92, + "level": "high", + "level_label": "高风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } + }, + "severity": "high", + "risk_score": 92, + "risk_level": "high", + "template_key": "keyword_match_v1", + "risk_level_label": "高风险", + "risk_score_detail": { + "score": 92, + "level": "high", + "level_label": "高风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } +} diff --git a/server/rules/risk-rules/risk.travel.low.application_fields_missing.json b/server/rules/risk-rules/risk.travel.low.application_fields_missing.json new file mode 100644 index 0000000..0cc8e8b --- /dev/null +++ b/server/rules/risk-rules/risk.travel.low.application_fields_missing.json @@ -0,0 +1,138 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.travel.low.application_fields_missing", + "name": "差旅申请基础信息不完整低风险", + "description": "费用申请环节缺少差旅地点、事由、起止时间或预计金额等基础信息时,提示经办人补充。", + "enabled": true, + "requires_attachment": false, + "risk_dimension": "travel_reimbursement_control", + "risk_category": "差旅费-申请信息", + "ontology_signal": "travel_application_fields_missing", + "evaluator": "template_rule", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application" + ], + "expense_types": [ + "travel" + ], + "applies_to": { + "domains": [ + "expense", + "travel" + ], + "expense_types": [ + "travel" + ], + "business_stages": [ + "expense_application" + ] + }, + "inputs": { + "fields": [ + { + "key": "claim.reason", + "label": "报销/申请事由", + "type": "text", + "source": "claim" + }, + { + "key": "claim.location", + "label": "申报地点", + "type": "text", + "source": "claim" + }, + { + "key": "claim.trip_start_date", + "label": "出差开始日期", + "type": "date", + "source": "claim" + }, + { + "key": "claim.trip_end_date", + "label": "出差结束日期", + "type": "date", + "source": "claim" + }, + { + "key": "claim.amount", + "label": "申报金额", + "type": "number", + "source": "claim" + } + ] + }, + "params": { + "field_keys": [ + "claim.reason", + "claim.location", + "claim.trip_start_date", + "claim.trip_end_date", + "claim.amount" + ], + "required_fields": [ + "claim.reason", + "claim.location", + "claim.trip_start_date", + "claim.trip_end_date", + "claim.amount" + ], + "condition_summary": "差旅申请缺少事由、地点、起止时间或预计金额。", + "message_template": "差旅申请基础信息不完整,请补充地点、事由、起止时间和预计金额。", + "template_key": "field_required_v1" + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "low", + "action": "warning", + "risk_score": 42 + } + }, + "metadata": { + "owner": "admin", + "stability": "admin_configured", + "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "created_at": "2026-05-26T07:06:27.746703+00:00", + "created_by": "admin", + "risk_score": 42, + "risk_level": "low", + "rule_title": "差旅申请基础信息不完整低风险", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application" + ], + "expense_types": [ + "travel" + ], + "implementation_note": "使用当前规则中心 JSON 风险规则执行器可识别的字段与模板配置。", + "risk_level_label": "低风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 42, + "level": "low", + "level_label": "低风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } + }, + "severity": "low", + "risk_score": 42, + "risk_level": "low", + "template_key": "field_required_v1", + "risk_level_label": "低风险", + "risk_score_detail": { + "score": 42, + "level": "low", + "level_label": "低风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } +} diff --git a/server/rules/risk-rules/risk.travel.low.attachment_ocr_missing.json b/server/rules/risk-rules/risk.travel.low.attachment_ocr_missing.json new file mode 100644 index 0000000..8a2bf1c --- /dev/null +++ b/server/rules/risk-rules/risk.travel.low.attachment_ocr_missing.json @@ -0,0 +1,109 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.travel.low.attachment_ocr_missing", + "name": "差旅附件无法识别低风险", + "description": "差旅报销已上传附件但没有可读取的 OCR 文本或关键票据信息,提醒人工补录或重新上传。", + "enabled": true, + "requires_attachment": true, + "risk_dimension": "travel_reimbursement_control", + "risk_category": "差旅费-附件质量", + "ontology_signal": "travel_attachment_ocr_missing", + "evaluator": "template_rule", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "applies_to": { + "domains": [ + "expense", + "travel" + ], + "expense_types": [ + "travel" + ], + "business_stages": [ + "expense_application", + "reimbursement" + ] + }, + "inputs": { + "fields": [ + { + "key": "attachment.ocr_text", + "label": "票据 OCR 全文", + "type": "text", + "source": "attachment" + } + ] + }, + "params": { + "field_keys": [ + "attachment.ocr_text" + ], + "required_fields": [ + "attachment.ocr_text" + ], + "condition_summary": "差旅附件缺少可读取 OCR 文本。", + "message_template": "差旅附件暂未识别到有效票据信息,请重新上传清晰附件或人工补录。", + "template_key": "field_required_v1" + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "low", + "action": "warning", + "risk_score": 38 + } + }, + "metadata": { + "owner": "admin", + "stability": "admin_configured", + "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "created_at": "2026-05-26T07:06:27.746703+00:00", + "created_by": "admin", + "risk_score": 38, + "risk_level": "low", + "rule_title": "差旅附件无法识别低风险", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "implementation_note": "使用当前规则中心 JSON 风险规则执行器可识别的字段与模板配置。", + "risk_level_label": "低风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 38, + "level": "low", + "level_label": "低风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } + }, + "severity": "low", + "risk_score": 38, + "risk_level": "low", + "template_key": "field_required_v1", + "risk_level_label": "低风险", + "risk_score_detail": { + "score": 38, + "level": "low", + "level_label": "低风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } +} diff --git a/server/rules/risk-rules/risk.travel.low.local_transport_detail_missing.json b/server/rules/risk-rules/risk.travel.low.local_transport_detail_missing.json new file mode 100644 index 0000000..5c0fcef --- /dev/null +++ b/server/rules/risk-rules/risk.travel.low.local_transport_detail_missing.json @@ -0,0 +1,161 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.travel.low.local_transport_detail_missing", + "name": "市内交通路线说明不足低风险", + "description": "差旅行程中出现打车、网约车、出租车等市内交通表达,但未说明起点、终点或路线。", + "enabled": true, + "requires_attachment": false, + "risk_dimension": "travel_reimbursement_control", + "risk_category": "差旅费-市内交通", + "ontology_signal": "travel_local_transport_detail_missing", + "evaluator": "template_rule", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "applies_to": { + "domains": [ + "expense", + "travel" + ], + "expense_types": [ + "travel" + ], + "business_stages": [ + "expense_application", + "reimbursement" + ] + }, + "inputs": { + "fields": [ + { + "key": "claim.reason", + "label": "报销/申请事由", + "type": "text", + "source": "claim" + }, + { + "key": "item.item_reason", + "label": "明细说明", + "type": "text", + "source": "item" + }, + { + "key": "attachment.ocr_text", + "label": "票据 OCR 全文", + "type": "text", + "source": "attachment" + } + ] + }, + "params": { + "template_key": "composite_rule_v1", + "field_keys": [ + "claim.reason", + "item.item_reason", + "attachment.ocr_text" + ], + "conditions": [ + { + "id": "has_local_transport", + "operator": "contains_any", + "fields": [ + "claim.reason", + "item.item_reason", + "attachment.ocr_text" + ], + "keywords": [ + "打车", + "出租车", + "网约车", + "滴滴", + "市内交通" + ] + }, + { + "id": "missing_route_detail", + "operator": "not_contains_any", + "fields": [ + "claim.reason", + "item.item_reason", + "attachment.ocr_text" + ], + "keywords": [ + "起点", + "终点", + "路线", + "从", + "到", + "往返" + ] + } + ], + "hit_logic": { + "all": [ + "has_local_transport", + "missing_route_detail" + ] + }, + "condition_summary": "存在市内交通关键词,但文本中缺少起点、终点或路线说明。", + "message_template": "市内交通路线说明不足,请补充起点、终点或业务地点。" + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "low", + "action": "warning", + "risk_score": 36 + } + }, + "metadata": { + "owner": "admin", + "stability": "admin_configured", + "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "created_at": "2026-05-26T07:06:27.746703+00:00", + "created_by": "admin", + "risk_score": 36, + "risk_level": "low", + "rule_title": "市内交通路线说明不足低风险", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "implementation_note": "使用当前规则中心 JSON 风险规则执行器可识别的字段与模板配置。", + "risk_level_label": "低风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 36, + "level": "low", + "level_label": "低风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } + }, + "severity": "low", + "risk_score": 36, + "risk_level": "low", + "template_key": "composite_rule_v1", + "risk_level_label": "低风险", + "risk_score_detail": { + "score": 36, + "level": "low", + "level_label": "低风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } +} diff --git a/server/rules/risk-rules/risk.travel.low.vague_ticket_content.json b/server/rules/risk-rules/risk.travel.low.vague_ticket_content.json new file mode 100644 index 0000000..3bf5027 --- /dev/null +++ b/server/rules/risk-rules/risk.travel.low.vague_ticket_content.json @@ -0,0 +1,107 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.travel.low.vague_ticket_content", + "name": "差旅票据服务内容笼统低风险", + "description": "票据商品或服务名称过于笼统,例如仅写服务费、其他、详见清单等,提醒补充明细。", + "enabled": true, + "requires_attachment": true, + "risk_dimension": "travel_reimbursement_control", + "risk_category": "差旅费-票据明细", + "ontology_signal": "travel_vague_ticket_content", + "evaluator": "vague_goods_description", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "applies_to": { + "domains": [ + "expense", + "travel" + ], + "expense_types": [ + "travel" + ], + "business_stages": [ + "expense_application", + "reimbursement" + ] + }, + "inputs": { + "fields": [ + { + "key": "attachment.goods_name", + "label": "商品或服务名称", + "type": "text", + "source": "attachment" + }, + { + "key": "attachment.ocr_text", + "label": "票据 OCR 全文", + "type": "text", + "source": "attachment" + } + ] + }, + "params": { + "condition_summary": "票据商品或服务名称过于笼统,无法直接对应差旅事项。", + "message_template": "差旅票据服务内容较笼统,请补充明细清单或业务说明。" + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "low", + "action": "warning", + "risk_score": 34 + } + }, + "metadata": { + "owner": "admin", + "stability": "admin_configured", + "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "created_at": "2026-05-26T07:06:27.746703+00:00", + "created_by": "admin", + "risk_score": 34, + "risk_level": "low", + "rule_title": "差旅票据服务内容笼统低风险", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "implementation_note": "使用当前规则中心 JSON 风险规则执行器可识别的字段与模板配置。", + "risk_level_label": "低风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 34, + "level": "low", + "level_label": "低风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } + }, + "severity": "low", + "risk_score": 34, + "risk_level": "low", + "risk_level_label": "低风险", + "risk_score_detail": { + "score": 34, + "level": "low", + "level_label": "低风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } +} diff --git a/server/rules/risk-rules/risk.travel.medium.duplicate_ticket.json b/server/rules/risk-rules/risk.travel.medium.duplicate_ticket.json new file mode 100644 index 0000000..669cec4 --- /dev/null +++ b/server/rules/risk-rules/risk.travel.medium.duplicate_ticket.json @@ -0,0 +1,107 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.travel.medium.duplicate_ticket", + "name": "差旅票据重复中风险", + "description": "同一张交通票、住宿票或发票号码在当前单据内重复,或与历史报销附件重复。", + "enabled": true, + "requires_attachment": true, + "risk_dimension": "travel_reimbursement_control", + "risk_category": "差旅费-票据重复", + "ontology_signal": "travel_duplicate_ticket", + "evaluator": "duplicate_invoice", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "applies_to": { + "domains": [ + "expense", + "travel" + ], + "expense_types": [ + "travel" + ], + "business_stages": [ + "expense_application", + "reimbursement" + ] + }, + "inputs": { + "fields": [ + { + "key": "attachment.invoice_no", + "label": "票据号码", + "type": "text", + "source": "attachment" + }, + { + "key": "attachment.ocr_text", + "label": "票据 OCR 全文", + "type": "text", + "source": "attachment" + } + ] + }, + "params": { + "condition_summary": "票据号码在当前单据或历史报销中重复出现。", + "message_template": "发现疑似重复票据,请核对是否已经报销或重复上传。" + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "medium", + "action": "manual_review", + "risk_score": 75 + } + }, + "metadata": { + "owner": "admin", + "stability": "admin_configured", + "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "created_at": "2026-05-26T07:06:27.746703+00:00", + "created_by": "admin", + "risk_score": 75, + "risk_level": "medium", + "rule_title": "差旅票据重复中风险", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "implementation_note": "使用当前规则中心 JSON 风险规则执行器可识别的字段与模板配置。", + "risk_level_label": "中风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 75, + "level": "medium", + "level_label": "中风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } + }, + "severity": "medium", + "risk_score": 75, + "risk_level": "medium", + "risk_level_label": "中风险", + "risk_score_detail": { + "score": 75, + "level": "medium", + "level_label": "中风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } +} diff --git a/server/rules/risk-rules/risk.travel.medium.multi_city_no_reason.json b/server/rules/risk-rules/risk.travel.medium.multi_city_no_reason.json new file mode 100644 index 0000000..f4d986a --- /dev/null +++ b/server/rules/risk-rules/risk.travel.medium.multi_city_no_reason.json @@ -0,0 +1,125 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.travel.medium.multi_city_no_reason", + "name": "多城市行程缺少说明中风险", + "description": "票据或明细识别到多个城市,但申请/报销事由未说明中转、多地拜访、改签或绕行原因。", + "enabled": true, + "requires_attachment": true, + "risk_dimension": "travel_reimbursement_control", + "risk_category": "差旅费-行程说明", + "ontology_signal": "travel_multi_city_without_reason", + "evaluator": "multi_city_reason_required", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "applies_to": { + "domains": [ + "expense", + "travel" + ], + "expense_types": [ + "travel" + ], + "business_stages": [ + "expense_application", + "reimbursement" + ] + }, + "inputs": { + "fields": [ + { + "key": "claim.reason", + "label": "报销/申请事由", + "type": "text", + "source": "claim" + }, + { + "key": "item.item_reason", + "label": "明细说明", + "type": "text", + "source": "item" + }, + { + "key": "item.item_location", + "label": "明细发生地点", + "type": "text", + "source": "item" + }, + { + "key": "attachment.route_cities", + "label": "交通票行程城市", + "type": "list", + "source": "attachment" + }, + { + "key": "attachment.hotel_city", + "label": "住宿城市", + "type": "text", + "source": "attachment" + } + ] + }, + "params": { + "condition_summary": "差旅行程涉及 3 个及以上城市,且事由未包含中转、多地、改签、绕行等说明。", + "message_template": "识别到多城市差旅行程,请补充中转、多地拜访或改签原因。" + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "medium", + "action": "manual_review", + "risk_score": 72 + } + }, + "metadata": { + "owner": "admin", + "stability": "admin_configured", + "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "created_at": "2026-05-26T07:06:27.746703+00:00", + "created_by": "admin", + "risk_score": 72, + "risk_level": "medium", + "rule_title": "多城市行程缺少说明中风险", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "implementation_note": "使用当前规则中心 JSON 风险规则执行器可识别的字段与模板配置。", + "risk_level_label": "中风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 72, + "level": "medium", + "level_label": "中风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } + }, + "severity": "medium", + "risk_score": 72, + "risk_level": "medium", + "risk_level_label": "中风险", + "risk_score_detail": { + "score": 72, + "level": "medium", + "level_label": "中风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } +} diff --git a/server/rules/risk-rules/risk.travel.medium.reason_too_brief.json b/server/rules/risk-rules/risk.travel.medium.reason_too_brief.json new file mode 100644 index 0000000..55a61c6 --- /dev/null +++ b/server/rules/risk-rules/risk.travel.medium.reason_too_brief.json @@ -0,0 +1,108 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.travel.medium.reason_too_brief", + "name": "差旅事由过短中风险", + "description": "差旅申请或报销事由有效描述不足,无法支撑目的、客户/项目、行程必要性的判断。", + "enabled": true, + "requires_attachment": false, + "risk_dimension": "travel_reimbursement_control", + "risk_category": "差旅费-事由完整性", + "ontology_signal": "travel_reason_too_brief", + "evaluator": "reason_too_brief", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "applies_to": { + "domains": [ + "expense", + "travel" + ], + "expense_types": [ + "travel" + ], + "business_stages": [ + "expense_application", + "reimbursement" + ] + }, + "inputs": { + "fields": [ + { + "key": "claim.reason", + "label": "报销/申请事由", + "type": "text", + "source": "claim" + }, + { + "key": "item.item_reason", + "label": "明细说明", + "type": "text", + "source": "item" + } + ] + }, + "params": { + "min_reason_length": 10, + "condition_summary": "合并申请/报销事由后有效字符少于 10 个。", + "message_template": "差旅事由描述过短,请补充项目、客户、地点和出差目的。" + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "medium", + "action": "manual_review", + "risk_score": 68 + } + }, + "metadata": { + "owner": "admin", + "stability": "admin_configured", + "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "created_at": "2026-05-26T07:06:27.746703+00:00", + "created_by": "admin", + "risk_score": 68, + "risk_level": "medium", + "rule_title": "差旅事由过短中风险", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "implementation_note": "使用当前规则中心 JSON 风险规则执行器可识别的字段与模板配置。", + "risk_level_label": "中风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 68, + "level": "medium", + "level_label": "中风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } + }, + "severity": "medium", + "risk_score": 68, + "risk_level": "medium", + "risk_level_label": "中风险", + "risk_score_detail": { + "score": 68, + "level": "medium", + "level_label": "中风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } +} diff --git a/server/rules/risk-rules/risk.travel.medium.title_mismatch.json b/server/rules/risk-rules/risk.travel.medium.title_mismatch.json new file mode 100644 index 0000000..5461f0a --- /dev/null +++ b/server/rules/risk-rules/risk.travel.medium.title_mismatch.json @@ -0,0 +1,118 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.travel.medium.title_mismatch", + "name": "差旅票据抬头不一致中风险", + "description": "票据抬头、乘车人或购买方与报销人不一致,且不属于公司抬头或允许例外。", + "enabled": true, + "requires_attachment": true, + "risk_dimension": "travel_reimbursement_control", + "risk_category": "差旅费-票据主体", + "ontology_signal": "travel_invoice_title_mismatch", + "evaluator": "identity_consistency", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "applies_to": { + "domains": [ + "expense", + "travel" + ], + "expense_types": [ + "travel" + ], + "business_stages": [ + "expense_application", + "reimbursement" + ] + }, + "inputs": { + "fields": [ + { + "key": "claim.employee_name", + "label": "申请/报销人", + "type": "text", + "source": "claim" + }, + { + "key": "attachment.buyer_name", + "label": "票据抬头/购买方", + "type": "text", + "source": "attachment" + }, + { + "key": "attachment.ocr_text", + "label": "票据 OCR 全文", + "type": "text", + "source": "attachment" + } + ] + }, + "params": { + "allow_keywords": [ + "公司", + "远光", + "远光软件" + ], + "condition_summary": "票据抬头/购买方不包含报销人姓名,也不包含公司抬头关键词。", + "message_template": "票据抬头或乘车人与报销人不一致,请补充代订、同行或公司抬头说明。" + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "medium", + "action": "manual_review", + "risk_score": 64 + } + }, + "metadata": { + "owner": "admin", + "stability": "admin_configured", + "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "created_at": "2026-05-26T07:06:27.746703+00:00", + "created_by": "admin", + "risk_score": 64, + "risk_level": "medium", + "rule_title": "差旅票据抬头不一致中风险", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "implementation_note": "使用当前规则中心 JSON 风险规则执行器可识别的字段与模板配置。", + "risk_level_label": "中风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 64, + "level": "medium", + "level_label": "中风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } + }, + "severity": "medium", + "risk_score": 64, + "risk_level": "medium", + "risk_level_label": "中风险", + "risk_score_detail": { + "score": 64, + "level": "medium", + "level_label": "中风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } +} diff --git a/server/scripts/build_expense_control_demo_risk_rules.py b/server/scripts/build_expense_control_demo_risk_rules.py new file mode 100644 index 0000000..20aabe1 --- /dev/null +++ b/server/scripts/build_expense_control_demo_risk_rules.py @@ -0,0 +1,704 @@ +#!/usr/bin/env python3 +"""Build expense-control demo risk rule JSON manifests.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path + +SERVER_DIR = Path(__file__).resolve().parents[1] +RISK_RULE_DIR = SERVER_DIR / "rules" / "risk-rules" + + +BUDGET_EXPENSE_TYPES = ( + "travel", + "hotel", + "transport", + "meal", + "meeting", + "marketing", + "office", + "training", + "software", + "communication", + "welfare", +) + + +FIELD_LABELS = { + "claim.amount": ("报销金额", "number", "claim"), + "claim.expense_type": ("费用类型", "enum", "claim"), + "claim.department_name": ("部门", "text", "claim"), + "claim.reason": ("事由", "text", "claim"), + "item.item_reason": ("明细说明", "text", "item"), + "application.id": ("申请单", "text", "application"), + "application.status": ("申请状态", "enum", "application"), + "application.approved_amount": ("申请审批金额", "number", "application"), + "application.expense_type": ("申请费用类型", "enum", "application"), + "application.department_name": ("申请部门", "text", "application"), + "application.start_date": ("申请开始日期", "date", "application"), + "application.end_date": ("申请结束日期", "date", "application"), + "budget.line_id": ("预算行", "text", "budget"), + "budget.available_amount": ("预算可用金额", "number", "budget"), + "budget.used_rate": ("预算使用率", "number", "budget"), + "budget.status": ("预算状态", "enum", "budget"), + "budget.department_name": ("预算部门", "text", "budget"), + "budget.quarter": ("预算季度", "text", "budget"), + "budget.project_code": ("预算项目", "text", "budget"), + "material.attachment_count": ("附件数量", "number", "material"), + "material.contract_uploaded": ("合同已上传", "boolean", "material"), + "material.acceptance_uploaded": ("验收材料已上传", "boolean", "material"), + "material.plan_uploaded": ("方案已上传", "boolean", "material"), + "material.attendee_list_uploaded": ("参与人清单已上传", "boolean", "material"), + "material.invoice_uploaded": ("发票已上传", "boolean", "material"), +} + + +BASE_FIELDS = ( + "claim.amount", + "claim.expense_type", + "claim.department_name", + "claim.reason", + "item.item_reason", +) +BUDGET_FIELDS = BASE_FIELDS + ( + "budget.line_id", + "budget.available_amount", + "budget.used_rate", + "budget.status", + "budget.department_name", + "budget.quarter", + "budget.project_code", +) +APPLICATION_FIELDS = BASE_FIELDS + ( + "application.id", + "application.status", + "application.approved_amount", + "application.expense_type", + "application.department_name", +) +MATERIAL_FIELDS = BASE_FIELDS + ( + "material.attachment_count", + "material.contract_uploaded", + "material.acceptance_uploaded", + "material.plan_uploaded", + "material.attendee_list_uploaded", + "material.invoice_uploaded", +) + + +@dataclass(frozen=True, slots=True) +class DemoRiskRule: + code: str + name: str + description: str + category: str + ontology_signal: str + finance_rule_code: str + finance_rule_sheet: str + business_stage: tuple[str, ...] + expense_types: tuple[str, ...] + condition_summary: str + keywords: tuple[str, ...] + severity: str + action: str + risk_score: int + risk_level: str + budget_required: bool = True + requires_attachment: bool = False + field_keys: tuple[str, ...] = field(default_factory=lambda: BASE_FIELDS) + + +def _budget_rule( + code: str, + name: str, + description: str, + condition_summary: str, + keywords: tuple[str, ...], + severity: str, + action: str, + risk_score: int, +) -> DemoRiskRule: + return DemoRiskRule( + code=code, + name=name, + description=description, + category="预算管控", + ontology_signal="budget_over_limit", + finance_rule_code="budget.execution.policy", + finance_rule_sheet="预算执行规则", + business_stage=("expense_application", "reimbursement", "budget_execution"), + expense_types=BUDGET_EXPENSE_TYPES, + condition_summary=condition_summary, + keywords=keywords, + severity=severity, + action=action, + risk_score=risk_score, + risk_level="high" if risk_score >= 80 else "medium", + field_keys=BUDGET_FIELDS, + ) + + +def _rule_payload(rule: DemoRiskRule) -> dict[str, object]: + now = datetime.now(UTC).isoformat() + fields = [ + { + "key": key, + "label": FIELD_LABELS[key][0], + "type": FIELD_LABELS[key][1], + "source": FIELD_LABELS[key][2], + } + for key in rule.field_keys + ] + return { + "schema_version": "2.0", + "rule_code": rule.code, + "name": rule.name, + "description": rule.description, + "enabled": True, + "requires_attachment": rule.requires_attachment, + "risk_dimension": "expense_control_demo", + "risk_category": rule.category, + "ontology_signal": rule.ontology_signal, + "evaluator": "template_rule", + "template_key": "keyword_match_v1", + "finance_rule_code": rule.finance_rule_code, + "finance_rule_sheet": rule.finance_rule_sheet, + "business_stage": list(rule.business_stage), + "expense_types": list(rule.expense_types), + "budget_required": rule.budget_required, + "applies_to": { + "domains": ["expense"], + "expense_types": list(rule.expense_types), + "business_stages": list(rule.business_stage), + }, + "inputs": {"fields": fields}, + "params": { + "template_key": "keyword_match_v1", + "field_keys": list(rule.field_keys), + "search_fields": [ + "claim.reason", + "item.item_reason", + "claim.expense_type", + ], + "keywords": list(rule.keywords), + "condition_summary": rule.condition_summary, + "finance_rule_code": rule.finance_rule_code, + "finance_rule_sheet": rule.finance_rule_sheet, + "business_stage": list(rule.business_stage), + "expense_types": list(rule.expense_types), + "budget_required": rule.budget_required, + }, + "outcomes": { + "pass": {"severity": "none", "action": "continue"}, + "fail": { + "severity": rule.severity, + "action": rule.action, + "risk_score": rule.risk_score, + }, + }, + "metadata": { + "owner": "风控与审计部", + "stability": "platform", + "source_ref": "费用管控 Demo 风险规则库", + "created_at": now, + "created_by": "system", + "risk_score": rule.risk_score, + "risk_level": rule.risk_level, + "rule_title": rule.name, + "finance_rule_code": rule.finance_rule_code, + "finance_rule_sheet": rule.finance_rule_sheet, + "business_stage": list(rule.business_stage), + "expense_types": list(rule.expense_types), + "budget_required": rule.budget_required, + }, + "severity": rule.severity, + "risk_score": rule.risk_score, + "risk_level": rule.risk_level, + } + + +RULES: tuple[DemoRiskRule, ...] = ( + _budget_rule( + "risk.budget.available_balance_insufficient", + "预算可用余额不足", + "提交后预算余额为负,或当前可用预算不足以覆盖本次申请/报销金额。", + "预算可用金额小于本次金额时触发。", + ("预算不足", "可用余额不足", "超预算"), + "high", + "manual_review", + 88, + ), + _budget_rule( + "risk.budget.usage_warning_80", + "预算使用率达到 80% 预警", + "报销或申请通过后,部门/项目/费用类型预算使用率达到 80% 以上。", + "预算使用率大于等于 80% 且低于 100% 时触发。", + ("预算预警", "80%", "使用率过高"), + "medium", + "warning", + 70, + ), + _budget_rule( + "risk.budget.usage_over_100", + "预算使用率超过 100% 管控", + "报销或申请通过后,预算使用率超过 100%,需要阻断或升级审批。", + "预算使用率超过 100% 时触发。", + ("预算超支", "超过100%", "禁止提交"), + "critical", + "block_submit", + 96, + ), + _budget_rule( + "risk.budget.frozen_or_closed_used", + "使用冻结或关闭预算", + "单据引用了已冻结、已关闭或已作废的预算行。", + "预算状态不是启用时触发。", + ("冻结预算", "关闭预算", "预算作废"), + "high", + "block_submit", + 90, + ), + _budget_rule( + "risk.budget.missing_budget_line", + "缺少预算口径", + "需要预算管控的费用未关联年度、季度、部门、项目或费用类型预算。", + "费用类型要求预算管控但预算行为空时触发。", + ("无预算", "预算口径缺失", "未关联预算"), + "high", + "manual_review", + 82, + ), + _budget_rule( + "risk.budget.cross_department_without_authorization", + "跨部门预算未授权", + "报销部门与预算归属部门不一致,且没有跨部门预算授权。", + "单据部门与预算部门不一致且无授权说明时触发。", + ("跨部门预算", "部门不一致", "未授权"), + "high", + "manual_review", + 86, + ), + _budget_rule( + "risk.budget.cross_quarter_without_explanation", + "跨季度预算未说明", + "单据发生期间与预算季度不一致,且缺少跨季度使用说明。", + "发生季度与预算季度不一致且未说明时触发。", + ("跨季度预算", "季度不一致", "未说明"), + "medium", + "manual_review", + 76, + ), + _budget_rule( + "risk.budget.project_department_mismatch", + "项目预算与部门不匹配", + "单据引用的项目预算不属于当前部门或当前成本中心。", + "项目预算归属与报销部门不一致时触发。", + ("项目预算", "成本中心不匹配", "部门不匹配"), + "high", + "manual_review", + 84, + ), + _budget_rule( + "risk.budget.duplicate_reserve", + "重复占用预算", + "同一申请、项目或合同已占用预算,本次单据再次占用同一预算口径。", + "相同业务标识存在未释放预算占用时触发。", + ("重复占用", "预算锁定", "重复申请"), + "medium", + "manual_review", + 74, + ), + _budget_rule( + "risk.budget.consume_without_release", + "预算占用未释放", + "申请取消、退回或驳回后,预算占用未释放导致后续可用预算失真。", + "申请非有效状态但仍存在预算占用时触发。", + ("占用未释放", "退回未释放", "预算释放"), + "medium", + "manual_review", + 72, + ), + DemoRiskRule( + "risk.application.large_expense_without_preapproval", + "大额费用未事前申请", + "达到财务制度中大额标准的费用,未找到有效事前申请即进入报销。", + "申请前置", + "application_required", + "finance.preapproval.policy", + "费用申请前置规则", + ("reimbursement",), + BUDGET_EXPENSE_TYPES, + "金额达到大额阈值且缺少已通过申请单时触发。", + ("大额费用", "未申请", "先申请后报销"), + "high", + "manual_review", + 86, + "high", + field_keys=APPLICATION_FIELDS, + ), + DemoRiskRule( + "risk.application.marketing_without_campaign", + "市场推广费无活动申请", + "市场活动、投放、展会等推广费用,缺少已审批的活动申请或投放方案。", + "申请前置", + "application_required", + "expense.application.policy", + "费用申请前置规则", + ("reimbursement",), + ("marketing",), + "市场推广费报销缺少活动申请或方案时触发。", + ("市场推广", "活动申请", "投放方案"), + "high", + "manual_review", + 84, + "high", + field_keys=APPLICATION_FIELDS + ("material.plan_uploaded",), + ), + DemoRiskRule( + "risk.application.training_without_plan", + "培训费无培训计划", + "培训费报销缺少年度培训计划、专项培训申请或审批记录。", + "申请前置", + "application_required", + "expense.application.policy", + "费用申请前置规则", + ("reimbursement",), + ("training",), + "培训费用没有匹配到培训计划或事前申请时触发。", + ("培训计划", "培训申请", "未申请"), + "high", + "manual_review", + 83, + "high", + field_keys=APPLICATION_FIELDS + ("material.plan_uploaded",), + ), + DemoRiskRule( + "risk.application.meeting_without_application", + "会务费无会议申请", + "会务场地、物料、会务服务等费用缺少会议申请或会议预算审批。", + "申请前置", + "application_required", + "expense.application.policy", + "费用申请前置规则", + ("reimbursement",), + ("meeting",), + "会务费报销缺少会议申请、会议预算或会议通知时触发。", + ("会议申请", "会议预算", "会务费"), + "high", + "manual_review", + 82, + "high", + field_keys=APPLICATION_FIELDS + ("material.plan_uploaded",), + ), + DemoRiskRule( + "risk.application.software_without_purchase", + "软件服务费无采购申请", + "软件订阅、SaaS 服务或系统实施费用缺少采购申请或审批链。", + "申请前置", + "application_required", + "expense.application.policy", + "费用申请前置规则", + ("reimbursement",), + ("software",), + "软件服务费缺少采购申请或合同审批时触发。", + ("软件采购", "SaaS", "采购申请"), + "high", + "manual_review", + 85, + "high", + field_keys=APPLICATION_FIELDS + ("material.contract_uploaded",), + ), + DemoRiskRule( + "risk.application.office_bulk_without_purchase", + "办公用品大额采购未申请", + "批量办公用品或设备采购达到阈值但未走采购申请。", + "申请前置", + "application_required", + "expense.application.policy", + "费用申请前置规则", + ("reimbursement",), + ("office",), + "办公用品单次金额达到采购阈值且缺少采购申请时触发。", + ("办公采购", "大额办公用品", "采购申请"), + "medium", + "manual_review", + 78, + "medium", + field_keys=APPLICATION_FIELDS, + ), + DemoRiskRule( + "risk.application.meal_high_value_without_preapproval", + "大额业务招待未申请", + "业务招待金额或人均金额超过制度阈值但未事前审批。", + "申请前置", + "application_required", + "expense.application.policy", + "费用申请前置规则", + ("reimbursement",), + ("meal",), + "业务招待金额超过申请阈值且没有通过申请时触发。", + ("业务招待", "人均超标", "未申请"), + "high", + "manual_review", + 84, + "high", + field_keys=APPLICATION_FIELDS + ("material.attendee_list_uploaded",), + ), + DemoRiskRule( + "risk.application.travel_large_without_preapproval", + "大额差旅未申请", + "多人出差、长周期出差或高金额差旅报销缺少出差申请。", + "申请前置", + "application_required", + "rule.expense.company_travel_expense_reimbursement", + "差旅住宿费标准", + ("reimbursement",), + ("travel", "hotel", "transport"), + "差旅金额达到大额阈值且缺少有效出差申请时触发。", + ("差旅申请", "大额差旅", "未申请"), + "high", + "manual_review", + 82, + "high", + field_keys=APPLICATION_FIELDS, + ), + DemoRiskRule( + "risk.reimbursement.amount_over_application", + "报销金额超过申请金额", + "报销总金额超过已审批申请金额,需要按偏差规则复核。", + "报销偏差", + "amount_over_application", + "application.reimbursement.linkage.policy", + "申请报销关联规则", + ("reimbursement",), + BUDGET_EXPENSE_TYPES, + "报销金额大于申请审批金额时触发。", + ("超过申请金额", "报销偏差", "申请金额"), + "medium", + "manual_review", + 76, + "medium", + field_keys=APPLICATION_FIELDS, + ), + DemoRiskRule( + "risk.reimbursement.amount_over_application_10pct", + "报销金额超过申请金额 10%", + "报销金额比申请审批金额高出 10% 以上,需要升级审批或禁止提交。", + "报销偏差", + "amount_over_application", + "application.reimbursement.linkage.policy", + "申请报销关联规则", + ("reimbursement",), + BUDGET_EXPENSE_TYPES, + "报销金额超过申请审批金额 10% 以上时触发。", + ("超过申请10%", "金额偏差", "升级审批"), + "high", + "manual_review", + 88, + "high", + field_keys=APPLICATION_FIELDS, + ), + DemoRiskRule( + "risk.reimbursement.expense_type_mismatch_application", + "报销费用类型与申请不一致", + "报销单费用类型与关联申请单费用类型不一致。", + "报销偏差", + "expense_type_mismatch", + "application.reimbursement.linkage.policy", + "申请报销关联规则", + ("reimbursement",), + BUDGET_EXPENSE_TYPES, + "报销费用类型与申请费用类型不一致时触发。", + ("费用类型不一致", "申请报销不匹配", "类型偏差"), + "medium", + "manual_review", + 74, + "medium", + field_keys=APPLICATION_FIELDS, + ), + DemoRiskRule( + "risk.reimbursement.department_mismatch_application", + "报销部门与申请部门不一致", + "报销部门、成本中心与关联申请单不一致,且缺少调整说明。", + "报销偏差", + "department_mismatch", + "application.reimbursement.linkage.policy", + "申请报销关联规则", + ("reimbursement",), + BUDGET_EXPENSE_TYPES, + "报销部门与申请部门不一致时触发。", + ("部门不一致", "成本中心偏差", "申请部门"), + "medium", + "manual_review", + 72, + "medium", + field_keys=APPLICATION_FIELDS, + ), + DemoRiskRule( + "risk.reimbursement.period_outside_application", + "报销发生期间超出申请期间", + "费用发生日期不在已审批申请的起止日期范围内。", + "报销偏差", + "period_outside_application", + "application.reimbursement.linkage.policy", + "申请报销关联规则", + ("reimbursement",), + BUDGET_EXPENSE_TYPES, + "发生日期超出申请有效期间时触发。", + ("期间不一致", "超出申请期间", "日期偏差"), + "medium", + "manual_review", + 70, + "medium", + field_keys=APPLICATION_FIELDS + ("application.start_date", "application.end_date"), + ), + DemoRiskRule( + "risk.reimbursement.rejected_application_claimed", + "已驳回申请被用于报销", + "报销单关联的申请单为驳回、撤回或已取消状态。", + "报销偏差", + "invalid_application_status", + "application.reimbursement.linkage.policy", + "申请报销关联规则", + ("reimbursement",), + BUDGET_EXPENSE_TYPES, + "关联申请状态不是已通过或已完成时触发。", + ("申请驳回", "申请撤回", "无效申请"), + "high", + "block_submit", + 92, + "high", + field_keys=APPLICATION_FIELDS, + ), + DemoRiskRule( + "risk.reimbursement.duplicate_against_application", + "同一申请重复报销", + "同一申请单或同一合同/项目存在多笔疑似重复报销。", + "报销偏差", + "duplicate_reimbursement", + "application.reimbursement.linkage.policy", + "申请报销关联规则", + ("reimbursement",), + BUDGET_EXPENSE_TYPES, + "同一申请的已报销金额与本次金额超过申请金额时触发。", + ("重复报销", "同一申请", "重复占用"), + "high", + "manual_review", + 86, + "high", + field_keys=APPLICATION_FIELDS, + ), + DemoRiskRule( + "risk.standard.meal_participants_missing", + "业务招待缺少参与人清单", + "业务招待费要求提供客户名称、参与人清单和招待说明。", + "材料完整性", + "material_missing", + "expense.material.policy", + "材料完整性规则", + ("reimbursement",), + ("meal",), + "业务招待费缺少参与人清单或客户信息时触发。", + ("参与人清单", "客户信息", "业务招待"), + "medium", + "manual_review", + 72, + "medium", + requires_attachment=True, + field_keys=MATERIAL_FIELDS, + ), + DemoRiskRule( + "risk.standard.software_contract_missing", + "软件服务费缺少合同", + "软件服务费、SaaS 订阅或系统实施费用缺少合同、订单或验收材料。", + "材料完整性", + "contract_missing", + "expense.material.policy", + "材料完整性规则", + ("expense_application", "reimbursement"), + ("software",), + "软件服务费要求合同或订单但未提供时触发。", + ("合同缺失", "软件服务", "验收材料"), + "high", + "manual_review", + 84, + "high", + requires_attachment=True, + field_keys=MATERIAL_FIELDS, + ), + DemoRiskRule( + "risk.standard.marketing_acceptance_missing", + "市场推广费缺少验收材料", + "市场推广活动缺少投放截图、活动复盘、验收单或结案材料。", + "材料完整性", + "acceptance_missing", + "expense.material.policy", + "材料完整性规则", + ("reimbursement",), + ("marketing",), + "市场推广费要求验收或结案材料但未提供时触发。", + ("验收材料", "活动结案", "投放截图"), + "high", + "manual_review", + 82, + "high", + requires_attachment=True, + field_keys=MATERIAL_FIELDS, + ), + DemoRiskRule( + "risk.standard.office_fixed_asset_as_office", + "固定资产伪装为办公用品费", + "办公用品费明细疑似包含固定资产、电子设备或应走采购入库的物品。", + "费用标准", + "expense_type_mismatch", + "expense.classification.policy", + "费用类型归类规则", + ("reimbursement",), + ("office",), + "办公用品费包含固定资产关键词或超过采购阈值时触发。", + ("固定资产", "电脑", "显示器", "办公设备"), + "medium", + "manual_review", + 78, + "medium", + field_keys=BASE_FIELDS, + ), + DemoRiskRule( + "risk.standard.meeting_attendee_list_missing", + "会务费缺少参会名单", + "会务费报销缺少参会名单、会议通知、会议照片或会议纪要。", + "材料完整性", + "material_missing", + "expense.material.policy", + "材料完整性规则", + ("reimbursement",), + ("meeting",), + "会务费要求参会名单或会议材料但未提供时触发。", + ("参会名单", "会议通知", "会议纪要"), + "medium", + "manual_review", + 74, + "medium", + requires_attachment=True, + field_keys=MATERIAL_FIELDS, + ), +) + + +def main() -> None: + RISK_RULE_DIR.mkdir(parents=True, exist_ok=True) + for rule in RULES: + path = RISK_RULE_DIR / f"{rule.code}.json" + payload = _rule_payload(rule) + path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + print(f"Generated {len(RULES)} expense-control demo risk rule manifest(s).") + + +if __name__ == "__main__": + main() diff --git a/server/scripts/sync_finance_rule_assets.py b/server/scripts/sync_finance_rule_assets.py new file mode 100644 index 0000000..b7fe7f6 --- /dev/null +++ b/server/scripts/sync_finance_rule_assets.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Sync finance rule spreadsheet assets from the built-in catalog.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +SERVER_SRC = Path(__file__).resolve().parents[1] / "src" +if str(SERVER_SRC) not in sys.path: + sys.path.insert(0, str(SERVER_SRC)) + +from app.db.session import get_session_factory # noqa: E402 +from app.services.agent_foundation import AgentFoundationService # noqa: E402 + + +def main() -> None: + db = get_session_factory()() + try: + count = AgentFoundationService(db).sync_finance_rule_assets_from_catalog() + db.commit() + print(f"Synced {count} finance rule asset(s) from catalog.") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/server/src/app/api/deps.py b/server/src/app/api/deps.py index a7fc866..2831e30 100644 --- a/server/src/app/api/deps.py +++ b/server/src/app/api/deps.py @@ -1,30 +1,31 @@ -from collections.abc import Generator -from dataclasses import dataclass -from typing import Annotated - -from fastapi import Depends, Header, HTTPException, status -from sqlalchemy.orm import Session - -from app.db.session import get_session_factory - - -def get_db() -> Generator[Session, None, None]: - db = get_session_factory()() - try: - yield db - finally: - db.close() - - -@dataclass(slots=True) +from collections.abc import Generator +from dataclasses import dataclass +from typing import Annotated + +from fastapi import Depends, Header, HTTPException, status +from sqlalchemy.orm import Session + +from app.db.session import get_session_factory + + +def get_db() -> Generator[Session, None, None]: + db = get_session_factory()() + try: + yield db + finally: + db.close() + + +@dataclass(slots=True) class CurrentUserContext: username: str name: str role_codes: list[str] is_admin: bool department_name: str = "" - - + cost_center: str = "" + + def get_current_user( x_auth_username: Annotated[ str | None, @@ -46,34 +47,75 @@ def get_current_user( str | None, Header(description="当前登录人的所属部门。"), ] = None, + x_auth_cost_center: Annotated[ + str | None, + Header(description="当前登录人的成本中心。"), + ] = None, ) -> CurrentUserContext: - role_codes = [item.strip() for item in (x_auth_role_codes or "").split(",") if item.strip()] - is_admin = str(x_auth_is_admin or "").strip().lower() in {"1", "true", "yes", "on"} - - username = (x_auth_username or "").strip() - name = (x_auth_name or username).strip() - - if not username and not name: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="请先登录后再访问知识库。", - ) - - return CurrentUserContext( - username=username or name, + role_codes = [ + _normalize_role_code(item) + for item in (x_auth_role_codes or "").split(",") + if _normalize_role_code(item) + ] + username = (x_auth_username or "").strip() + name = (x_auth_name or username).strip() + is_admin = _resolve_platform_admin_flag( + username=username, + name=name, + role_codes=role_codes, + header_value=x_auth_is_admin, + ) + + if not username and not name: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="请先登录后再访问知识库。", + ) + + return CurrentUserContext( + username=username or name, name=name or username, role_codes=role_codes, is_admin=is_admin, department_name=(x_auth_department or "").strip(), + cost_center=(x_auth_cost_center or "").strip(), ) - - + + +def _normalize_role_code(value: str | None) -> str: + role_code = str(value or "").strip().lower() + if role_code == "auditor": + return "budget_monitor" + return role_code + + +def _current_user_role_codes(current_user: CurrentUserContext) -> set[str]: + return {_normalize_role_code(item) for item in current_user.role_codes if _normalize_role_code(item)} + + +def _resolve_platform_admin_flag( + *, + username: str, + name: str, + role_codes: list[str], + header_value: str | None, +) -> bool: + if str(header_value or "").strip().lower() in {"1", "true", "yes", "on"}: + return True + + identities = { + str(username or "").strip().lower(), + str(name or "").strip().lower(), + } + return "admin" in identities or "admin" in {_normalize_role_code(item) for item in role_codes} + + def require_admin_user( current_user: Annotated[CurrentUserContext, Depends(get_current_user)], ) -> CurrentUserContext: - if current_user.is_admin or "manager" in current_user.role_codes: + if current_user.is_admin or "manager" in _current_user_role_codes(current_user): return current_user - + raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="只有管理员可以上传、删除或修改知识库文件。", @@ -95,24 +137,58 @@ def require_platform_admin_user( def require_rule_editor_user( current_user: Annotated[CurrentUserContext, Depends(get_current_user)], ) -> CurrentUserContext: - role_codes = {item.strip() for item in current_user.role_codes} + role_codes = _current_user_role_codes(current_user) if current_user.is_admin or "manager" in role_codes or "finance" in role_codes: return current_user raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="只有财务人员或高级管理人员可以编辑规则草稿。", + detail="只有财务人员或高级财务人员可以编辑规则草稿。", ) def require_rule_reviewer_user( current_user: Annotated[CurrentUserContext, Depends(get_current_user)], ) -> CurrentUserContext: - role_codes = {item.strip() for item in current_user.role_codes} + role_codes = _current_user_role_codes(current_user) if current_user.is_admin or "manager" in role_codes: return current_user raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="只有高级管理人员或 admin 管理员可以执行该操作。", + detail="只有高级财务人员或 admin 管理员可以执行该操作。", ) + + +def require_budget_viewer_user( + current_user: Annotated[CurrentUserContext, Depends(get_current_user)], +) -> CurrentUserContext: + role_codes = _current_user_role_codes(current_user) + if current_user.is_admin or role_codes & {"budget_monitor", "executive"}: + return current_user + + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="只有预算监控员或高级财务人员可以查看预算中心。", + ) + + +def require_budget_editor_user( + current_user: Annotated[CurrentUserContext, Depends(get_current_user)], +) -> CurrentUserContext: + role_codes = _current_user_role_codes(current_user) + if current_user.is_admin or "executive" in role_codes: + return current_user + + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="只有 admin 管理员或高级财务人员可以维护预算额度。", + ) + + +def is_budget_scope_limited_user(current_user: CurrentUserContext) -> bool: + if current_user.is_admin: + return False + + role_codes = _current_user_role_codes(current_user) + return "budget_monitor" in role_codes and "executive" not in role_codes diff --git a/server/src/app/api/v1/endpoints/agent_assets.py b/server/src/app/api/v1/endpoints/agent_assets.py index e37ba0a..9d7cb9b 100644 --- a/server/src/app/api/v1/endpoints/agent_assets.py +++ b/server/src/app/api/v1/endpoints/agent_assets.py @@ -695,9 +695,9 @@ def create_agent_asset_review( role_codes = {item.strip() for item in current_user.role_codes} if payload.review_status.value == "pending": if not (current_user.is_admin or "manager" in role_codes or "finance" in role_codes): - raise PermissionError("只有财务人员或高级管理人员可以提交审核。") + raise PermissionError("只有财务人员或高级财务人员可以提交审核。") elif not (current_user.is_admin or "manager" in role_codes): - raise PermissionError("只有高级管理人员可以审核规则。") + raise PermissionError("只有高级财务人员可以审核规则。") return AgentAssetService(db).create_review( asset_id, payload, @@ -746,7 +746,7 @@ def activate_agent_asset( response_model=AgentAssetRead, summary="设置风险规则启用状态", description=( - "高级管理人员可独立启用或停用 JSON 风险规则;停用后即使已上线也不会进入真实业务扫描。" + "高级财务人员可独立启用或停用 JSON 风险规则;停用后即使已上线也不会进入真实业务扫描。" ), ) def set_agent_asset_risk_rule_enabled( @@ -797,7 +797,7 @@ def set_agent_asset_risk_rule_level( "/{asset_id}/return", response_model=AgentAssetRiskRuleLatestTestSummary, summary="回退待审核风险规则", - description="高级管理人员将待审核风险规则回退到草稿,并记录回退原因。", + description="高级财务人员将待审核风险规则回退到草稿,并记录回退原因。", ) def return_agent_asset_risk_rule( asset_id: str, @@ -822,7 +822,7 @@ def return_agent_asset_risk_rule( "/{asset_id}/publish", response_model=AgentAssetRead, summary="审核并发布风险规则", - description="高级管理人员确认测试通过后,将待审核风险规则一次性审核通过并发布上线。", + description="高级财务人员确认测试通过后,将待审核风险规则一次性审核通过并发布上线。", ) def publish_agent_asset_risk_rule( asset_id: str, diff --git a/server/src/app/api/v1/endpoints/budgets.py b/server/src/app/api/v1/endpoints/budgets.py new file mode 100644 index 0000000..c91ed2e --- /dev/null +++ b/server/src/app/api/v1/endpoints/budgets.py @@ -0,0 +1,338 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import func, or_, select +from sqlalchemy.orm import selectinload +from sqlalchemy.orm import Session + +from app.api.deps import ( + CurrentUserContext, + get_current_user, + get_db, + is_budget_scope_limited_user, + require_budget_editor_user, + require_budget_viewer_user, +) +from app.models.employee import Employee +from app.models.budget import BudgetAllocation +from app.schemas.budget import ( + BudgetAllocationCreate, + BudgetAllocationRead, + BudgetCheckRead, + BudgetCheckRequest, + BudgetOperationRead, + BudgetOperationRequest, + BudgetSummaryRead, + BudgetTransactionRead, +) +from app.schemas.common import ErrorResponse +from app.services.budget import BudgetControlError, BudgetService + +router = APIRouter(prefix="/budgets") +DbSession = Annotated[Session, Depends(get_db)] +CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)] +BudgetViewer = Annotated[CurrentUserContext, Depends(require_budget_viewer_user)] +BudgetEditor = Annotated[CurrentUserContext, Depends(require_budget_editor_user)] + + +@router.get( + "/summary", + response_model=BudgetSummaryRead, + summary="读取预算中心汇总", +) +def get_budget_summary( + db: DbSession, + current_user: BudgetViewer, + fiscal_year: Annotated[int | None, Query(alias="year")] = None, + period_key: Annotated[str | None, Query(alias="period")] = None, + department_id: str | None = None, + department_name: str | None = None, + cost_center: str | None = None, +) -> BudgetSummaryRead: + scope = _resolve_budget_query_scope( + db, + current_user, + department_id=department_id, + department_name=department_name, + cost_center=cost_center, + ) + return BudgetService(db).get_summary( + fiscal_year=fiscal_year, + period_key=period_key, + **scope, + ) + + +@router.get( + "/allocations", + response_model=list[BudgetAllocationRead], + summary="查询预算额度列表", +) +def list_budget_allocations( + db: DbSession, + current_user: BudgetViewer, + fiscal_year: Annotated[int | None, Query(alias="year")] = None, + period_key: Annotated[str | None, Query(alias="period")] = None, + department_id: str | None = None, + department_name: str | None = None, + cost_center: str | None = None, +) -> list[BudgetAllocationRead]: + scope = _resolve_budget_query_scope( + db, + current_user, + department_id=department_id, + department_name=department_name, + cost_center=cost_center, + ) + return BudgetService(db).list_allocations( + fiscal_year=fiscal_year, + period_key=period_key, + **scope, + ) + + +@router.post( + "/allocations", + response_model=BudgetAllocationRead, + status_code=status.HTTP_201_CREATED, + summary="创建或更新预算额度", + responses={status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse}}, +) +def create_budget_allocation( + payload: BudgetAllocationCreate, + db: DbSession, + current_user: BudgetEditor, +) -> BudgetAllocationRead: + try: + allocation = BudgetService(db).create_or_update_allocation( + payload, + operator=current_user.name or current_user.username, + ) + db.commit() + return allocation + except ValueError as error: + db.rollback() + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error + + +@router.get( + "/allocations/{allocation_id}/transactions", + response_model=list[BudgetTransactionRead], + summary="读取预算交易台账", +) +def list_budget_transactions( + allocation_id: str, + db: DbSession, + current_user: BudgetViewer, +) -> list[BudgetTransactionRead]: + allocation = BudgetService(db).get_allocation_row(allocation_id) + if allocation is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="预算额度不存在。") + if not _allocation_visible_to_user(db, current_user, allocation): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能查看其他部门预算流水。") + return BudgetService(db).list_transactions(allocation_id) + + +@router.post( + "/check", + response_model=BudgetCheckRead, + summary="校验预算可用余额", +) +def check_budget(payload: BudgetCheckRequest, db: DbSession, current_user: BudgetViewer) -> BudgetCheckRead: + scope = _resolve_budget_query_scope( + db, + current_user, + department_id=payload.department_id, + department_name=payload.department_name, + cost_center=payload.cost_center, + ) + scoped_payload = payload.model_copy(update=scope) + return BudgetService(db).check(scoped_payload) + + +@router.post( + "/reserve", + response_model=BudgetOperationRead, + summary="记录预算预占台账", + responses={status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse}}, +) +def reserve_budget( + payload: BudgetOperationRequest, + db: DbSession, + current_user: BudgetEditor, +) -> BudgetOperationRead: + return _execute_budget_operation( + payload, + db=db, + current_user=current_user, + transaction_type="reserve", + ) + + +@router.post( + "/release", + response_model=BudgetOperationRead, + summary="记录预算释放台账", + responses={status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse}}, +) +def release_budget( + payload: BudgetOperationRequest, + db: DbSession, + current_user: BudgetEditor, +) -> BudgetOperationRead: + return _execute_budget_operation( + payload, + db=db, + current_user=current_user, + transaction_type="release", + ) + + +@router.post( + "/consume", + response_model=BudgetOperationRead, + summary="记录预算核销台账", + responses={status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse}}, +) +def consume_budget( + payload: BudgetOperationRequest, + db: DbSession, + current_user: BudgetEditor, +) -> BudgetOperationRead: + return _execute_budget_operation( + payload, + db=db, + current_user=current_user, + transaction_type="consume", + ) + + +@router.post( + "/rollback", + response_model=BudgetOperationRead, + summary="记录预算核销回滚台账", + responses={status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse}}, +) +def rollback_budget( + payload: BudgetOperationRequest, + db: DbSession, + current_user: BudgetEditor, +) -> BudgetOperationRead: + return _execute_budget_operation( + payload, + db=db, + current_user=current_user, + transaction_type="rollback", + ) + + +def _execute_budget_operation( + payload: BudgetOperationRequest, + *, + db: Session, + current_user: CurrentUserContext, + transaction_type: str, +) -> BudgetOperationRead: + try: + result = BudgetService(db).execute_operation( + payload, + transaction_type=transaction_type, + operator=current_user.name or current_user.username, + ) + db.commit() + return result + except BudgetControlError as error: + db.rollback() + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error + except ValueError as error: + db.rollback() + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error + + +def _resolve_budget_query_scope( + db: Session, + current_user: CurrentUserContext, + *, + department_id: str | None = None, + department_name: str | None = None, + cost_center: str | None = None, +) -> dict[str, str | None]: + if not is_budget_scope_limited_user(current_user): + return { + "department_id": _blank_to_none(department_id), + "department_name": _blank_to_none(department_name), + "cost_center": _blank_to_none(cost_center), + } + + employee = _resolve_current_employee(db, current_user) + scoped_cost_center = ( + _blank_to_none(current_user.cost_center) + or _blank_to_none(getattr(employee, "cost_center", None)) + or _blank_to_none(getattr(getattr(employee, "organization_unit", None), "cost_center", None)) + ) + scoped_department_name = ( + _blank_to_none(current_user.department_name) + or _blank_to_none(getattr(getattr(employee, "organization_unit", None), "name", None)) + ) + + if not scoped_cost_center and not scoped_department_name: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="预算监控员缺少部门归属,不能查看预算中心。", + ) + + return { + "department_id": None, + "department_name": None if scoped_cost_center else scoped_department_name, + "cost_center": scoped_cost_center, + } + + +def _allocation_visible_to_user( + db: Session, + current_user: CurrentUserContext, + allocation: BudgetAllocation, +) -> bool: + if not is_budget_scope_limited_user(current_user): + return True + + scope = _resolve_budget_query_scope(db, current_user) + scoped_cost_center = scope.get("cost_center") + scoped_department_name = scope.get("department_name") + if scoped_cost_center: + return str(allocation.cost_center or "").strip() == scoped_cost_center + if scoped_department_name: + return str(allocation.department_name or "").strip() == scoped_department_name + return False + + +def _resolve_current_employee(db: Session, current_user: CurrentUserContext) -> Employee | None: + identities = [ + str(current_user.username or "").strip(), + str(current_user.name or "").strip(), + ] + identities = [item for item in dict.fromkeys(identities) if item] + if not identities: + return None + + lowered = [item.lower() for item in identities] + stmt = ( + select(Employee) + .options(selectinload(Employee.organization_unit)) + .where( + or_( + func.lower(Employee.email).in_(lowered), + Employee.employee_no.in_(identities), + Employee.name.in_(identities), + ) + ) + .limit(1) + ) + return db.scalars(stmt).first() + + +def _blank_to_none(value: str | None) -> str | None: + text = str(value or "").strip() + return text or None diff --git a/server/src/app/api/v1/endpoints/reimbursements.py b/server/src/app/api/v1/endpoints/reimbursements.py index 38f2eca..65b002c 100644 --- a/server/src/app/api/v1/endpoints/reimbursements.py +++ b/server/src/app/api/v1/endpoints/reimbursements.py @@ -505,7 +505,7 @@ def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser "/claims/{claim_id}/return", response_model=ExpenseClaimRead, summary="退回报销单", - description="财务人员、高级管理人员或当前审批人可将可见报销单退回到待提交状态。", + description="财务人员、高级财务人员或当前审批人可将可见报销单退回到待提交状态。", responses={ status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, @@ -571,7 +571,7 @@ def approve_expense_claim( "/claims/{claim_id}", response_model=ExpenseClaimActionResponse, summary="删除报销单", - description="申请人仅可删除自己的草稿、待补充或退回单据;高级管理人员可删除可见的非归档单据;已归档单据仅高级管理员可删除,财务人员没有删除权限。", + description="申请人仅可删除自己的草稿、待补充或退回单据;高级财务人员可删除可见的非归档单据;已归档单据仅高级管理员可删除,财务人员没有删除权限。", responses={ status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, diff --git a/server/src/app/api/v1/router.py b/server/src/app/api/v1/router.py index 391d2cb..5dbab1c 100644 --- a/server/src/app/api/v1/router.py +++ b/server/src/app/api/v1/router.py @@ -5,6 +5,7 @@ from app.api.v1.endpoints.agent_runs import router as agent_runs_router from app.api.v1.endpoints.audit_logs import router as audit_logs_router from app.api.v1.endpoints.auth import router as auth_router from app.api.v1.endpoints.bootstrap import router as bootstrap_router +from app.api.v1.endpoints.budgets import router as budgets_router from app.api.v1.endpoints.employees import router as employees_router from app.api.v1.endpoints.health import router as health_router from app.api.v1.endpoints.knowledge import router as knowledge_router @@ -19,6 +20,7 @@ router = APIRouter() router.include_router(health_router, tags=["health"]) router.include_router(bootstrap_router, tags=["bootstrap"]) router.include_router(auth_router, tags=["auth"]) +router.include_router(budgets_router, tags=["budgets"]) router.include_router(agent_assets_router, tags=["agent-assets"]) router.include_router(agent_runs_router, tags=["agent-runs"]) router.include_router(audit_logs_router, tags=["audit-logs"]) diff --git a/server/src/app/db/base.py b/server/src/app/db/base.py index fddcc46..a886a5f 100644 --- a/server/src/app/db/base.py +++ b/server/src/app/db/base.py @@ -4,6 +4,7 @@ from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetTestR from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog from app.models.approval import ApprovalRecord from app.models.audit_log import AuditLog +from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction from app.models.employee_change_log import EmployeeChangeLog from app.models.employee import Employee from app.models.financial_record import ( @@ -33,6 +34,9 @@ __all__ = [ "AgentToolCall", "ApprovalRecord", "AuditLog", + "BudgetAllocation", + "BudgetReservation", + "BudgetTransaction", "Employee", "EmployeeChangeLog", "ExpenseClaim", diff --git a/server/src/app/models/__init__.py b/server/src/app/models/__init__.py index f3b07a1..b14eb61 100644 --- a/server/src/app/models/__init__.py +++ b/server/src/app/models/__init__.py @@ -3,6 +3,7 @@ from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersi from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog from app.models.approval import ApprovalRecord from app.models.audit_log import AuditLog +from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction from app.models.employee_change_log import EmployeeChangeLog from app.models.employee import Employee from app.models.financial_record import ( @@ -32,6 +33,9 @@ __all__ = [ "AgentToolCall", "ApprovalRecord", "AuditLog", + "BudgetAllocation", + "BudgetReservation", + "BudgetTransaction", "Employee", "EmployeeChangeLog", "ExpenseClaim", diff --git a/server/src/app/models/budget.py b/server/src/app/models/budget.py new file mode 100644 index 0000000..13e1fb1 --- /dev/null +++ b/server/src/app/models/budget.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import DateTime, ForeignKey, Index, Numeric, String, Text, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.types import JSON + +from app.db.base_class import Base + + +class BudgetAllocation(Base): + __tablename__ = "budget_allocations" + __table_args__ = ( + UniqueConstraint( + "fiscal_year", + "period_key", + "department_id", + "cost_center", + "project_code", + "subject_code", + name="uq_budget_allocation_dimension", + ), + Index("ix_budget_allocations_dimension", "fiscal_year", "period_key", "subject_code"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + budget_no: Mapped[str] = mapped_column(String(50), unique=True, index=True) + fiscal_year: Mapped[int] = mapped_column(index=True) + period_type: Mapped[str] = mapped_column(String(20), default="quarter", index=True) + period_key: Mapped[str] = mapped_column(String(30), index=True) + department_id: Mapped[str | None] = mapped_column( + ForeignKey("organization_units.id"), nullable=True, index=True + ) + department_name: Mapped[str] = mapped_column(String(100), index=True) + cost_center: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True) + project_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True) + subject_code: Mapped[str] = mapped_column(String(50), index=True) + subject_name: Mapped[str] = mapped_column(String(100)) + original_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=Decimal("0.00")) + adjusted_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=Decimal("0.00")) + status: Mapped[str] = mapped_column(String(30), default="active", index=True) + warning_threshold: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("80.00")) + control_action: Mapped[str] = mapped_column(String(30), default="block") + description: Mapped[str | None] = mapped_column(Text(), nullable=True) + created_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + updated_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + transactions = relationship("BudgetTransaction", back_populates="allocation") + reservations = relationship("BudgetReservation", back_populates="allocation") + + +class BudgetReservation(Base): + __tablename__ = "budget_reservations" + __table_args__ = ( + Index("ix_budget_reservations_source", "source_type", "source_id"), + Index("ix_budget_reservations_status", "allocation_id", "source_status"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + reservation_no: Mapped[str] = mapped_column(String(50), unique=True, index=True) + allocation_id: Mapped[str] = mapped_column(ForeignKey("budget_allocations.id"), index=True) + source_type: Mapped[str] = mapped_column(String(40), index=True) + source_id: Mapped[str] = mapped_column(String(64), index=True) + source_no: Mapped[str] = mapped_column(String(80), index=True) + source_status: Mapped[str] = mapped_column(String(30), default="active", index=True) + amount: Mapped[Decimal] = mapped_column(Numeric(14, 2)) + consumed_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=Decimal("0.00")) + released_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=Decimal("0.00")) + context_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + released_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + allocation = relationship("BudgetAllocation", back_populates="reservations") + transactions = relationship("BudgetTransaction", back_populates="reservation") + + +class BudgetTransaction(Base): + __tablename__ = "budget_transactions" + __table_args__ = ( + Index("ix_budget_transactions_allocation_created", "allocation_id", "created_at"), + Index("ix_budget_transactions_source", "source_type", "source_id"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + transaction_no: Mapped[str] = mapped_column(String(50), unique=True, index=True) + allocation_id: Mapped[str] = mapped_column(ForeignKey("budget_allocations.id"), index=True) + reservation_id: Mapped[str | None] = mapped_column( + ForeignKey("budget_reservations.id"), nullable=True, index=True + ) + source_type: Mapped[str] = mapped_column(String(40), index=True) + source_id: Mapped[str] = mapped_column(String(64), index=True) + source_no: Mapped[str] = mapped_column(String(80), index=True) + transaction_type: Mapped[str] = mapped_column(String(30), index=True) + amount: Mapped[Decimal] = mapped_column(Numeric(14, 2)) + before_available_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2)) + after_available_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2)) + operator: Mapped[str | None] = mapped_column(String(100), nullable=True) + reason: Mapped[str | None] = mapped_column(Text(), nullable=True) + context_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + allocation = relationship("BudgetAllocation", back_populates="transactions") + reservation = relationship("BudgetReservation", back_populates="transactions") diff --git a/server/src/app/schemas/budget.py b/server/src/app/schemas/budget.py new file mode 100644 index 0000000..ecbebf0 --- /dev/null +++ b/server/src/app/schemas/budget.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, Field + + +class BudgetAllocationCreate(BaseModel): + fiscal_year: int = Field(ge=2000, le=2100) + period_type: str = Field(default="quarter", max_length=20) + period_key: str = Field(min_length=1, max_length=30) + department_id: str | None = Field(default=None, max_length=36) + department_name: str = Field(min_length=1, max_length=100) + cost_center: str | None = Field(default=None, max_length=50) + project_code: str | None = Field(default=None, max_length=50) + subject_code: str = Field(min_length=1, max_length=50) + subject_name: str = Field(min_length=1, max_length=100) + original_amount: Decimal = Field(ge=0) + warning_threshold: Decimal = Field(default=Decimal("80.00"), ge=0, le=100) + control_action: str = Field(default="block", max_length=30) + description: str | None = Field(default=None, max_length=500) + + +class BudgetCheckRequest(BaseModel): + fiscal_year: int | None = Field(default=None, ge=2000, le=2100) + period_key: str | None = Field(default=None, max_length=30) + department_id: str | None = Field(default=None, max_length=36) + department_name: str | None = Field(default=None, max_length=100) + cost_center: str | None = Field(default=None, max_length=50) + project_code: str | None = Field(default=None, max_length=50) + subject_code: str = Field(min_length=1, max_length=50) + amount: Decimal = Field(ge=0) + + +class BudgetOperationRequest(BudgetCheckRequest): + source_type: str = Field(min_length=1, max_length=40) + source_id: str = Field(min_length=1, max_length=64) + source_no: str = Field(min_length=1, max_length=80) + reason: str | None = Field(default=None, max_length=500) + + +class BudgetBalanceRead(BaseModel): + total_amount: Decimal + reserved_amount: Decimal + consumed_amount: Decimal + available_amount: Decimal + usage_rate: Decimal + + +class BudgetAllocationRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + budget_no: str + fiscal_year: int + period_type: str + period_key: str + department_id: str | None + department_name: str + cost_center: str | None + project_code: str | None + subject_code: str + subject_name: str + original_amount: Decimal + adjusted_amount: Decimal + status: str + warning_threshold: Decimal + control_action: str + description: str | None = None + balance: BudgetBalanceRead + created_at: datetime + updated_at: datetime + + +class BudgetTransactionRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + transaction_no: str + allocation_id: str + reservation_id: str | None + source_type: str + source_id: str + source_no: str + transaction_type: str + amount: Decimal + before_available_amount: Decimal + after_available_amount: Decimal + operator: str | None + reason: str | None + created_at: datetime + + +class BudgetSummaryRead(BaseModel): + fiscal_year: int | None = None + period_key: str | None = None + total_amount: Decimal + reserved_amount: Decimal + consumed_amount: Decimal + available_amount: Decimal + warning_count: int + over_budget_count: int + allocations: list[BudgetAllocationRead] = Field(default_factory=list) + + +class BudgetCheckRead(BaseModel): + passed: bool + blocking_reasons: list[str] = Field(default_factory=list) + flags: list[dict] = Field(default_factory=list) + allocation: BudgetAllocationRead | None = None + + +class BudgetOperationRead(BaseModel): + ok: bool + message: str + reservation_id: str | None = None + allocation: BudgetAllocationRead | None = None + transaction: BudgetTransactionRead | None = None + flags: list[dict] = Field(default_factory=list) diff --git a/server/src/app/schemas/settings.py b/server/src/app/schemas/settings.py index a6e93d1..a3554ac 100644 --- a/server/src/app/schemas/settings.py +++ b/server/src/app/schemas/settings.py @@ -23,7 +23,7 @@ class SettingsCompanyForm(BaseModel): class SettingsAdminForm(BaseModel): adminAccount: str = Field(min_length=1, max_length=120) - adminEmail: str = Field(min_length=1, max_length=255) + adminEmail: str = Field(default="", max_length=255) newPassword: str = Field(default="", max_length=128) confirmPassword: str = Field(default="", max_length=128) sessionTimeout: int = Field(default=30, ge=5, le=240) diff --git a/server/src/app/services/agent_asset_spreadsheet.py b/server/src/app/services/agent_asset_spreadsheet.py index bbc477f..fbbaa60 100644 --- a/server/src/app/services/agent_asset_spreadsheet.py +++ b/server/src/app/services/agent_asset_spreadsheet.py @@ -342,6 +342,10 @@ class AgentAssetSpreadsheetManager: ] ) + @staticmethod + def build_rule_workbook(sheets: list[tuple[str, list[list[object]]]]) -> bytes: + return _build_xlsx_bytes(sheets) + @staticmethod def build_blank_rule_workbook(sheet_name: str = "规则配置") -> bytes: return _build_xlsx_bytes([(sheet_name, [[""]])]) diff --git a/server/src/app/services/agent_foundation_asset_seed.py b/server/src/app/services/agent_foundation_asset_seed.py index 5708d75..70b8dda 100644 --- a/server/src/app/services/agent_foundation_asset_seed.py +++ b/server/src/app/services/agent_foundation_asset_seed.py @@ -1,12 +1,8 @@ from __future__ import annotations -import hashlib -import json -from datetime import UTC, date, datetime -from decimal import Decimal -from pathlib import Path +from datetime import UTC, datetime -from sqlalchemy import inspect, select, text +from sqlalchemy import select from app.core.agent_enums import ( AgentAssetContentType, @@ -14,34 +10,15 @@ from app.core.agent_enums import ( AgentAssetStatus, AgentAssetType, AgentName, - AgentPermissionLevel, AgentReviewStatus, - AgentRunSource, - AgentRunStatus, - AgentToolType, ) +from app.core.logging import get_logger from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion -from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog -from app.models.audit_log import AuditLog -from app.models.financial_record import ( - AccountsPayableRecord, - AccountsReceivableRecord, - ExpenseClaim, - ExpenseClaimItem, -) -from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager from app.services.agent_asset_spreadsheet import ( - AgentAssetSpreadsheetManager, COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, - COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, COMPANY_TRAVEL_EXPENSE_RULE_CODE, - COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, FINANCE_RULES_LIBRARY, - RISK_RULES_LIBRARY, -) -from app.services.expense_rule_runtime import ( - build_scene_submission_standard_markdown, - build_travel_risk_control_standard_markdown, + AgentAssetSpreadsheetManager, ) from app.services.agent_foundation_constants import ( ATTACHMENT_RULE_ASSET_CODE, @@ -50,13 +27,7 @@ from app.services.agent_foundation_constants import ( COMPANY_COMMUNICATION_RULE_VERSION, COMPANY_TRAVEL_RULE_SCENARIO_JSON, COMPANY_TRAVEL_RULE_VERSION, - DEMO_EXPENSE_CLAIM_SIGNATURES, - DEMO_PAYABLE_SIGNATURES, - DEMO_RECEIVABLE_SIGNATURES, - LEGACY_RULE_CODES, - PLATFORM_DESTINATION_LOCATION_RULE_FILENAME, ) -from app.core.logging import get_logger logger = get_logger("app.services.agent_foundation") @@ -352,6 +323,8 @@ class AgentFoundationAssetSeedMixin: actor_name="系统初始化", ) + self._hide_deprecated_finance_rule_assets() + self.db.add_all( [ AgentAssetVersion( diff --git a/server/src/app/services/agent_foundation_asset_topup.py b/server/src/app/services/agent_foundation_asset_topup.py index 9694ca5..711b490 100644 --- a/server/src/app/services/agent_foundation_asset_topup.py +++ b/server/src/app/services/agent_foundation_asset_topup.py @@ -1,12 +1,8 @@ from __future__ import annotations -import hashlib -import json -from datetime import UTC, date, datetime -from decimal import Decimal -from pathlib import Path +from datetime import UTC, datetime -from sqlalchemy import inspect, select, text +from sqlalchemy import select from app.core.agent_enums import ( AgentAssetContentType, @@ -14,34 +10,15 @@ from app.core.agent_enums import ( AgentAssetStatus, AgentAssetType, AgentName, - AgentPermissionLevel, AgentReviewStatus, - AgentRunSource, - AgentRunStatus, - AgentToolType, ) -from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion -from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog -from app.models.audit_log import AuditLog -from app.models.financial_record import ( - AccountsPayableRecord, - AccountsReceivableRecord, - ExpenseClaim, - ExpenseClaimItem, -) -from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager +from app.core.logging import get_logger +from app.models.agent_asset import AgentAsset from app.services.agent_asset_spreadsheet import ( - AgentAssetSpreadsheetManager, COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, - COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, COMPANY_TRAVEL_EXPENSE_RULE_CODE, - COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, FINANCE_RULES_LIBRARY, - RISK_RULES_LIBRARY, -) -from app.services.expense_rule_runtime import ( - build_scene_submission_standard_markdown, - build_travel_risk_control_standard_markdown, + AgentAssetSpreadsheetManager, ) from app.services.agent_foundation_constants import ( ATTACHMENT_RULE_ASSET_CODE, @@ -50,13 +27,7 @@ from app.services.agent_foundation_constants import ( COMPANY_COMMUNICATION_RULE_VERSION, COMPANY_TRAVEL_RULE_SCENARIO_JSON, COMPANY_TRAVEL_RULE_VERSION, - DEMO_EXPENSE_CLAIM_SIGNATURES, - DEMO_PAYABLE_SIGNATURES, - DEMO_RECEIVABLE_SIGNATURES, - LEGACY_RULE_CODES, - PLATFORM_DESTINATION_LOCATION_RULE_FILENAME, ) -from app.core.logging import get_logger logger = get_logger("app.services.agent_foundation") @@ -509,6 +480,8 @@ class AgentFoundationAssetTopUpMixin: reviewed_at=datetime.now(UTC), ) + self._hide_deprecated_finance_rule_assets() + if "skill.ar.aging_summary" not in existing_codes: asset = self._create_seed_asset( diff --git a/server/src/app/services/agent_foundation_constants.py b/server/src/app/services/agent_foundation_constants.py index 9eb1663..706fbef 100644 --- a/server/src/app/services/agent_foundation_constants.py +++ b/server/src/app/services/agent_foundation_constants.py @@ -82,9 +82,9 @@ COMPANY_TRAVEL_RULE_VERSION = "v1.0.0" COMPANY_COMMUNICATION_RULE_VERSION = "v1.0.0" -COMPANY_TRAVEL_RULE_SCENARIO_JSON = ("差旅",) +COMPANY_TRAVEL_RULE_SCENARIO_JSON = ("差旅费",) -COMPANY_COMMUNICATION_RULE_SCENARIO_JSON = ("费用科目",) +COMPANY_COMMUNICATION_RULE_SCENARIO_JSON = ("通信费",) ATTACHMENT_RULE_RUNTIME_CONFIG = { diff --git a/server/src/app/services/agent_foundation_risk_rules.py b/server/src/app/services/agent_foundation_risk_rules.py index af24651..fa9dd7d 100644 --- a/server/src/app/services/agent_foundation_risk_rules.py +++ b/server/src/app/services/agent_foundation_risk_rules.py @@ -23,6 +23,21 @@ from app.services.agent_foundation_constants import ( logger = get_logger("app.services.agent_foundation") +EXPENSE_TYPE_SCENARIO_LABELS = { + "travel": "差旅费", + "hotel": "住宿费", + "transport": "交通费", + "meal": "业务招待费", + "meeting": "会务费", + "marketing": "市场推广费", + "office": "办公用品费", + "training": "培训费", + "software": "软件服务费", + "communication": "通信费", + "welfare": "福利费", +} + + class AgentFoundationRiskRuleMixin: def _iter_platform_risk_manifests(self) -> list[tuple[str, dict[str, object]]]: @@ -123,8 +138,54 @@ class AgentFoundationRiskRuleMixin: return "通用" + @staticmethod + def _resolve_manifest_expense_types(manifest: dict[str, object]) -> list[str]: + def _collect(value: object) -> list[str]: + if isinstance(value, str): + return [value] + if isinstance(value, (list, tuple, set)): + return [str(item or "").strip() for item in value] + return [] + + candidates: list[str] = [] + candidates.extend(_collect(manifest.get("expense_types"))) + + applies_to = ( + manifest.get("applies_to") if isinstance(manifest.get("applies_to"), dict) else {} + ) + candidates.extend(_collect(applies_to.get("expense_types"))) + + metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {} + candidates.extend(_collect(metadata.get("expense_types"))) + + normalized: list[str] = [] + seen: set[str] = set() + for item in candidates: + value = item.strip().lower() + if not value or value in seen: + continue + seen.add(value) + normalized.append(value) + return normalized + + @staticmethod + def _expense_type_scenario_labels(expense_types: list[str]) -> list[str]: + labels: list[str] = [] + seen: set[str] = set() + for expense_type in expense_types: + label = EXPENSE_TYPE_SCENARIO_LABELS.get(expense_type) + if not label or label in seen: + continue + seen.add(label) + labels.append(label) + return labels + def _platform_risk_scenario_json(self, manifest: dict[str, object]) -> list[str]: + labels = self._expense_type_scenario_labels(self._resolve_manifest_expense_types(manifest)) + if labels: + return labels + category = self._resolve_platform_risk_category(manifest) return [category] if category else ["通用"] @@ -139,7 +200,7 @@ class AgentFoundationRiskRuleMixin: risk_category = self._resolve_platform_risk_category(manifest) - return { + config = { "severity": str(fail_outcome.get("severity") or "medium"), @@ -176,6 +237,20 @@ class AgentFoundationRiskRuleMixin: ), } + metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {} + for key in ( + "finance_rule_code", + "finance_rule_sheet", + "business_stage", + "expense_types", + "budget_required", + ): + value = manifest.get(key) + if value is None and isinstance(metadata, dict): + value = metadata.get(key) + if value is not None: + config[key] = value + return config def _build_platform_risk_seed_assets(self) -> list[AgentAsset]: @@ -242,6 +317,12 @@ class AgentFoundationRiskRuleMixin: before_count = len(existing_codes) self._ensure_platform_risk_rules_from_library(existing_codes) + manifest_codes = { + str(manifest.get("rule_code") or "").strip() + for _, manifest in self._iter_platform_risk_manifests() + if str(manifest.get("rule_code") or "").strip() + } + self._hide_stale_demo_risk_rules(manifest_codes) self.db.flush() @@ -265,6 +346,25 @@ class AgentFoundationRiskRuleMixin: return manifest_count + def _hide_stale_demo_risk_rules(self, manifest_codes: set[str]) -> None: + assets = self.db.scalars( + select(AgentAsset).where(AgentAsset.asset_type == AgentAssetType.RULE.value) + ).all() + for asset in assets: + config = asset.config_json if isinstance(asset.config_json, dict) else {} + if config.get("source_ref") != "费用管控 Demo 风险规则库": + continue + if asset.code in manifest_codes: + continue + asset.status = AgentAssetStatus.DISABLED.value + asset.config_json = { + **config, + "enabled": False, + "tag": "废弃风险规则", + "deprecated": True, + "deprecated_reason": "对应风险规则 JSON 已删除,不再参与费用管控 Demo。", + } + def _ensure_platform_risk_rules_from_library(self, existing_codes: set[str]) -> None: for file_name, manifest in self._iter_platform_risk_manifests(): diff --git a/server/src/app/services/agent_foundation_spreadsheets.py b/server/src/app/services/agent_foundation_spreadsheets.py index 8701695..16d43a3 100644 --- a/server/src/app/services/agent_foundation_spreadsheets.py +++ b/server/src/app/services/agent_foundation_spreadsheets.py @@ -1,66 +1,115 @@ from __future__ import annotations -import hashlib -import json -from datetime import UTC, date, datetime -from decimal import Decimal from pathlib import Path -from sqlalchemy import inspect, select, text +from sqlalchemy import select from app.core.agent_enums import ( - AgentAssetContentType, - AgentAssetDomain, AgentAssetStatus, - AgentAssetType, - AgentName, - AgentPermissionLevel, - AgentReviewStatus, - AgentRunSource, - AgentRunStatus, - AgentToolType, ) -from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion -from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog -from app.models.audit_log import AuditLog -from app.models.financial_record import ( - AccountsPayableRecord, - AccountsReceivableRecord, - ExpenseClaim, - ExpenseClaimItem, -) -from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager +from app.core.logging import get_logger +from app.models.agent_asset import AgentAsset from app.services.agent_asset_spreadsheet import ( - AgentAssetSpreadsheetManager, COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, COMPANY_TRAVEL_EXPENSE_RULE_CODE, COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, FINANCE_RULES_LIBRARY, - RISK_RULES_LIBRARY, -) -from app.services.expense_rule_runtime import ( - build_scene_submission_standard_markdown, - build_travel_risk_control_standard_markdown, + AgentAssetSpreadsheetManager, ) from app.services.agent_foundation_constants import ( - ATTACHMENT_RULE_ASSET_CODE, - ATTACHMENT_RULE_RUNTIME_CONFIG, COMPANY_COMMUNICATION_RULE_SCENARIO_JSON, - COMPANY_COMMUNICATION_RULE_VERSION, COMPANY_TRAVEL_RULE_SCENARIO_JSON, - COMPANY_TRAVEL_RULE_VERSION, - DEMO_EXPENSE_CLAIM_SIGNATURES, - DEMO_PAYABLE_SIGNATURES, - DEMO_RECEIVABLE_SIGNATURES, - LEGACY_RULE_CODES, - PLATFORM_DESTINATION_LOCATION_RULE_FILENAME, ) -from app.core.logging import get_logger +from app.services.finance_rule_catalog import ( + DEPRECATED_FINANCE_RULE_CODES, + DEPRECATED_FINANCE_RULE_REPLACEMENTS, +) logger = get_logger("app.services.agent_foundation") + class AgentFoundationSpreadsheetMixin: + def sync_finance_rule_assets_from_catalog(self) -> int: + synced_count = self._ensure_core_finance_rule_asset_metadata() + self._hide_deprecated_finance_rule_assets() + self.db.flush() + return synced_count + + def _ensure_core_finance_rule_asset_metadata(self) -> int: + synced_count = 0 + synced_count += int( + self._ensure_core_finance_rule_asset( + code=COMPANY_TRAVEL_EXPENSE_RULE_CODE, + scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0], + finance_rule_sheet="差旅住宿费标准", + expense_types=["travel", "hotel", "transport"], + ) + ) + synced_count += int( + self._ensure_core_finance_rule_asset( + code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, + scenario_category=COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0], + finance_rule_sheet="通信费报销标准", + expense_types=["communication"], + ) + ) + return synced_count + + def _ensure_core_finance_rule_asset( + self, + *, + code: str, + scenario_category: str, + finance_rule_sheet: str, + expense_types: list[str], + ) -> bool: + asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code)) + if asset is None: + return False + asset.scenario_json = [scenario_category] + asset.config_json = { + **(asset.config_json or {}), + "enabled": True, + "tag": "财务规则", + "detail_mode": "spreadsheet", + "rule_library": FINANCE_RULES_LIBRARY, + "scenario_category": scenario_category, + "ai_review_category": scenario_category, + "finance_rule_code": code, + "finance_rule_sheet": finance_rule_sheet, + "expense_types": expense_types, + "business_stage": ["expense_application", "reimbursement"], + "budget_required": True, + } + return True + + def _hide_deprecated_finance_rule_assets(self) -> None: + for code in DEPRECATED_FINANCE_RULE_CODES: + asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code)) + if asset is None: + continue + asset.status = AgentAssetStatus.DISABLED.value + asset.scenario_json = ["已废弃"] + replacement = DEPRECATED_FINANCE_RULE_REPLACEMENTS.get(code) + deprecated_reason = ( + "交通/住宿细分并入公司差旅费报销规则,不再作为独立财务规则展示。" + if replacement + else ( + "该费用类型没有独立职务金额分档,额度控制转入预算中心," + "不再作为独立财务规则表展示。" + ) + ) + asset.config_json = { + **(asset.config_json or {}), + "enabled": False, + "tag": "废弃规则", + "deprecated": True, + "deprecated_reason": deprecated_reason, + } + if replacement: + asset.config_json["replaced_by"] = replacement + def _ensure_company_travel_rule_spreadsheet_seed( self, @@ -251,6 +300,8 @@ class AgentFoundationSpreadsheetMixin: fallback_sheet_name: str, + workbook_sheets: list[tuple[str, list[list[object]]]] | None = None, + ): manager = AgentAssetSpreadsheetManager() @@ -271,6 +322,8 @@ class AgentFoundationSpreadsheetMixin: fallback_sheet_name=fallback_sheet_name, + workbook_sheets=workbook_sheets, + ), actor_name=actor_name, @@ -379,6 +432,8 @@ class AgentFoundationSpreadsheetMixin: fallback_sheet_name: str, + workbook_sheets: list[tuple[str, list[list[object]]]] | None = None, + ) -> bytes: live_key = ( @@ -397,4 +452,8 @@ class AgentFoundationSpreadsheetMixin: return live_path.read_bytes() + if workbook_sheets is not None: + + return AgentAssetSpreadsheetManager.build_rule_workbook(workbook_sheets) + return AgentAssetSpreadsheetManager.build_blank_rule_workbook(fallback_sheet_name) diff --git a/server/src/app/services/auth.py b/server/src/app/services/auth.py index 13fad71..7d14e01 100644 --- a/server/src/app/services/auth.py +++ b/server/src/app/services/auth.py @@ -22,9 +22,10 @@ logger = get_logger("app.services.auth") ROLE_LABELS = { "manager": "管理员", "finance": "财务人员", - "executive": "高级管理人员", + "executive": "高级财务人员", "approver": "审批负责人", - "auditor": "审计观察员", + "budget_monitor": "预算监控员", + "auditor": "预算监控员", "user": "使用者", } diff --git a/server/src/app/services/budget.py b/server/src/app/services/budget.py new file mode 100644 index 0000000..23004bf --- /dev/null +++ b/server/src/app/services/budget.py @@ -0,0 +1,776 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db.base import Base +from app.models.budget import BudgetAllocation, BudgetReservation +from app.models.financial_record import ExpenseClaim +from app.schemas.budget import ( + BudgetAllocationCreate, + BudgetAllocationRead, + BudgetCheckRead, + BudgetCheckRequest, + BudgetOperationRequest, + BudgetOperationRead, + BudgetSummaryRead, + BudgetTransactionRead, +) +from app.services.budget_support import BudgetSupportMixin +from app.services.budget_types import BudgetControlError, SUPPORTED_BUDGET_SUBJECT_CODES + + +class BudgetService(BudgetSupportMixin): + def __init__(self, db: Session) -> None: + self.db = db + + def ensure_budget_ready(self) -> None: + Base.metadata.create_all(bind=self.db.get_bind()) + exists = self.db.scalar(select(BudgetAllocation.id).limit(1)) + if exists: + return + self._seed_default_allocations() + + def list_allocations( + self, + *, + fiscal_year: int | None = None, + period_key: str | None = None, + department_id: str | None = None, + department_name: str | None = None, + cost_center: str | None = None, + ) -> list[BudgetAllocationRead]: + self.ensure_budget_ready() + stmt = select(BudgetAllocation).order_by( + BudgetAllocation.fiscal_year.desc(), + BudgetAllocation.period_key.asc(), + BudgetAllocation.department_name.asc(), + BudgetAllocation.subject_code.asc(), + ).where(BudgetAllocation.subject_code.in_(SUPPORTED_BUDGET_SUBJECT_CODES)) + if fiscal_year is not None: + stmt = stmt.where(BudgetAllocation.fiscal_year == fiscal_year) + if period_key: + stmt = stmt.where(BudgetAllocation.period_key == period_key) + if department_id: + stmt = stmt.where(BudgetAllocation.department_id == department_id) + if department_name: + stmt = stmt.where(BudgetAllocation.department_name == department_name) + if cost_center: + stmt = stmt.where(BudgetAllocation.cost_center == cost_center) + return [self.serialize_allocation(row) for row in self.db.scalars(stmt).all()] + + def get_summary( + self, + *, + fiscal_year: int | None = None, + period_key: str | None = None, + department_id: str | None = None, + department_name: str | None = None, + cost_center: str | None = None, + ) -> BudgetSummaryRead: + allocations = self.list_allocations( + fiscal_year=fiscal_year, + period_key=period_key, + department_id=department_id, + department_name=department_name, + cost_center=cost_center, + ) + total_amount = sum((item.balance.total_amount for item in allocations), Decimal("0.00")) + reserved_amount = sum((item.balance.reserved_amount for item in allocations), Decimal("0.00")) + consumed_amount = sum((item.balance.consumed_amount for item in allocations), Decimal("0.00")) + available_amount = sum((item.balance.available_amount for item in allocations), Decimal("0.00")) + warning_count = sum( + 1 + for item in allocations + if item.balance.usage_rate >= item.warning_threshold + and item.balance.available_amount >= Decimal("0.00") + ) + over_budget_count = sum( + 1 for item in allocations if item.balance.available_amount < Decimal("0.00") + ) + return BudgetSummaryRead( + fiscal_year=fiscal_year, + period_key=period_key, + total_amount=total_amount, + reserved_amount=reserved_amount, + consumed_amount=consumed_amount, + available_amount=available_amount, + warning_count=warning_count, + over_budget_count=over_budget_count, + allocations=allocations, + ) + + def create_or_update_allocation( + self, + payload: BudgetAllocationCreate, + *, + operator: str, + ) -> BudgetAllocationRead: + self.ensure_budget_ready() + subject_code = self._normalize_subject_code(payload.subject_code) + if not self._is_supported_budget_subject(subject_code): + raise ValueError("demo 阶段预算中心只维护差旅、通信、招待费、办公用品四类预算。") + period_key = self._normalize_period_key(payload.fiscal_year, payload.period_key) + existing = self._find_exact_allocation( + fiscal_year=payload.fiscal_year, + period_key=period_key, + department_id=payload.department_id, + department_name=payload.department_name, + cost_center=payload.cost_center, + project_code=payload.project_code, + subject_code=subject_code, + ) + if existing is None: + allocation = BudgetAllocation( + budget_no=self._make_no("BUD"), + fiscal_year=payload.fiscal_year, + period_type=self._normalize_period_type(payload.period_type), + period_key=period_key, + department_id=self._blank_to_none(payload.department_id), + department_name=payload.department_name.strip(), + cost_center=self._blank_to_none(payload.cost_center), + project_code=self._blank_to_none(payload.project_code), + subject_code=subject_code, + subject_name=payload.subject_name.strip(), + original_amount=self._money(payload.original_amount), + adjusted_amount=Decimal("0.00"), + status="active", + warning_threshold=self._percent(payload.warning_threshold), + control_action=self._normalize_control_action(payload.control_action), + description=self._blank_to_none(payload.description), + created_by=operator, + updated_by=operator, + ) + self.db.add(allocation) + self.db.flush() + self._record_transaction( + allocation=allocation, + transaction_type="init", + amount=allocation.original_amount, + before_available=Decimal("0.00"), + after_available=self.get_balance(allocation).available_amount, + source_type="budget_allocation", + source_id=allocation.id, + source_no=allocation.budget_no, + operator=operator, + reason="初始化预算额度", + ) + self.db.flush() + return self.serialize_allocation(allocation) + + before_balance = self.get_balance(existing) + original_before = self._money(existing.original_amount) + existing.period_type = self._normalize_period_type(payload.period_type) + existing.department_name = payload.department_name.strip() + existing.cost_center = self._blank_to_none(payload.cost_center) + existing.project_code = self._blank_to_none(payload.project_code) + existing.subject_name = payload.subject_name.strip() + existing.original_amount = self._money(payload.original_amount) + existing.warning_threshold = self._percent(payload.warning_threshold) + existing.control_action = self._normalize_control_action(payload.control_action) + existing.description = self._blank_to_none(payload.description) + existing.updated_by = operator + self.db.flush() + amount_delta = self._money(existing.original_amount) - original_before + if amount_delta: + self._record_transaction( + allocation=existing, + transaction_type="adjust", + amount=amount_delta, + before_available=before_balance.available_amount, + after_available=self.get_balance(existing).available_amount, + source_type="budget_allocation", + source_id=existing.id, + source_no=existing.budget_no, + operator=operator, + reason="调整预算额度", + ) + self.db.flush() + return self.serialize_allocation(existing) + + def check(self, payload: BudgetCheckRequest) -> BudgetCheckRead: + self.ensure_budget_ready() + subject_code = self._normalize_subject_code(payload.subject_code) + if not self._is_supported_budget_subject(subject_code): + flag = self._build_budget_flag( + event_type="budget_control_skipped", + severity="low", + label="预算暂不管控", + message="demo 阶段该费用类型暂不纳入预算计算。", + amount=self._money(payload.amount), + extra={"subject_code": subject_code}, + ) + return BudgetCheckRead(passed=True, flags=[flag]) + allocation = self._find_allocation_for_dimension( + fiscal_year=payload.fiscal_year, + period_key=payload.period_key, + department_id=payload.department_id, + department_name=payload.department_name, + cost_center=payload.cost_center, + project_code=payload.project_code, + subject_code=subject_code, + ) + amount = self._money(payload.amount) + if allocation is None: + flag = self._build_budget_flag( + event_type="budget_missing", + severity="high", + label="预算归属缺失", + message="未找到匹配的预算额度,当前单据不能进入费用控制闭环。", + amount=amount, + ) + return BudgetCheckRead(passed=False, blocking_reasons=[flag["message"]], flags=[flag]) + + review = self._review_allocation_amount(allocation, amount) + return BudgetCheckRead( + passed=not review["blocking_reasons"], + blocking_reasons=review["blocking_reasons"], + flags=review["flags"], + allocation=self.serialize_allocation(allocation), + ) + + def review_claim_budget(self, claim: ExpenseClaim) -> dict[str, list[Any]]: + self.ensure_budget_ready() + if not self._claim_uses_budget_control(claim): + return {"flags": [], "blocking_reasons": []} + allocation = self._find_allocation_for_claim(claim) + amount = self._money(claim.amount or Decimal("0.00")) + if allocation is None: + if self._budget_table_empty(): + allocation = self._create_fallback_allocation_for_claim(claim) + else: + flag = self._build_budget_flag( + event_type="budget_missing", + severity="high", + label="预算归属缺失", + message=f"单据 {claim.claim_no} 未找到预算池额度,请先在预算中心建立预算。", + amount=amount, + ) + return {"flags": [flag], "blocking_reasons": [flag["message"]]} + review = self._review_allocation_amount(allocation, amount) + return {"flags": review["flags"], "blocking_reasons": review["blocking_reasons"]} + + def reserve_for_claim( + self, + claim: ExpenseClaim, + *, + source_type: str, + operator: str, + ) -> list[dict[str, Any]]: + self.ensure_budget_ready() + if not self._claim_uses_budget_control(claim): + return [] + existing = self._find_active_reservation(source_type=source_type, source_id=claim.id) + if existing is not None: + return [ + self._build_operation_flag( + existing.allocation, + event_type="budget_reserve_reused", + label="预算预占已存在", + message=f"单据 {claim.claim_no} 已存在有效预算预占,本次提交复用原预占。", + amount=existing.amount, + reservation_id=existing.id, + ) + ] + + allocation = self._find_allocation_for_claim(claim) + if allocation is None and self._budget_table_empty(): + allocation = self._create_fallback_allocation_for_claim(claim) + if allocation is None: + raise BudgetControlError([f"单据 {claim.claim_no} 未找到预算池额度,请先建立预算。"]) + + amount = self._money(claim.amount or Decimal("0.00")) + review = self._review_allocation_amount(allocation, amount) + if review["blocking_reasons"]: + raise BudgetControlError(review["blocking_reasons"], flags=review["flags"]) + + before_balance = self.get_balance(allocation) + reservation = BudgetReservation( + reservation_no=self._make_no("BRS"), + allocation_id=allocation.id, + source_type=source_type, + source_id=claim.id, + source_no=claim.claim_no, + source_status="active", + amount=amount, + context_json=self._claim_context(claim), + ) + self.db.add(reservation) + self.db.flush() + after_balance = self.get_balance(allocation) + transaction = self._record_transaction( + allocation=allocation, + reservation=reservation, + transaction_type="reserve", + amount=amount, + before_available=before_balance.available_amount, + after_available=after_balance.available_amount, + source_type=source_type, + source_id=claim.id, + source_no=claim.claim_no, + operator=operator, + reason="单据提交预算预占", + ) + self.db.flush() + flag = self._build_operation_flag( + allocation, + event_type="budget_reserved", + label="预算已预占", + message=f"已为单据 {claim.claim_no} 预占预算 {amount} 元。", + amount=amount, + reservation_id=reservation.id, + transaction_id=transaction.id, + ) + return [*review["flags"], flag] + + def release_for_claim( + self, + claim: ExpenseClaim, + *, + source_type: str, + operator: str, + reason: str, + ) -> list[dict[str, Any]]: + self.ensure_budget_ready() + reservations = self._find_active_reservations(source_type=source_type, source_id=claim.id) + flags: list[dict[str, Any]] = [] + for reservation in reservations: + allocation = reservation.allocation + before_balance = self.get_balance(allocation) + amount = self._money(reservation.amount) + reservation.source_status = "released" + reservation.released_amount = amount + reservation.released_at = datetime.now(UTC) + self.db.flush() + after_balance = self.get_balance(allocation) + transaction = self._record_transaction( + allocation=allocation, + reservation=reservation, + transaction_type="release", + amount=amount, + before_available=before_balance.available_amount, + after_available=after_balance.available_amount, + source_type=source_type, + source_id=claim.id, + source_no=claim.claim_no, + operator=operator, + reason=reason, + ) + flags.append( + self._build_operation_flag( + allocation, + event_type="budget_released", + label="预算预占已释放", + message=f"单据 {claim.claim_no} 已释放预算预占 {amount} 元。", + amount=amount, + reservation_id=reservation.id, + transaction_id=transaction.id, + ) + ) + self.db.flush() + return flags + + def consume_for_claim( + self, + claim: ExpenseClaim, + *, + operator: str, + reason: str, + ) -> dict[str, Any] | None: + self.ensure_budget_ready() + if not self._claim_uses_budget_control(claim): + return None + reservations = self._find_active_reservations(source_type="claim", source_id=claim.id) + amount = self._money(claim.amount or Decimal("0.00")) + if reservations: + reservation = reservations[0] + allocation = reservation.allocation + before_balance = self.get_balance(allocation) + reserved_amount = self._money(reservation.amount) + consume_amount = min(amount, reserved_amount) + release_amount = max(reserved_amount - consume_amount, Decimal("0.00")) + reservation.source_status = "consumed" + reservation.consumed_amount = consume_amount + reservation.released_amount = release_amount + reservation.consumed_at = datetime.now(UTC) + self.db.flush() + after_balance = self.get_balance(allocation) + transaction = self._record_transaction( + allocation=allocation, + reservation=reservation, + transaction_type="consume", + amount=consume_amount, + before_available=before_balance.available_amount, + after_available=after_balance.available_amount, + source_type="claim", + source_id=claim.id, + source_no=claim.claim_no, + operator=operator, + reason=reason, + ) + self.db.flush() + return self._build_operation_flag( + allocation, + event_type="budget_consumed", + label="预算已核销", + message=f"单据 {claim.claim_no} 已核销预算 {consume_amount} 元。", + amount=consume_amount, + reservation_id=reservation.id, + transaction_id=transaction.id, + ) + + allocation = self._find_allocation_for_claim(claim) + if allocation is None: + return self._build_budget_flag( + event_type="budget_consume_skipped", + severity="low", + label="预算未核销", + message=f"单据 {claim.claim_no} 未找到预算池额度,按存量单据兼容放行。", + amount=amount, + ) + review = self._review_allocation_amount(allocation, amount) + if review["blocking_reasons"]: + raise BudgetControlError(review["blocking_reasons"], flags=review["flags"]) + before_balance = self.get_balance(allocation) + transaction = self._record_transaction( + allocation=allocation, + transaction_type="consume", + amount=amount, + before_available=before_balance.available_amount, + after_available=before_balance.available_amount - amount, + source_type="claim", + source_id=claim.id, + source_no=claim.claim_no, + operator=operator, + reason=reason, + ) + self.db.flush() + return self._build_operation_flag( + allocation, + event_type="budget_consumed", + label="预算已核销", + message=f"单据 {claim.claim_no} 已核销预算 {amount} 元。", + amount=amount, + transaction_id=transaction.id, + ) + + def transfer_application_reservation( + self, + *, + application_claim: ExpenseClaim, + draft_claim: ExpenseClaim, + operator: str, + ) -> dict[str, Any] | None: + self.ensure_budget_ready() + reservation = self._find_active_reservation( + source_type="application", + source_id=application_claim.id, + ) + if reservation is None: + return None + allocation = reservation.allocation + before_balance = self.get_balance(allocation) + reservation.source_type = "claim" + reservation.source_id = draft_claim.id + reservation.source_no = draft_claim.claim_no + context = dict(reservation.context_json or {}) + context["application_claim_id"] = application_claim.id + context["application_claim_no"] = application_claim.claim_no + context["draft_claim_id"] = draft_claim.id + context["draft_claim_no"] = draft_claim.claim_no + reservation.context_json = context + self.db.flush() + transaction = self._record_transaction( + allocation=allocation, + reservation=reservation, + transaction_type="transfer", + amount=Decimal("0.00"), + before_available=before_balance.available_amount, + after_available=self.get_balance(allocation).available_amount, + source_type="claim", + source_id=draft_claim.id, + source_no=draft_claim.claim_no, + operator=operator, + reason="申请审批通过后预算预占转入报销草稿", + ) + self.db.flush() + return self._build_operation_flag( + allocation, + event_type="budget_reservation_transferred", + label="预算预占已转入报销", + message=f"申请 {application_claim.claim_no} 的预算预占已转入报销草稿 {draft_claim.claim_no}。", + amount=reservation.amount, + reservation_id=reservation.id, + transaction_id=transaction.id, + ) + + def execute_operation( + self, + payload: BudgetOperationRequest, + *, + transaction_type: str, + operator: str, + ) -> BudgetOperationRead: + self.ensure_budget_ready() + normalized_type = str(transaction_type or "").strip().lower() + subject_code = self._normalize_subject_code(payload.subject_code) + if not self._is_supported_budget_subject(subject_code): + raise BudgetControlError(["demo 阶段该费用类型暂不纳入预算计算。"]) + allocation = self._find_allocation_for_dimension( + fiscal_year=payload.fiscal_year, + period_key=payload.period_key, + department_id=payload.department_id, + department_name=payload.department_name, + cost_center=payload.cost_center, + project_code=payload.project_code, + subject_code=subject_code, + ) + if allocation is None: + raise BudgetControlError(["未找到匹配的预算额度。"]) + amount = self._money(payload.amount) + + if normalized_type == "reserve": + return self._execute_manual_reserve_operation(payload, allocation, amount, operator) + if normalized_type == "release": + return self._execute_manual_release_operation(payload, operator) + if normalized_type == "consume": + return self._execute_manual_consume_operation(payload, allocation, amount, operator) + + before_balance = self.get_balance(allocation) + transaction = self._record_transaction( + allocation=allocation, + transaction_type=normalized_type, + amount=amount, + before_available=before_balance.available_amount, + after_available=before_balance.available_amount - amount, + source_type=payload.source_type, + source_id=payload.source_id, + source_no=payload.source_no, + operator=operator, + reason=payload.reason, + ) + self.db.flush() + return BudgetOperationRead( + ok=True, + message="预算操作已记录。", + allocation=self.serialize_allocation(allocation), + transaction=BudgetTransactionRead.model_validate(transaction), + ) + + def _execute_manual_reserve_operation( + self, + payload: BudgetOperationRequest, + allocation: BudgetAllocation, + amount: Decimal, + operator: str, + ) -> BudgetOperationRead: + existing = self._find_active_reservation( + source_type=payload.source_type, + source_id=payload.source_id, + ) + if existing is not None: + flag = self._build_operation_flag( + existing.allocation, + event_type="budget_reserve_reused", + label="预算预占已存在", + message=f"来源 {payload.source_no} 已存在有效预算预占。", + amount=existing.amount, + reservation_id=existing.id, + ) + return BudgetOperationRead( + ok=True, + message="预算预占已存在。", + reservation_id=existing.id, + allocation=self.serialize_allocation(existing.allocation), + flags=[flag], + ) + + review = self._review_allocation_amount(allocation, amount) + if review["blocking_reasons"]: + raise BudgetControlError(review["blocking_reasons"], flags=review["flags"]) + + before_balance = self.get_balance(allocation) + reservation = BudgetReservation( + reservation_no=self._make_no("BRS"), + allocation_id=allocation.id, + source_type=payload.source_type, + source_id=payload.source_id, + source_no=payload.source_no, + source_status="active", + amount=amount, + context_json={"manual_operation": True}, + ) + self.db.add(reservation) + self.db.flush() + after_balance = self.get_balance(allocation) + transaction = self._record_transaction( + allocation=allocation, + reservation=reservation, + transaction_type="reserve", + amount=amount, + before_available=before_balance.available_amount, + after_available=after_balance.available_amount, + source_type=payload.source_type, + source_id=payload.source_id, + source_no=payload.source_no, + operator=operator, + reason=payload.reason, + ) + self.db.flush() + flag = self._build_operation_flag( + allocation, + event_type="budget_reserved", + label="预算已预占", + message=f"来源 {payload.source_no} 已预占预算 {amount} 元。", + amount=amount, + reservation_id=reservation.id, + transaction_id=transaction.id, + ) + return BudgetOperationRead( + ok=True, + message="预算预占已记录。", + reservation_id=reservation.id, + allocation=self.serialize_allocation(allocation), + transaction=BudgetTransactionRead.model_validate(transaction), + flags=[*review["flags"], flag], + ) + + def _execute_manual_release_operation( + self, + payload: BudgetOperationRequest, + operator: str, + ) -> BudgetOperationRead: + reservation = self._find_active_reservation( + source_type=payload.source_type, + source_id=payload.source_id, + ) + if reservation is None: + raise BudgetControlError(["未找到可释放的预算预占。"]) + + allocation = reservation.allocation + before_balance = self.get_balance(allocation) + amount = self._money(reservation.amount) + reservation.source_status = "released" + reservation.released_amount = amount + reservation.released_at = datetime.now(UTC) + self.db.flush() + after_balance = self.get_balance(allocation) + transaction = self._record_transaction( + allocation=allocation, + reservation=reservation, + transaction_type="release", + amount=amount, + before_available=before_balance.available_amount, + after_available=after_balance.available_amount, + source_type=payload.source_type, + source_id=payload.source_id, + source_no=payload.source_no, + operator=operator, + reason=payload.reason, + ) + self.db.flush() + flag = self._build_operation_flag( + allocation, + event_type="budget_released", + label="预算预占已释放", + message=f"来源 {payload.source_no} 已释放预算预占 {amount} 元。", + amount=amount, + reservation_id=reservation.id, + transaction_id=transaction.id, + ) + return BudgetOperationRead( + ok=True, + message="预算释放已记录。", + reservation_id=reservation.id, + allocation=self.serialize_allocation(allocation), + transaction=BudgetTransactionRead.model_validate(transaction), + flags=[flag], + ) + + def _execute_manual_consume_operation( + self, + payload: BudgetOperationRequest, + allocation: BudgetAllocation, + amount: Decimal, + operator: str, + ) -> BudgetOperationRead: + reservation = self._find_active_reservation( + source_type=payload.source_type, + source_id=payload.source_id, + ) + if reservation is None: + return self._record_direct_operation(payload, allocation, "consume", amount, operator) + + before_balance = self.get_balance(reservation.allocation) + reserved_amount = self._money(reservation.amount) + consume_amount = min(amount, reserved_amount) + reservation.source_status = "consumed" + reservation.consumed_amount = consume_amount + reservation.released_amount = max(reserved_amount - consume_amount, Decimal("0.00")) + reservation.consumed_at = datetime.now(UTC) + self.db.flush() + after_balance = self.get_balance(reservation.allocation) + transaction = self._record_transaction( + allocation=reservation.allocation, + reservation=reservation, + transaction_type="consume", + amount=consume_amount, + before_available=before_balance.available_amount, + after_available=after_balance.available_amount, + source_type=payload.source_type, + source_id=payload.source_id, + source_no=payload.source_no, + operator=operator, + reason=payload.reason, + ) + self.db.flush() + flag = self._build_operation_flag( + reservation.allocation, + event_type="budget_consumed", + label="预算已核销", + message=f"来源 {payload.source_no} 已核销预算 {consume_amount} 元。", + amount=consume_amount, + reservation_id=reservation.id, + transaction_id=transaction.id, + ) + return BudgetOperationRead( + ok=True, + message="预算核销已记录。", + reservation_id=reservation.id, + allocation=self.serialize_allocation(reservation.allocation), + transaction=BudgetTransactionRead.model_validate(transaction), + flags=[flag], + ) + + def _record_direct_operation( + self, + payload: BudgetOperationRequest, + allocation: BudgetAllocation, + transaction_type: str, + amount: Decimal, + operator: str, + ) -> BudgetOperationRead: + before_balance = self.get_balance(allocation) + transaction = self._record_transaction( + allocation=allocation, + transaction_type=transaction_type, + amount=amount, + before_available=before_balance.available_amount, + after_available=before_balance.available_amount - amount, + source_type=payload.source_type, + source_id=payload.source_id, + source_no=payload.source_no, + operator=operator, + reason=payload.reason, + ) + self.db.flush() + return BudgetOperationRead( + ok=True, + message="预算操作已记录。", + allocation=self.serialize_allocation(allocation), + transaction=BudgetTransactionRead.model_validate(transaction), + ) diff --git a/server/src/app/services/budget_support.py b/server/src/app/services/budget_support.py new file mode 100644 index 0000000..f909f1c --- /dev/null +++ b/server/src/app/services/budget_support.py @@ -0,0 +1,623 @@ +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import select + +from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction +from app.models.financial_record import ExpenseClaim +from app.models.organization import OrganizationUnit +from app.schemas.budget import BudgetAllocationRead, BudgetTransactionRead +from app.services.budget_types import ( + BUDGET_SUBJECT_LABELS, + BudgetBalance, + DEFAULT_SUBJECT_AMOUNTS, + SUBJECT_CODE_ALIASES, + SUPPORTED_BUDGET_SUBJECT_CODES, +) +from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS +from app.services.expense_type_keywords import resolve_expense_type_code_from_text + + +class BudgetSupportMixin: + def serialize_allocation(self, allocation: BudgetAllocation) -> BudgetAllocationRead: + return BudgetAllocationRead( + id=allocation.id, + budget_no=allocation.budget_no, + fiscal_year=allocation.fiscal_year, + period_type=allocation.period_type, + period_key=allocation.period_key, + department_id=allocation.department_id, + department_name=allocation.department_name, + cost_center=allocation.cost_center, + project_code=allocation.project_code, + subject_code=allocation.subject_code, + subject_name=allocation.subject_name, + original_amount=self._money(allocation.original_amount), + adjusted_amount=self._money(allocation.adjusted_amount), + status=allocation.status, + warning_threshold=self._percent(allocation.warning_threshold), + control_action=allocation.control_action, + description=allocation.description, + balance=self.get_balance(allocation).to_read(), + created_at=allocation.created_at, + updated_at=allocation.updated_at, + ) + + def get_balance(self, allocation: BudgetAllocation) -> BudgetBalance: + reservations = self.db.scalars( + select(BudgetReservation).where( + BudgetReservation.allocation_id == allocation.id, + BudgetReservation.source_status == "active", + ) + ).all() + transactions = self.db.scalars( + select(BudgetTransaction).where(BudgetTransaction.allocation_id == allocation.id) + ).all() + reserved_amount = sum((self._money(item.amount) for item in reservations), Decimal("0.00")) + consumed_amount = Decimal("0.00") + for transaction in transactions: + transaction_type = str(transaction.transaction_type or "").strip().lower() + amount = self._money(transaction.amount) + if transaction_type == "consume": + consumed_amount += amount + elif transaction_type == "rollback": + consumed_amount -= amount + total_amount = self._money(allocation.original_amount) + self._money(allocation.adjusted_amount) + available_amount = total_amount - reserved_amount - consumed_amount + usage_amount = reserved_amount + consumed_amount + usage_rate = Decimal("0.00") + if total_amount > Decimal("0.00"): + usage_rate = ((usage_amount / total_amount) * Decimal("100")).quantize(Decimal("0.01")) + return BudgetBalance( + total_amount=total_amount, + reserved_amount=reserved_amount, + consumed_amount=consumed_amount, + available_amount=available_amount, + usage_rate=usage_rate, + ) + + def list_transactions(self, allocation_id: str) -> list[BudgetTransactionRead]: + self.ensure_budget_ready() + rows = self.db.scalars( + select(BudgetTransaction) + .where(BudgetTransaction.allocation_id == allocation_id) + .order_by(BudgetTransaction.created_at.desc()) + ).all() + return [BudgetTransactionRead.model_validate(row) for row in rows] + + def get_allocation_row(self, allocation_id: str) -> BudgetAllocation | None: + self.ensure_budget_ready() + return self.db.get(BudgetAllocation, allocation_id) + + def _review_allocation_amount( + self, + allocation: BudgetAllocation, + amount: Decimal, + ) -> dict[str, list[Any]]: + balance = self.get_balance(allocation) + flags: list[dict[str, Any]] = [] + blocking_reasons: list[str] = [] + if str(allocation.status or "").strip().lower() == "frozen": + message = f"预算 {allocation.budget_no} 已冻结,不能继续占用。" + flags.append( + self._build_operation_flag( + allocation, + event_type="budget_frozen", + label="预算已冻结", + message=message, + severity="high", + amount=amount, + ) + ) + blocking_reasons.append(message) + return {"flags": flags, "blocking_reasons": blocking_reasons} + + if amount > balance.available_amount: + over_amount = amount - balance.available_amount + message = ( + f"预算 {allocation.budget_no} 可用余额 {balance.available_amount} 元," + f"当前单据金额 {amount} 元,超出 {over_amount} 元。" + ) + flags.append( + self._build_operation_flag( + allocation, + event_type="budget_insufficient", + label="预算余额不足", + message=message, + severity="high", + amount=amount, + extra={"available_amount": str(balance.available_amount), "over_budget_amount": str(over_amount)}, + ) + ) + blocking_reasons.append(message) + return {"flags": flags, "blocking_reasons": blocking_reasons} + + after_usage = balance.reserved_amount + balance.consumed_amount + amount + usage_rate = Decimal("0.00") + if balance.total_amount > Decimal("0.00"): + usage_rate = ((after_usage / balance.total_amount) * Decimal("100")).quantize(Decimal("0.01")) + if usage_rate >= self._percent(allocation.warning_threshold): + flags.append( + self._build_operation_flag( + allocation, + event_type="budget_warning", + label="预算接近预警线", + message=( + f"预算 {allocation.budget_no} 本次占用后使用率预计达到 {usage_rate}%," + f"已达到预警线 {allocation.warning_threshold}%。" + ), + severity="medium", + amount=amount, + extra={"usage_rate": str(usage_rate)}, + ) + ) + return {"flags": flags, "blocking_reasons": blocking_reasons} + + def build_claim_budget_context(self, claim: ExpenseClaim) -> dict[str, Any]: + self.ensure_budget_ready() + amount = self._money(claim.amount or Decimal("0.00")) + fiscal_year, period_key = self._period_from_claim(claim) + subject_code = self._subject_code_from_claim(claim) + if not self._is_supported_budget_subject(subject_code): + return { + "matched": False, + "budget_applicable": False, + "skip_reason": "demo_budget_subject_not_enabled", + "claim_amount": str(amount), + "fiscal_year": fiscal_year, + "period_key": period_key, + "subject_code": subject_code, + "department_id": claim.department_id, + "department_name": claim.department_name, + "cost_center": self._resolve_claim_cost_center(claim), + } + + allocation = self._find_allocation_for_claim(claim) + if allocation is None: + return { + "matched": False, + "budget_applicable": True, + "claim_amount": str(amount), + "fiscal_year": fiscal_year, + "period_key": period_key, + "subject_code": subject_code, + "department_id": claim.department_id, + "department_name": claim.department_name, + "cost_center": self._resolve_claim_cost_center(claim), + } + + balance = self.get_balance(allocation) + over_budget_amount = max(amount - balance.available_amount, Decimal("0.00")) + return { + "matched": True, + "budget_applicable": True, + "allocation_id": allocation.id, + "budget_no": allocation.budget_no, + "claim_amount": str(amount), + "total_amount": str(balance.total_amount), + "reserved_amount": str(balance.reserved_amount), + "consumed_amount": str(balance.consumed_amount), + "available_amount": str(balance.available_amount), + "usage_rate": str(balance.usage_rate), + "over_budget_amount": str(over_budget_amount), + "warning_threshold": str(allocation.warning_threshold), + "control_action": allocation.control_action, + "fiscal_year": allocation.fiscal_year, + "period_key": allocation.period_key, + "subject_code": allocation.subject_code, + "subject_name": allocation.subject_name, + "department_id": allocation.department_id, + "department_name": allocation.department_name, + "cost_center": allocation.cost_center, + "project_code": allocation.project_code, + } + + 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( + fiscal_year=fiscal_year, + period_key=period_key, + department_id=claim.department_id, + department_name=claim.department_name, + cost_center=self._resolve_claim_cost_center(claim), + project_code=claim.project_code, + subject_code=self._subject_code_from_claim(claim), + ) + + def _find_allocation_for_dimension( + self, + *, + fiscal_year: int | None, + period_key: str | None, + department_id: str | None, + department_name: str | None, + cost_center: str | None, + project_code: str | None, + subject_code: str, + ) -> BudgetAllocation | None: + now = datetime.now(UTC) + year = fiscal_year or now.year + key = self._normalize_period_key(year, period_key or self._quarter_key(year, now.month)) + normalized_subject = self._normalize_subject_code(subject_code) + candidates = list( + self.db.scalars( + select(BudgetAllocation) + .where(BudgetAllocation.fiscal_year == year) + .where(BudgetAllocation.period_key == key) + .where(BudgetAllocation.subject_code == normalized_subject) + .where(BudgetAllocation.status.in_(["active", "published"])) + .order_by(BudgetAllocation.project_code.desc().nullslast()) + ).all() + ) + if not candidates: + return None + normalized_department_id = self._blank_to_none(department_id) + normalized_department_name = str(department_name or "").strip() + normalized_cost_center = self._blank_to_none(cost_center) + normalized_project_code = self._blank_to_none(project_code) + for item in candidates: + if normalized_project_code and item.project_code and item.project_code != normalized_project_code: + continue + if normalized_department_id and item.department_id == normalized_department_id: + return item + if normalized_cost_center and item.cost_center == normalized_cost_center: + return item + if normalized_department_name and item.department_name == normalized_department_name: + return item + return None + + def _find_exact_allocation( + self, + *, + fiscal_year: int, + period_key: str, + department_id: str | None, + department_name: str, + cost_center: str | None, + project_code: str | None, + subject_code: str, + ) -> BudgetAllocation | None: + rows = self.db.scalars( + select(BudgetAllocation) + .where(BudgetAllocation.fiscal_year == fiscal_year) + .where(BudgetAllocation.period_key == period_key) + .where(BudgetAllocation.subject_code == subject_code) + ).all() + normalized_department_id = self._blank_to_none(department_id) + normalized_department_name = department_name.strip() + normalized_cost_center = self._blank_to_none(cost_center) + normalized_project_code = self._blank_to_none(project_code) + for row in rows: + if row.project_code != normalized_project_code: + continue + if normalized_department_id and row.department_id == normalized_department_id: + return row + if normalized_cost_center and row.cost_center == normalized_cost_center: + return row + if row.department_name == normalized_department_name: + return row + return None + + def _find_active_reservation(self, *, source_type: str, source_id: str) -> BudgetReservation | None: + return self.db.scalar( + select(BudgetReservation) + .where(BudgetReservation.source_type == source_type) + .where(BudgetReservation.source_id == source_id) + .where(BudgetReservation.source_status == "active") + .order_by(BudgetReservation.created_at.desc()) + .limit(1) + ) + + def _find_active_reservations(self, *, source_type: str, source_id: str) -> list[BudgetReservation]: + return list( + self.db.scalars( + select(BudgetReservation) + .where(BudgetReservation.source_type == source_type) + .where(BudgetReservation.source_id == source_id) + .where(BudgetReservation.source_status == "active") + ).all() + ) + + def _seed_default_allocations(self) -> None: + units = list( + self.db.scalars( + select(OrganizationUnit).where(OrganizationUnit.unit_type == "department") + ).all() + ) + if not units: + return + year = datetime.now(UTC).year + for unit in units: + for quarter in range(1, 5): + period_key = f"{year}Q{quarter}" + for subject_code, amount in DEFAULT_SUBJECT_AMOUNTS.items(): + allocation = BudgetAllocation( + budget_no=self._make_no("BUD"), + fiscal_year=year, + period_type="quarter", + period_key=period_key, + department_id=unit.id, + department_name=unit.name, + cost_center=unit.cost_center, + project_code=None, + subject_code=subject_code, + subject_name=self._subject_label(subject_code), + original_amount=amount, + adjusted_amount=Decimal("0.00"), + status="active", + warning_threshold=Decimal("80.00"), + control_action="block", + description="系统初始化预算池额度", + created_by="system", + updated_by="system", + ) + self.db.add(allocation) + self.db.flush() + self._record_transaction( + allocation=allocation, + transaction_type="init", + amount=amount, + before_available=Decimal("0.00"), + after_available=amount, + source_type="budget_seed", + source_id=allocation.id, + source_no=allocation.budget_no, + operator="system", + reason="系统初始化预算池额度", + ) + self.db.flush() + + def _create_fallback_allocation_for_claim(self, claim: ExpenseClaim) -> BudgetAllocation: + fiscal_year, period_key = self._period_from_claim(claim) + subject_code = self._subject_code_from_claim(claim) + allocation = BudgetAllocation( + budget_no=self._make_no("BUD"), + fiscal_year=fiscal_year, + period_type="quarter", + period_key=period_key, + department_id=claim.department_id, + department_name=str(claim.department_name or "未归属部门").strip() or "未归属部门", + cost_center=self._resolve_claim_cost_center(claim), + project_code=claim.project_code, + subject_code=subject_code, + subject_name=self._subject_label(subject_code), + original_amount=DEFAULT_SUBJECT_AMOUNTS.get(subject_code, Decimal("100000.00")), + adjusted_amount=Decimal("0.00"), + status="active", + warning_threshold=Decimal("80.00"), + control_action="block", + description="测试或演示环境自动补齐预算池额度", + created_by="system", + updated_by="system", + ) + self.db.add(allocation) + self.db.flush() + self._record_transaction( + allocation=allocation, + transaction_type="init", + amount=allocation.original_amount, + before_available=Decimal("0.00"), + after_available=allocation.original_amount, + source_type="budget_seed", + source_id=allocation.id, + source_no=allocation.budget_no, + operator="system", + reason="自动补齐预算池额度", + ) + self.db.flush() + return allocation + + def _budget_table_empty(self) -> bool: + return self.db.scalar(select(BudgetAllocation.id).limit(1)) is None + + def _record_transaction( + self, + *, + allocation: BudgetAllocation, + transaction_type: str, + amount: Decimal, + before_available: Decimal, + after_available: Decimal, + source_type: str, + source_id: str, + source_no: str, + operator: str | None, + reason: str | None, + reservation: BudgetReservation | None = None, + context_json: dict[str, Any] | None = None, + ) -> BudgetTransaction: + transaction = BudgetTransaction( + transaction_no=self._make_no("BTX"), + allocation_id=allocation.id, + reservation_id=reservation.id if reservation is not None else None, + source_type=source_type, + source_id=source_id, + source_no=source_no, + transaction_type=transaction_type, + amount=self._money(amount), + before_available_amount=self._money(before_available), + after_available_amount=self._money(after_available), + operator=operator, + reason=reason, + context_json=context_json or {}, + ) + self.db.add(transaction) + return transaction + + @staticmethod + def _build_budget_flag( + *, + event_type: str, + severity: str, + label: str, + message: str, + amount: Decimal, + extra: dict[str, Any] | None = None, + ) -> dict[str, Any]: + payload = { + "source": "budget_control", + "event_type": event_type, + "severity": severity, + "label": label, + "message": message, + "amount": str(amount), + "created_at": datetime.now(UTC).isoformat(), + } + payload.update(extra or {}) + return payload + + def _build_operation_flag( + self, + allocation: BudgetAllocation, + *, + event_type: str, + label: str, + message: str, + amount: Decimal, + severity: str = "info", + reservation_id: str | None = None, + transaction_id: str | None = None, + extra: dict[str, Any] | None = None, + ) -> dict[str, Any]: + balance = self.get_balance(allocation) + payload = self._build_budget_flag( + event_type=event_type, + severity=severity, + label=label, + message=message, + amount=amount, + extra={ + "allocation_id": allocation.id, + "budget_no": allocation.budget_no, + "subject_code": allocation.subject_code, + "subject_name": allocation.subject_name, + "available_amount": str(balance.available_amount), + "reserved_amount": str(balance.reserved_amount), + "consumed_amount": str(balance.consumed_amount), + **(extra or {}), + }, + ) + if reservation_id: + payload["reservation_id"] = reservation_id + if transaction_id: + payload["transaction_id"] = transaction_id + return payload + + @staticmethod + def _money(value: Any) -> Decimal: + return Decimal(str(value or "0")).quantize(Decimal("0.01")) + + @staticmethod + def _percent(value: Any) -> Decimal: + return Decimal(str(value or "0")).quantize(Decimal("0.01")) + + @staticmethod + def _blank_to_none(value: str | None) -> str | None: + text = str(value or "").strip() + return text or None + + @staticmethod + def _make_no(prefix: str) -> str: + return f"{prefix}-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:8].upper()}" + + @staticmethod + def _normalize_period_type(value: str | None) -> str: + text = str(value or "").strip().lower() + return text if text in {"month", "quarter", "year"} else "quarter" + + @staticmethod + def _normalize_period_key(year: int, value: str | None) -> str: + text = str(value or "").strip().upper().replace("年", "").replace("第", "").replace("季度", "") + if text.startswith(str(year)) and "Q" in text: + return text + if text in {"Q1", "Q2", "Q3", "Q4"}: + return f"{year}{text}" + return text or f"{year}Q1" + + @staticmethod + def _quarter_key(year: int, month: int) -> str: + quarter = ((max(1, min(month, 12)) - 1) // 3) + 1 + return f"{year}Q{quarter}" + + def _period_from_claim(self, claim: ExpenseClaim) -> tuple[int, str]: + occurred_at = claim.occurred_at or claim.submitted_at or datetime.now(UTC) + return occurred_at.year, self._quarter_key(occurred_at.year, occurred_at.month) + + def _subject_code_from_claim(self, claim: ExpenseClaim) -> str: + expense_type = str(claim.expense_type or "").strip().lower() + if expense_type.endswith("_application"): + expense_type = expense_type.removesuffix("_application") + expense_type = SUBJECT_CODE_ALIASES.get(expense_type, expense_type) + if expense_type in DEFAULT_SUBJECT_AMOUNTS or expense_type in EXPENSE_TYPE_LABELS: + return expense_type + resolved = resolve_expense_type_code_from_text(expense_type) + if resolved: + return SUBJECT_CODE_ALIASES.get(resolved, resolved) + return resolved or expense_type or "other" + + @staticmethod + def _normalize_subject_code(value: str | None) -> str: + text = str(value or "").strip().lower() + if text.endswith("_application"): + text = text.removesuffix("_application") + text = SUBJECT_CODE_ALIASES.get(text, text) + resolved = resolve_expense_type_code_from_text(text) + if resolved: + return SUBJECT_CODE_ALIASES.get(resolved, resolved) + return text or "other" + + @staticmethod + def _is_supported_budget_subject(subject_code: str | None) -> bool: + return str(subject_code or "").strip().lower() in SUPPORTED_BUDGET_SUBJECT_CODES + + def _claim_uses_budget_control(self, claim: ExpenseClaim) -> bool: + return self._is_supported_budget_subject(self._subject_code_from_claim(claim)) + + @staticmethod + def _subject_label(code: str) -> str: + return BUDGET_SUBJECT_LABELS.get(code, EXPENSE_TYPE_LABELS.get(code, code)) + + @staticmethod + def _normalize_control_action(value: str | None) -> str: + text = str(value or "").strip().lower() + if text in {"block", "control", "管控", "强控"}: + return "block" + if text in {"warn", "warning", "提醒", "预警"}: + return "warn" + if text in {"allow", "normal", "正常", "放行"}: + return "allow" + return "block" + + def _resolve_claim_cost_center(self, claim: ExpenseClaim) -> str | None: + employee = getattr(claim, "employee", None) + if employee is not None: + cost_center = self._blank_to_none(getattr(employee, "cost_center", None)) + if cost_center: + return cost_center + organization_unit = getattr(employee, "organization_unit", None) + if organization_unit is not None: + cost_center = self._blank_to_none(getattr(organization_unit, "cost_center", None)) + if cost_center: + return cost_center + return None + + def _claim_context(self, claim: ExpenseClaim) -> dict[str, Any]: + fiscal_year, period_key = self._period_from_claim(claim) + return { + "claim_id": claim.id, + "claim_no": claim.claim_no, + "employee_id": claim.employee_id, + "employee_name": claim.employee_name, + "department_id": claim.department_id, + "department_name": claim.department_name, + "cost_center": self._resolve_claim_cost_center(claim), + "project_code": claim.project_code, + "expense_type": claim.expense_type, + "subject_code": self._subject_code_from_claim(claim), + "fiscal_year": fiscal_year, + "period_key": period_key, + } diff --git a/server/src/app/services/budget_types.py b/server/src/app/services/budget_types.py new file mode 100644 index 0000000..996b4eb --- /dev/null +++ b/server/src/app/services/budget_types.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from typing import Any + +from app.schemas.budget import BudgetBalanceRead + + +DEFAULT_SUBJECT_AMOUNTS: dict[str, Decimal] = { + "travel": Decimal("600000.00"), + "meal": Decimal("420000.00"), + "office": Decimal("180000.00"), + "communication": Decimal("120000.00"), +} + +BUDGET_SUBJECT_LABELS = { + "travel": "差旅", + "communication": "通信", + "meal": "招待费", + "office": "办公用品", +} +SUPPORTED_BUDGET_SUBJECT_CODES = frozenset(DEFAULT_SUBJECT_AMOUNTS) +SUBJECT_CODE_ALIASES = { + "entertainment": "meal", + "purchase": "office", +} + +MUTATING_TRANSACTION_TYPES = {"adjust", "reserve", "release", "consume", "rollback"} + + +class BudgetControlError(ValueError): + def __init__(self, reasons: list[str], *, flags: list[dict[str, Any]] | None = None) -> None: + self.reasons = [reason for reason in reasons if reason] + self.flags = list(flags or []) + super().__init__(";".join(self.reasons) or "预算校验未通过") + + +@dataclass(frozen=True) +class BudgetBalance: + total_amount: Decimal + reserved_amount: Decimal + consumed_amount: Decimal + available_amount: Decimal + usage_rate: Decimal + + def to_read(self) -> BudgetBalanceRead: + return BudgetBalanceRead( + total_amount=self.total_amount, + reserved_amount=self.reserved_amount, + consumed_amount=self.consumed_amount, + available_amount=self.available_amount, + usage_rate=self.usage_rate, + ) diff --git a/server/src/app/services/employee.py b/server/src/app/services/employee.py index 50b74df..d1b8ff3 100644 --- a/server/src/app/services/employee.py +++ b/server/src/app/services/employee.py @@ -128,6 +128,7 @@ class EmployeeService: for status in STATUS_ORDER ] + visible_role_codes = {item["role_code"] for item in ROLE_DEFINITIONS} role_options = [ EmployeeRoleOptionRead( id=role.role_code, @@ -137,6 +138,7 @@ class EmployeeService: permissions=list(ROLE_PERMISSION_MAP.get(role.role_code, [])), ) for role in self._sorted_roles(self.repository.list_roles()) + if role.role_code in visible_role_codes ] canonical_department_codes = set(CANONICAL_DEPARTMENT_CODES) @@ -470,6 +472,11 @@ class EmployeeService: def _seed_roles(self) -> None: existing_by_code = {role.role_code: role for role in self.repository.list_roles()} + legacy_auditor = existing_by_code.get("auditor") + if legacy_auditor is not None and "budget_monitor" not in existing_by_code: + legacy_auditor.role_code = "budget_monitor" + existing_by_code["budget_monitor"] = legacy_auditor + existing_by_code.pop("auditor", None) for definition in ROLE_DEFINITIONS: role = existing_by_code.get(definition["role_code"]) @@ -481,6 +488,9 @@ class EmployeeService: ) self.db.add(role) existing_by_code[role.role_code] = role + else: + role.name = definition["name"] + role.description = definition["description"] self.db.flush() diff --git a/server/src/app/services/employee_seed_part1.py b/server/src/app/services/employee_seed_part1.py index d1fd4f7..6934128 100644 --- a/server/src/app/services/employee_seed_part1.py +++ b/server/src/app/services/employee_seed_part1.py @@ -99,7 +99,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [ "spotlight": False, "updated_at": "2026-05-07 09:35", "last_sync_at": "2026-05-07 09:10", - "role_codes": ["finance", "auditor"], + "role_codes": ["finance", "budget_monitor"], }, { "employee_no": "E10289", @@ -143,7 +143,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [ "spotlight": False, "updated_at": "2026-05-05 09:18", "last_sync_at": "2026-05-05 09:18", - "role_codes": ["manager", "auditor"], + "role_codes": ["manager", "budget_monitor"], }, { "employee_no": "E11618", @@ -363,7 +363,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [ "spotlight": False, "updated_at": "2026-05-06 13:08", "last_sync_at": "2026-05-06 13:08", - "role_codes": ["user", "approver", "auditor"], + "role_codes": ["user", "approver", "budget_monitor"], }, { "employee_no": "E11991", diff --git a/server/src/app/services/employee_seed_part2.py b/server/src/app/services/employee_seed_part2.py index 772dcc5..1489b08 100644 --- a/server/src/app/services/employee_seed_part2.py +++ b/server/src/app/services/employee_seed_part2.py @@ -87,7 +87,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [ "spotlight": True, "updated_at": "2026-05-07 09:52", "last_sync_at": "2026-05-07 09:52", - "role_codes": ["auditor", "finance"], + "role_codes": ["budget_monitor", "finance"], "history": [ { "action": "更新审计观察范围", @@ -121,7 +121,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [ "spotlight": False, "updated_at": "2026-05-07 08:58", "last_sync_at": "2026-05-07 08:40", - "role_codes": ["auditor"], + "role_codes": ["budget_monitor"], }, { "employee_no": "E12688", @@ -385,7 +385,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [ "spotlight": False, "updated_at": "2026-05-03 13:18", "last_sync_at": "2026-05-03 13:18", - "role_codes": ["auditor"], + "role_codes": ["budget_monitor"], }, { "employee_no": "E12790", @@ -407,6 +407,6 @@ EMPLOYEE_DEFINITIONS_PART_2 = [ "spotlight": False, "updated_at": "2026-05-06 08:56", "last_sync_at": "2026-05-06 08:56", - "role_codes": ["user", "auditor"], + "role_codes": ["user", "budget_monitor"], }, ] diff --git a/server/src/app/services/employee_seed_roles.py b/server/src/app/services/employee_seed_roles.py index 80765a4..5634f2c 100644 --- a/server/src/app/services/employee_seed_roles.py +++ b/server/src/app/services/employee_seed_roles.py @@ -5,7 +5,7 @@ ROLE_DISPLAY_ORDER = { "finance": 2, "approver": 3, "executive": 4, - "auditor": 5, + "budget_monitor": 5, "user": 6, } @@ -13,7 +13,7 @@ ROLE_DEFINITIONS = [ { "role_code": "user", "name": "使用者", - "description": "可以发起报销、查看个人单据和使用 AI 助手。", + "description": "可以发起费用申请、报销、查看个人单据和使用 AI 助手。", }, { "role_code": "finance", @@ -27,8 +27,8 @@ ROLE_DEFINITIONS = [ }, { "role_code": "executive", - "name": "高级管理人员", - "description": "可以查看跨部门数据看板与关键审批结果。", + "name": "高级财务人员", + "description": "可以查看跨部门预算、经营看板与关键财务审批结果。", }, { "role_code": "approver", @@ -36,17 +36,17 @@ ROLE_DEFINITIONS = [ "description": "可以处理审批中心中的待审单据。", }, { - "role_code": "auditor", - "name": "审计观察员", - "description": "可以查看变更记录和权限调整历史。", + "role_code": "budget_monitor", + "name": "预算监控员", + "description": "可以查看本部门预算执行、预警和占用情况。", }, ] ROLE_PERMISSION_MAP = { - "user": ["可发起差旅申请与报销", "可查看个人单据与票据识别结果"], + "user": ["可发起费用申请与报销", "可查看个人单据与票据识别结果"], "finance": ["可处理财务复核任务", "可查看风险校验与财务知识库"], "manager": ["可维护员工档案与组织结构", "可配置系统角色与访问边界"], - "executive": ["可查看跨部门经营看板", "可处理高金额报销最终审批"], + "executive": ["可查看全部部门预算", "可维护预算额度与处理关键财务审批"], "approver": ["可处理本部门待审单据", "可查看审批链路与 SLA 状态"], - "auditor": ["可查看权限变更与审计留痕", "可导出员工权限观察记录"], + "budget_monitor": ["可查看本部门预算执行", "可跟踪本部门预算预警与占用"], } diff --git a/server/src/app/services/expense_claim_access_policy.py b/server/src/app/services/expense_claim_access_policy.py index e3039aa..01f1bd4 100644 --- a/server/src/app/services/expense_claim_access_policy.py +++ b/server/src/app/services/expense_claim_access_policy.py @@ -13,7 +13,7 @@ from app.models.organization import OrganizationUnit PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"} -ARCHIVE_CENTER_ROLE_CODES = {"finance", "executive", "auditor"} +ARCHIVE_CENTER_ROLE_CODES = {"finance", "executive"} APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"} CLAIM_DELETE_ROLE_CODES = {"executive"} ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid") diff --git a/server/src/app/services/expense_claim_budget_flow.py b/server/src/app/services/expense_claim_budget_flow.py new file mode 100644 index 0000000..b098e2a --- /dev/null +++ b/server/src/app/services/expense_claim_budget_flow.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from typing import Any + +from app.api.deps import CurrentUserContext +from app.models.financial_record import ExpenseClaim +from app.services.budget import BudgetService + + +class ExpenseClaimBudgetFlowMixin: + def _reserve_budget_for_submission( + self, + claim: ExpenseClaim, + current_user: CurrentUserContext, + *, + is_application_claim: bool, + ) -> list[dict[str, Any]]: + source_type = "application" if is_application_claim else "claim" + return BudgetService(self.db).reserve_for_claim( + claim, + source_type=source_type, + operator=self._resolve_budget_operator(current_user), + ) + + def _release_budget_for_return( + self, + claim: ExpenseClaim, + current_user: CurrentUserContext, + *, + reason: str, + ) -> list[dict[str, Any]]: + is_application_claim = self._is_expense_application_claim(claim) + source_type = "application" if is_application_claim else "claim" + return BudgetService(self.db).release_for_claim( + claim, + source_type=source_type, + operator=self._resolve_budget_operator(current_user), + reason=reason, + ) + + def _release_budget_for_delete( + self, + claim: ExpenseClaim, + current_user: CurrentUserContext, + ) -> None: + is_application_claim = self._is_expense_application_claim(claim) + source_type = "application" if is_application_claim else "claim" + BudgetService(self.db).release_for_claim( + claim, + source_type=source_type, + operator=self._resolve_budget_operator(current_user), + reason="单据删除释放预算预占", + ) + + def _consume_budget_for_finance_approval( + self, + claim: ExpenseClaim, + current_user: CurrentUserContext, + ) -> dict[str, Any] | None: + return BudgetService(self.db).consume_for_claim( + claim, + operator=self._resolve_budget_operator(current_user), + reason="财务终审通过核销预算", + ) + + def _transfer_application_budget_to_reimbursement( + self, + *, + application_claim: ExpenseClaim, + draft_claim: ExpenseClaim, + current_user: CurrentUserContext, + ) -> dict[str, Any] | None: + return BudgetService(self.db).transfer_application_reservation( + application_claim=application_claim, + draft_claim=draft_claim, + operator=self._resolve_budget_operator(current_user), + ) + + @staticmethod + def _append_budget_flags( + risk_flags: list[Any] | None, + budget_flags: list[dict[str, Any]] | dict[str, Any] | None, + ) -> list[Any]: + if budget_flags is None: + return list(risk_flags or []) + if isinstance(budget_flags, dict): + next_flags = [budget_flags] + else: + next_flags = list(budget_flags or []) + if not next_flags: + return list(risk_flags or []) + return [*list(risk_flags or []), *next_flags] + + @staticmethod + def _resolve_budget_operator(current_user: CurrentUserContext) -> str: + return current_user.name or current_user.username or "system" diff --git a/server/src/app/services/expense_claim_platform_risk.py b/server/src/app/services/expense_claim_platform_risk.py index 5682bb7..72f1331 100644 --- a/server/src/app/services/expense_claim_platform_risk.py +++ b/server/src/app/services/expense_claim_platform_risk.py @@ -10,9 +10,11 @@ from app.models.agent_asset import AgentAsset from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY +from app.services.budget import BudgetService from app.services.expense_rule_runtime import ( RuntimeTravelPolicy, ) +from app.services.expense_type_keywords import resolve_expense_type_code_from_text from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor @@ -29,6 +31,16 @@ class ExpenseClaimPlatformRiskMixin: return {"flags": [], "blocking_reasons": []} contexts = self._build_claim_attachment_contexts(claim) + contexts.append( + { + "index": len(contexts) + 1, + "item": None, + "document_info": {}, + "ocr_text": "", + "ocr_summary": "", + "budget_context": BudgetService(self.db).build_claim_budget_context(claim), + } + ) flags: list[dict[str, Any]] = [] blocking_reasons: list[str] = [] @@ -163,24 +175,18 @@ class ExpenseClaimPlatformRiskMixin: if min_attachments and int(claim.invoice_count or 0) < min_attachments and not contexts: return False - expense_types = { - str(claim.expense_type or "").strip().lower(), - *{ - str(item.item_type or "").strip().lower() - for item in list(claim.items or []) - if str(item.item_type or "").strip() - }, - } + expense_types = self._normalize_expense_type_values( + str(claim.expense_type or ""), + *[str(item.item_type or "") for item in list(claim.items or [])], + ) domains = { str(value or "").strip().lower() for value in list(applies_to.get("domains") or []) if str(value or "").strip() } - configured_expense_types = { - str(value or "").strip().lower() - for value in list(applies_to.get("expense_types") or []) - if str(value or "").strip() - } + configured_expense_types = self._normalize_expense_type_values( + *[str(value or "") for value in list(applies_to.get("expense_types") or [])] + ) if configured_expense_types and not (expense_types & configured_expense_types): return False @@ -193,6 +199,19 @@ class ExpenseClaimPlatformRiskMixin: return True + @staticmethod + def _normalize_expense_type_values(*values: str) -> set[str]: + normalized: set[str] = set() + for value in values: + raw = str(value or "").strip() + if not raw: + continue + normalized.add(raw.lower()) + resolved = resolve_expense_type_code_from_text(raw) + if resolved: + normalized.add(resolved) + return normalized + def _risk_domains_match_claim( self, domains: set[str], @@ -213,6 +232,9 @@ class ExpenseClaimPlatformRiskMixin: } ) + if "expense" in domains: + return True + if "travel" in domains: if expense_types & {"travel", "hotel", "transport"}: return True diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index 3d68c5a..611d7af 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -41,6 +41,7 @@ from app.services.expense_claim_application_handoff import ExpenseClaimApplicati from app.services.expense_claim_attachment_analysis import ExpenseClaimAttachmentAnalysisMixin from app.services.expense_claim_attachment_document import ExpenseClaimAttachmentDocumentMixin from app.services.expense_claim_attachment_operations import ExpenseClaimAttachmentOperationsMixin +from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin from app.services.expense_claim_document_item_builder import ExpenseClaimDocumentItemBuilderMixin from app.services.expense_claim_document_parsing import ExpenseClaimDocumentParsingMixin from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin @@ -127,6 +128,7 @@ from app.services.ocr import OcrService class ExpenseClaimService( ExpenseClaimApplicationHandoffMixin, + ExpenseClaimBudgetFlowMixin, ExpenseClaimAttachmentOperationsMixin, ExpenseClaimReviewPreviewMixin, ExpenseClaimDraftFlowMixin, @@ -437,6 +439,11 @@ class ExpenseClaimService( if missing_fields: raise ExpenseClaimSubmissionBlockedError(missing_fields) + budget_flags = self._reserve_budget_for_submission( + claim, + current_user, + is_application_claim=is_application_claim, + ) before_json = self._serialize_claim(claim) if is_application_claim: submitted_at = datetime.now(UTC) @@ -453,7 +460,7 @@ class ExpenseClaimService( "event_type": "expense_application_submission", "severity": "info", "label": "申请提交", - "message": "费用申请已提交至直属领导审批,并同步纳入预算管理口径。", + "message": "费用申请已提交至直属领导审批,请等待审核结果。", "previous_status": str(claim.status or "").strip(), "previous_approval_stage": str(claim.approval_stage or "").strip(), "next_status": "submitted", @@ -462,9 +469,10 @@ class ExpenseClaimService( } claim.status = "submitted" claim.approval_stage = "直属领导审批" - claim.risk_flags_json = [*preserved_flags, submit_flag] + claim.risk_flags_json = self._append_budget_flags([*preserved_flags, submit_flag], budget_flags) claim.submitted_at = submitted_at else: + claim.risk_flags_json = self._append_budget_flags(claim.risk_flags_json, budget_flags) review_result = self._run_ai_submission_review(claim) claim.status = str(review_result.get("status") or "supplement") @@ -520,11 +528,12 @@ class ExpenseClaimService( if not self._access_policy.has_claim_delete_access(current_user): self._ensure_draft_claim(claim) if not self._access_policy.is_claim_owned_by_current_user(claim, current_user): - raise ValueError("只有高级管理人员可以删除非本人单据,申请人仅可删除自己的草稿、待补充或退回单据。") + raise ValueError("只有高级财务人员可以删除非本人单据,申请人仅可删除自己的草稿、待补充或退回单据。") before_json = self._serialize_claim(claim) resource_id = claim.id + self._release_budget_for_delete(claim, current_user) self._attachment_storage.delete_claim_files(claim) self.db.delete(claim) self.db.commit() @@ -554,7 +563,7 @@ class ExpenseClaimService( return None if not self._access_policy.can_return_claim(current_user, claim): - raise ValueError("只有财务人员、高级管理人员或当前审批人可以退回报销单。") + raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。") normalized_status = str(claim.status or "").strip().lower() if normalized_status == "draft": @@ -619,10 +628,18 @@ class ExpenseClaimService( if unknown_reason_codes: return_flag["unknown_reason_codes"] = unknown_reason_codes + budget_flags = self._release_budget_for_return( + claim, + current_user, + reason=message, + ) claim.status = "returned" claim.approval_stage = "待提交" claim.submitted_at = None - claim.risk_flags_json = [*list(claim.risk_flags_json or []), return_flag] + claim.risk_flags_json = self._append_budget_flags( + [*list(claim.risk_flags_json or []), return_flag], + budget_flags, + ) self.db.commit() self.db.refresh(claim) @@ -691,6 +708,11 @@ class ExpenseClaimService( 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, @@ -723,7 +745,21 @@ class ExpenseClaimService( approval_flag=approval_flag, operator=operator, ) - claim.risk_flags_json = [*list(claim.risk_flags_json or []), approval_flag] + 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) diff --git a/server/src/app/services/finance_rule_catalog.py b/server/src/app/services/finance_rule_catalog.py new file mode 100644 index 0000000..9fd6c13 --- /dev/null +++ b/server/src/app/services/finance_rule_catalog.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from app.services.agent_asset_spreadsheet import COMPANY_TRAVEL_EXPENSE_RULE_CODE + +DEPRECATED_FINANCE_RULE_CODES = ( + "rule.expense.company_transport_hotel_detail_reimbursement", + "rule.expense.company_meal_expense_reimbursement", + "rule.expense.company_marketing_expense_reimbursement", + "rule.expense.company_meeting_expense_reimbursement", + "rule.expense.company_office_expense_reimbursement", + "rule.expense.company_training_expense_reimbursement", + "rule.expense.company_software_expense_reimbursement", + "rule.expense.company_welfare_expense_reimbursement", +) + +DEPRECATED_FINANCE_RULE_REPLACEMENTS = { + "rule.expense.company_transport_hotel_detail_reimbursement": ( + COMPANY_TRAVEL_EXPENSE_RULE_CODE + ), +} diff --git a/server/src/app/services/risk_rule_template_executor.py b/server/src/app/services/risk_rule_template_executor.py index bd2d345..ddbfdf5 100644 --- a/server/src/app/services/risk_rule_template_executor.py +++ b/server/src/app/services/risk_rule_template_executor.py @@ -519,6 +519,8 @@ class RiskRuleTemplateExecutor: ) if normalized.startswith("attachment."): return self._resolve_attachment_values(normalized.removeprefix("attachment."), contexts) + if normalized.startswith("budget."): + return self._resolve_budget_values(normalized.removeprefix("budget."), contexts) return [] @staticmethod @@ -566,6 +568,19 @@ class RiskRuleTemplateExecutor: values.extend(self._scan_document_values(document_info, field_key)) return self._normalize_values(values) + def _resolve_budget_values(self, field_key: str, contexts: list[dict[str, Any]]) -> list[str]: + values: list[Any] = [] + for context in contexts: + if not isinstance(context, dict): + continue + budget_context = context.get("budget_context") + if not isinstance(budget_context, dict): + continue + for key in {field_key, field_key.replace("_", ""), field_key.replace("-", "_")}: + if key in budget_context: + values.append(budget_context.get(key)) + return self._normalize_values(values) + def _scan_document_values(self, document_info: dict[str, Any], field_key: str) -> list[Any]: values: list[Any] = [] for key in {field_key, field_key.replace("_", ""), field_key.replace("_", "-")}: diff --git a/server/src/app/services/user_agent_application.py b/server/src/app/services/user_agent_application.py index 778fc45..326c152 100644 --- a/server/src/app/services/user_agent_application.py +++ b/server/src/app/services/user_agent_application.py @@ -164,7 +164,7 @@ class UserAgentApplicationMixin: f"申请单号:{application_no}", "申请信息:\n" + self._build_application_summary_table(facts), f"当前状态:{manager_name}审核中。", - "预算处理:用户预估费用已作为预算占用参考,等待领导审核确认。", + "费用预估:预计费用已随申请提交,等待领导审核确认。", ] ) diff --git a/server/tests/test_agent_asset_service.py b/server/tests/test_agent_asset_service.py index de12707..6a41ae7 100644 --- a/server/tests/test_agent_asset_service.py +++ b/server/tests/test_agent_asset_service.py @@ -32,6 +32,7 @@ from app.schemas.agent_asset import ( AgentAssetVersionCreate, ) from app.schemas.reimbursement import TravelReimbursementCalculatorRequest +from app.services import agent_foundation as agent_foundation_module from app.services.agent_asset_spreadsheet import ( COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, @@ -43,6 +44,9 @@ from app.services.agent_assets import AgentAssetService from app.services.agent_runs import AgentRunService from app.services.audit import AuditLogService from app.services.expense_rule_runtime import ExpenseRuleRuntimeService +from app.services.finance_rule_catalog import ( + DEPRECATED_FINANCE_RULE_CODES, +) from app.services.settings import OnlyOfficeRuntimeConfig from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService @@ -80,6 +84,7 @@ def isolate_rule_file_storage(tmp_path, monkeypatch) -> None: def build_session() -> Session: + agent_foundation_module._foundation_ready_keys.clear() engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, @@ -163,12 +168,59 @@ def test_finance_rules_use_risk_rule_scenario_categories() -> None: travel_config = travel_rule.config_json or {} communication_config = communication_rule.config_json or {} - assert travel_rule.scenario_json == ["差旅"] - assert travel_config["scenario_category"] == "差旅" - assert travel_config["ai_review_category"] == "差旅" - assert communication_rule.scenario_json == ["费用科目"] - assert communication_config["scenario_category"] == "费用科目" - assert communication_config["ai_review_category"] == "费用科目" + assert travel_rule.scenario_json == ["差旅费"] + assert travel_config["scenario_category"] == "差旅费" + assert travel_config["ai_review_category"] == "差旅费" + assert communication_rule.scenario_json == ["通信费"] + assert communication_config["scenario_category"] == "通信费" + assert communication_config["ai_review_category"] == "通信费" + + +def test_non_standard_finance_rule_spreadsheets_are_not_seeded() -> None: + with build_session() as db: + service = AgentAssetService(db) + service.list_assets(asset_type=AgentAssetType.RULE.value) + + for code in DEPRECATED_FINANCE_RULE_CODES: + asset = db.scalar(select(AgentAsset).where(AgentAsset.code == code)) + assert asset is None or asset.config_json["tag"] == "废弃规则" + + +def test_demo_budget_risk_rules_sync_with_finance_rule_references() -> None: + with build_session() as db: + service = AgentAssetService(db) + service.list_assets(asset_type=AgentAssetType.RULE.value) + + budget_rule = db.scalar( + select(AgentAsset).where( + AgentAsset.code == "risk.budget.available_balance_insufficient" + ) + ) + marketing_rule = db.scalar( + select(AgentAsset).where( + AgentAsset.code == "risk.application.marketing_without_campaign" + ) + ) + + assert budget_rule is not None + assert "差旅费" in budget_rule.scenario_json + assert "市场推广费" in budget_rule.scenario_json + assert "软件服务费" in budget_rule.scenario_json + assert budget_rule.config_json["budget_required"] is True + assert "marketing" in budget_rule.config_json["expense_types"] + assert budget_rule.config_json["business_stage"] == [ + "expense_application", + "reimbursement", + "budget_execution", + ] + assert budget_rule.config_json["finance_rule_code"] == "budget.execution.policy" + + assert marketing_rule is not None + assert marketing_rule.scenario_json == ["市场推广费"] + assert marketing_rule.config_json["finance_rule_code"] == "expense.application.policy" + assert marketing_rule.config_json["finance_rule_sheet"] == "费用申请前置规则" + assert marketing_rule.config_json["expense_types"] == ["marketing"] + assert marketing_rule.config_json["budget_required"] is True def test_agent_asset_service_can_activate_rule_after_review() -> None: diff --git a/server/tests/test_budget_endpoints.py b/server/tests/test_budget_endpoints.py new file mode 100644 index 0000000..616668d --- /dev/null +++ b/server/tests/test_budget_endpoints.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +from collections.abc import Generator +from datetime import UTC, datetime +from decimal import Decimal + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +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 + + +def build_session_factory() -> sessionmaker[Session]: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + return sessionmaker(bind=engine, autoflush=False, autocommit=False) + + +def build_client() -> tuple[TestClient, sessionmaker[Session]]: + session_factory = build_session_factory() + app = create_app() + + def override_db() -> Generator[Session, None, None]: + db = session_factory() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_db + return TestClient(app), session_factory + + +def seed_budget_allocations(db: Session) -> None: + now = datetime.now(UTC) + db.add_all( + [ + BudgetAllocation( + id="budget-market-travel", + budget_no="BUD-MARKET-TRAVEL", + fiscal_year=2026, + period_type="quarter", + period_key="2026Q2", + department_id="dept-market", + department_name="市场部", + cost_center="CC-4100", + project_code=None, + subject_code="travel", + subject_name="差旅费", + original_amount=Decimal("50000.00"), + adjusted_amount=Decimal("0.00"), + status="active", + warning_threshold=Decimal("80.00"), + control_action="block", + created_at=now, + updated_at=now, + ), + BudgetAllocation( + id="budget-finance-office", + budget_no="BUD-FINANCE-OFFICE", + fiscal_year=2026, + period_type="quarter", + period_key="2026Q2", + department_id="dept-finance", + department_name="财务部", + cost_center="CC-2100", + project_code=None, + subject_code="office", + subject_name="办公费", + original_amount=Decimal("30000.00"), + adjusted_amount=Decimal("0.00"), + status="active", + warning_threshold=Decimal("80.00"), + control_action="block", + created_at=now, + updated_at=now, + ), + BudgetAllocation( + id="budget-market-software-hidden", + budget_no="BUD-MARKET-SOFTWARE", + fiscal_year=2026, + period_type="quarter", + period_key="2026Q2", + department_id="dept-market", + department_name="市场部", + cost_center="CC-4100", + project_code=None, + subject_code="software", + subject_name="软件服务费", + original_amount=Decimal("90000.00"), + adjusted_amount=Decimal("0.00"), + status="active", + warning_threshold=Decimal("80.00"), + control_action="block", + created_at=now, + updated_at=now, + ), + ] + ) + db.commit() + + +def test_admin_can_view_all_budget_allocations_without_is_admin_header() -> None: + client, session_factory = build_client() + with session_factory() as db: + seed_budget_allocations(db) + + response = client.get( + "/api/v1/budgets/summary", + headers={"x-auth-username": "admin", "x-auth-role-codes": "manager"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert {item["department_name"] for item in payload["allocations"]} == {"市场部", "财务部"} + assert {item["subject_code"] for item in payload["allocations"]} == {"travel", "office"} + + +def test_budget_monitor_is_limited_to_own_department_scope() -> None: + client, session_factory = build_client() + with session_factory() as db: + seed_budget_allocations(db) + + response = client.get( + "/api/v1/budgets/summary?cost_center=CC-2100", + headers={ + "x-auth-username": "monitor@example.com", + "x-auth-role-codes": "budget_monitor", + "x-auth-cost-center": "CC-4100", + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert [item["cost_center"] for item in payload["allocations"]] == ["CC-4100"] + assert [item["subject_code"] for item in payload["allocations"]] == ["travel"] + + +def test_finance_user_cannot_enter_budget_center() -> None: + client, session_factory = build_client() + with session_factory() as db: + seed_budget_allocations(db) + + response = client.get( + "/api/v1/budgets/summary", + headers={"x-auth-username": "finance@example.com", "x-auth-role-codes": "finance"}, + ) + + assert response.status_code == 403 + + +def test_budget_monitor_cannot_edit_and_admin_can_edit() -> None: + client, session_factory = build_client() + with session_factory() as db: + seed_budget_allocations(db) + + payload = { + "fiscal_year": 2026, + "period_type": "quarter", + "period_key": "2026Q2", + "department_id": "dept-sales", + "department_name": "销售部", + "cost_center": "CC-5100", + "project_code": None, + "subject_code": "travel", + "subject_name": "差旅费", + "original_amount": "20000.00", + "warning_threshold": "80.00", + "control_action": "block", + "description": "销售部差旅预算", + } + + monitor_response = client.post( + "/api/v1/budgets/allocations", + json=payload, + headers={ + "x-auth-username": "monitor@example.com", + "x-auth-role-codes": "budget_monitor", + "x-auth-cost-center": "CC-4100", + }, + ) + assert monitor_response.status_code == 403 + + admin_response = client.post( + "/api/v1/budgets/allocations", + json=payload, + headers={"x-auth-username": "admin", "x-auth-role-codes": "manager"}, + ) + assert admin_response.status_code == 201 + assert admin_response.json()["department_name"] == "销售部" diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index e005c13..dd5b6c8 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +import uuid from datetime import UTC, date, datetime, timedelta from decimal import Decimal @@ -11,6 +12,7 @@ from sqlalchemy.pool import StaticPool from app.api.deps import CurrentUserContext from app.db.base import Base +from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction from app.models.employee import Employee from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.organization import OrganizationUnit @@ -75,6 +77,37 @@ def _count_claims(db: Session) -> int: return int(db.query(ExpenseClaim).count()) +def _seed_budget_allocation( + db: Session, + *, + department_id: str | None, + department_name: str, + subject_code: str = "travel", + amount: Decimal = Decimal("50000.00"), + period_key: str = "2026Q2", +) -> BudgetAllocation: + allocation = BudgetAllocation( + budget_no=f"BUD-TEST-{uuid.uuid4().hex[:8]}", + fiscal_year=2026, + period_type="quarter", + period_key=period_key, + department_id=department_id, + department_name=department_name, + cost_center=None, + project_code=None, + subject_code=subject_code, + subject_name=subject_code, + original_amount=amount, + adjusted_amount=Decimal("0.00"), + status="active", + warning_threshold=Decimal("80.00"), + control_action="block", + ) + db.add(allocation) + db.commit() + return allocation + + def test_validate_claim_for_submission_allows_office_claim_without_location() -> None: service = ExpenseClaimService.__new__(ExpenseClaimService) claim = build_claim(expense_type="office", location="待补充") @@ -2778,7 +2811,7 @@ def test_finance_can_return_but_cannot_delete_submitted_claim() -> None: for flag in returned.risk_flags_json ) - with pytest.raises(ValueError, match="只有高级管理人员可以删除"): + with pytest.raises(ValueError, match="只有高级财务人员可以删除"): service.delete_claim(claim_id, current_user) assert db.get(ExpenseClaim, claim_id) is not None @@ -3079,6 +3112,157 @@ def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch ) +def test_application_submit_reserves_budget_once() -> None: + current_user = CurrentUserContext( + username="application-budget-owner@example.com", + name="张三", + role_codes=["employee"], + is_admin=True, + ) + + with build_session() as db: + _seed_budget_allocation( + db, + department_id="dept-budget", + department_name="交付部", + amount=Decimal("50000.00"), + ) + claim = ExpenseClaim( + claim_no="APP-20260525-BUDGET", + employee_id="emp-budget", + employee_name="张三", + department_id="dept-budget", + 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, 25, 9, 0, tzinfo=UTC), + submitted_at=None, + status="draft", + approval_stage="待提交", + risk_flags_json=[], + ) + db.add(claim) + db.commit() + + submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user) + + assert submitted is not None + reservations = db.query(BudgetReservation).all() + assert len(reservations) == 1 + assert reservations[0].source_type == "application" + assert reservations[0].source_id == claim.id + assert reservations[0].amount == Decimal("12000.00") + transactions = db.query(BudgetTransaction).all() + assert any(item.transaction_type == "reserve" for item in transactions) + assert any( + isinstance(flag, dict) + and flag.get("source") == "budget_control" + and flag.get("event_type") == "budget_reserved" + for flag in submitted.risk_flags_json + ) + + +def test_application_submit_blocks_when_budget_insufficient_without_state_change() -> None: + current_user = CurrentUserContext( + username="application-budget-block@example.com", + name="张三", + role_codes=["employee"], + is_admin=True, + ) + + with build_session() as db: + _seed_budget_allocation( + db, + department_id="dept-budget-block", + department_name="交付部", + amount=Decimal("1000.00"), + ) + claim = ExpenseClaim( + claim_no="APP-20260525-BLOCK", + employee_id="emp-budget-block", + employee_name="张三", + department_id="dept-budget-block", + 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, 25, 9, 0, tzinfo=UTC), + submitted_at=None, + status="draft", + approval_stage="待提交", + risk_flags_json=[], + ) + db.add(claim) + db.commit() + + with pytest.raises(ValueError): + ExpenseClaimService(db).submit_claim(claim.id, current_user) + + db.refresh(claim) + assert claim.status == "draft" + assert db.query(BudgetReservation).count() == 0 + assert db.query(BudgetTransaction).count() == 0 + + +def test_application_submit_skips_budget_for_non_demo_subject() -> None: + current_user = CurrentUserContext( + username="application-budget-skip@example.com", + name="张三", + role_codes=["employee"], + is_admin=True, + ) + + with build_session() as db: + _seed_budget_allocation( + db, + department_id="dept-budget-skip", + department_name="交付部", + amount=Decimal("1000.00"), + ) + claim = ExpenseClaim( + claim_no="APP-20260525-SKIP", + employee_id="emp-budget-skip", + employee_name="张三", + department_id="dept-budget-skip", + department_name="交付部", + project_code=None, + expense_type="software_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() + + submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user) + + assert submitted is not None + assert submitted.status == "submitted" + assert submitted.approval_stage == "直属领导审批" + assert db.query(BudgetReservation).count() == 0 + assert db.query(BudgetTransaction).count() == 0 + assert not any( + isinstance(flag, dict) and str(flag.get("source") or "").strip() == "budget_control" + for flag in submitted.risk_flags_json + ) + + def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() -> None: current_user = CurrentUserContext( username="manager-application-approve@example.com", @@ -3175,6 +3359,80 @@ def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() - ) +def test_application_approval_transfers_budget_reservation_to_reimbursement_draft() -> None: + owner = CurrentUserContext( + username="application-budget-owner-approve@example.com", + name="张三", + role_codes=["employee"], + is_admin=False, + ) + manager_user = CurrentUserContext( + username="manager-application-budget@example.com", + name="李经理", + role_codes=["manager"], + is_admin=False, + ) + + with build_session() as db: + manager = Employee( + employee_no="M-BUDGET-APP", + name="李经理", + email="manager-application-budget@example.com", + ) + employee = Employee( + employee_no="E-BUDGET-APP", + name="张三", + email="application-budget-owner-approve@example.com", + manager=manager, + ) + db.add_all([manager, employee]) + db.flush() + _seed_budget_allocation( + db, + department_id="dept-budget-transfer", + department_name="交付部", + amount=Decimal("50000.00"), + ) + claim = ExpenseClaim( + claim_no="APP-20260525-TRANSFER", + employee_id=employee.id, + employee_name="张三", + department_id="dept-budget-transfer", + 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, 25, 9, 0, tzinfo=UTC), + submitted_at=None, + status="draft", + approval_stage="待提交", + risk_flags_json=[], + ) + db.add(claim) + db.commit() + 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() + reservation = db.query(BudgetReservation).one() + + assert approved is not None + assert reservation.source_type == "claim" + assert reservation.source_id == generated_draft.id + assert reservation.source_no == generated_draft.claim_no + assert any(item.transaction_type == "transfer" for item in db.query(BudgetTransaction).all()) + assert any( + isinstance(flag, dict) + and flag.get("event_type") == "budget_reservation_transferred" + for flag in generated_draft.risk_flags_json + ) + + def test_direct_manager_approval_requires_leader_opinion() -> None: current_user = CurrentUserContext( username="manager-application-required-opinion@example.com", @@ -3232,6 +3490,70 @@ def test_direct_manager_approval_requires_leader_opinion() -> None: assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0 +def test_finance_approve_reimbursement_consumes_budget_reservation() -> None: + current_user = CurrentUserContext( + username="finance-budget-approve@example.com", + name="财务", + role_codes=["finance"], + is_admin=False, + ) + + with build_session() as db: + allocation = _seed_budget_allocation( + db, + department_id="dept-finance-budget", + department_name="交付部", + amount=Decimal("50000.00"), + ) + claim = ExpenseClaim( + claim_no="RE-20260525-BUDGET", + employee_id="emp-finance-budget", + employee_name="张三", + department_id="dept-finance-budget", + department_name="交付部", + project_code=None, + expense_type="travel", + reason="客户现场交付", + location="上海", + amount=Decimal("12000.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC), + status="submitted", + approval_stage="财务审批", + risk_flags_json=[], + ) + db.add(claim) + db.flush() + reservation = BudgetReservation( + reservation_no=f"BRS-TEST-{uuid.uuid4().hex[:8]}", + allocation_id=allocation.id, + source_type="claim", + source_id=claim.id, + source_no=claim.claim_no, + source_status="active", + amount=Decimal("12000.00"), + context_json={}, + ) + db.add(reservation) + db.commit() + + approved = ExpenseClaimService(db).approve_claim(claim.id, current_user, opinion="同意入账") + + assert approved is not None + db.refresh(reservation) + assert reservation.source_status == "consumed" + assert reservation.consumed_amount == Decimal("12000.00") + assert db.query(BudgetTransaction).filter(BudgetTransaction.transaction_type == "consume").count() == 1 + assert any( + isinstance(flag, dict) + and flag.get("source") == "budget_control" + and flag.get("event_type") == "budget_consumed" + for flag in approved.risk_flags_json + ) + + def test_finance_can_approve_claim_to_archive_stage() -> None: current_user = CurrentUserContext( username="finance-approve@example.com", diff --git a/server/tests/test_risk_rule_generation.py b/server/tests/test_risk_rule_generation.py index 8cef6ec..4187b88 100644 --- a/server/tests/test_risk_rule_generation.py +++ b/server/tests/test_risk_rule_generation.py @@ -10,7 +10,12 @@ from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool -from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentReviewStatus +from app.core.agent_enums import ( + AgentAssetDomain, + AgentAssetStatus, + AgentAssetType, + AgentReviewStatus, +) from app.db.base import Base from app.models.agent_asset import AgentAsset from app.models.employee import Employee @@ -27,6 +32,7 @@ from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY from app.services.agent_assets import AgentAssetService from app.services.agent_foundation_risk_rules import AgentFoundationRiskRuleMixin +from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMixin from app.services.risk_rule_flow_diagram import ( RiskRuleFlowDiagramRenderer, RiskRuleFlowDiagramSpec, @@ -384,6 +390,92 @@ def test_platform_risk_sync_skips_natural_language_drafts() -> None: ) +def test_stale_demo_risk_rules_are_marked_deprecated() -> None: + class FoundationRiskSyncProbe(AgentFoundationRiskRuleMixin): + def __init__(self, db: Session) -> None: + self.db = db + + with build_session() as db: + stale_asset = AgentAsset( + asset_type=AgentAssetType.RULE.value, + code="risk.standard.training_per_capita_over_limit", + name="培训费人均超标准", + domain=AgentAssetDomain.EXPENSE.value, + owner="风控与审计部", + reviewer="顾承宇", + status=AgentAssetStatus.ACTIVE.value, + config_json={ + "enabled": True, + "tag": "风险规则", + "source_ref": "费用管控 Demo 风险规则库", + }, + ) + kept_asset = AgentAsset( + asset_type=AgentAssetType.RULE.value, + code="risk.standard.software_contract_missing", + name="软件服务费缺少合同", + domain=AgentAssetDomain.EXPENSE.value, + owner="风控与审计部", + reviewer="顾承宇", + status=AgentAssetStatus.ACTIVE.value, + config_json={ + "enabled": True, + "tag": "风险规则", + "source_ref": "费用管控 Demo 风险规则库", + }, + ) + db.add_all([stale_asset, kept_asset]) + db.flush() + + FoundationRiskSyncProbe(db)._hide_stale_demo_risk_rules( + {"risk.standard.software_contract_missing"} + ) + + assert stale_asset.status == AgentAssetStatus.DISABLED.value + assert stale_asset.config_json["tag"] == "废弃风险规则" + assert stale_asset.config_json["enabled"] is False + assert kept_asset.status == AgentAssetStatus.ACTIVE.value + assert kept_asset.config_json["tag"] == "风险规则" + + +def test_platform_risk_applies_to_chinese_expense_type_labels() -> None: + class PlatformRiskProbe(ExpenseClaimPlatformRiskMixin): + pass + + claim = ExpenseClaim( + claim_no="TEST-MARKETING-RISK", + employee_name="测试员工", + department_name="市场部", + expense_type="市场推广费", + reason="品牌投放活动", + amount=Decimal("20000.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime.now(UTC), + status="draft", + ) + manifest = { + "applies_to": { + "domains": ["expense"], + "expense_types": ["marketing"], + } + } + + assert PlatformRiskProbe()._risk_manifest_applies_to_claim( + manifest, + claim=claim, + contexts=[], + ) + + manifest["applies_to"]["expense_types"] = ["software"] + + assert not PlatformRiskProbe()._risk_manifest_applies_to_claim( + manifest, + claim=claim, + contexts=[], + ) + + def test_risk_rule_flow_diagram_uses_risk_level_palette() -> None: renderer = RiskRuleFlowDiagramRenderer() @@ -449,7 +541,10 @@ def test_current_keyword_city_consistency_rule_hits_ticket_city_mismatch() -> No "attachment.route_cities", "item.item_location", ], - "natural_language": "差旅报销时,交通票或住宿票据中的城市必须与申报目的地一致;未说明绕行、跨城或改签原因时标记风险。", + "natural_language": ( + "差旅报销时,交通票或住宿票据中的城市必须与申报目的地一致;" + "未说明绕行、跨城或改签原因时标记风险。" + ), "condition_summary": "检查住宿城市、申报地点、行程城市是否一致", "keywords": ["绕行", "跨城", "改签", "变更"], }, @@ -659,7 +754,10 @@ def test_travel_route_rule_hits_unexpected_intermediate_city_even_when_returning "home_city_fields": ["employee.location"], "exception_fields": ["claim.reason"], "exception_keywords": ["绕行", "跨城办事", "临时改签"], - "condition_summary": "A=票据路线城市,B=申报城市,C=员工常驻地,A中出现B∪C之外城市则命中。", + "condition_summary": ( + "A=票据路线城市,B=申报城市,C=员工常驻地," + "A中出现B∪C之外城市则命中。" + ), }, "outcomes": {"fail": {"severity": "high"}}, } @@ -700,7 +798,11 @@ def test_travel_route_rule_hits_unexpected_intermediate_city_even_when_returning "document_info": { "route_cities": ["上海", "北京", "武汉"], "fields": [ - {"key": "route_cities", "label": "行程城市", "value": ["上海", "北京", "武汉"]} + { + "key": "route_cities", + "label": "行程城市", + "value": ["上海", "北京", "武汉"], + } ], }, "ocr_text": "上海 到 北京 到 武汉", diff --git a/web/src/assets/styles/views/budget-center-dialog.css b/web/src/assets/styles/views/budget-center-dialog.css index bb11be7..bb8acf0 100644 --- a/web/src/assets/styles/views/budget-center-dialog.css +++ b/web/src/assets/styles/views/budget-center-dialog.css @@ -63,11 +63,24 @@ .budget-edit-body { min-height: 0; padding: 18px 24px 16px; - overflow: auto; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.budget-edit-section { + display: flex; + flex-direction: column; + min-height: 0; +} + +.budget-edit-section:first-child { + flex-shrink: 0; } .budget-edit-section + .budget-edit-section { margin-top: 18px; + flex: 1; } .budget-edit-section h3 { @@ -76,6 +89,7 @@ font-size: 15px; line-height: 1.35; font-weight: 800; + flex-shrink: 0; } .budget-edit-form-grid { @@ -153,6 +167,9 @@ border: 1px solid #edf1f6; border-radius: 8px; overflow-x: auto; + overflow-y: auto; + flex: 1; + min-height: 0; } .budget-edit-table { @@ -243,27 +260,35 @@ font-size: 13px; font-weight: 800; cursor: pointer; + flex-shrink: 0; } .budget-edit-total { height: 42px; margin-top: 8px; padding: 0 14px; - display: grid; - grid-template-columns: 120px 1fr; + display: flex; + justify-content: flex-end; + gap: 12px; align-items: center; border: 1px solid #edf1f6; border-radius: 8px; background: #fbfcfe; + flex-shrink: 0; } -.budget-edit-total span, -.budget-edit-total strong { +.budget-edit-total span { color: #111827; font-size: 14px; font-weight: 800; } +.budget-edit-total strong { + color: #059669; + font-size: 16px; + font-weight: 800; +} + .budget-edit-foot { padding: 18px 24px 20px; display: flex; diff --git a/web/src/assets/styles/views/budget-center-view.css b/web/src/assets/styles/views/budget-center-view.css index 5445707..94d1813 100644 --- a/web/src/assets/styles/views/budget-center-view.css +++ b/web/src/assets/styles/views/budget-center-view.css @@ -252,6 +252,10 @@ gap: 10px; } +.budget-work-grid.single-department { + grid-template-columns: minmax(0, 1fr); +} + .budget-department-panel, .budget-table-panel, .budget-chart-panel, diff --git a/web/src/components/shared/ExpenseApplicationDialog.vue b/web/src/components/shared/ExpenseApplicationDialog.vue index 0ea8062..e7f380e 100644 --- a/web/src/components/shared/ExpenseApplicationDialog.vue +++ b/web/src/components/shared/ExpenseApplicationDialog.vue @@ -18,7 +18,7 @@ id="application-intent-input" v-model="draft" rows="5" - placeholder="例如:申请下周去北京做客户现场验收,差旅预算18000元" + placeholder="例如:申请下周去北京做客户现场验收,预计费用18000元" >
diff --git a/web/src/composables/useAppShell.js b/web/src/composables/useAppShell.js index ac9413c..fe1a87e 100644 --- a/web/src/composables/useAppShell.js +++ b/web/src/composables/useAppShell.js @@ -113,7 +113,7 @@ export function useAppShell() { return { title: isApplicationDocument ? '申请单详情' : '报销单详情', desc: isApplicationDocument - ? '查看申请信息、预计金额、审批进度与预算管理口径。' + ? '查看申请信息、预计金额与审批进度。' : '查看报销明细、票据材料、审批进度与风险提示。' } } diff --git a/web/src/composables/useRequests.js b/web/src/composables/useRequests.js index fa873ad..adc4c3e 100644 --- a/web/src/composables/useRequests.js +++ b/web/src/composables/useRequests.js @@ -764,7 +764,7 @@ export function mapExpenseClaimToRequest(claim) { riskSummary, attachmentSummary: isApplicationDocument ? '申请单' : (invoiceCount > 0 ? `${invoiceCount} 张票据` : '无'), expenseTableSummary: isApplicationDocument - ? '预计金额已纳入预算管理口径' + ? '预计金额已随申请提交' : expenseItems.length ? (invoiceCount > 0 ? `共 ${expenseItems.length} 条费用明细,已关联 ${invoiceCount} 张票据` diff --git a/web/src/composables/useSystemState.js b/web/src/composables/useSystemState.js index 8a72c9d..aa30453 100644 --- a/web/src/composables/useSystemState.js +++ b/web/src/composables/useSystemState.js @@ -107,7 +107,7 @@ function buildAnonymousUser() { } } -function buildLegacyAdminUser(username = '') { +function buildLegacyAdminUser(username = '') { const normalized = String(username || '').trim() const name = normalized || DEFAULT_USER_NAME @@ -129,10 +129,25 @@ function buildLegacyAdminUser(username = '') { email: '', avatar: name.slice(0, 1).toUpperCase(), isAdmin: true - } -} - -function readStoredUser() { + } +} + +function resolvePlatformAdminFlag(payload, roleCodes = []) { + const username = String(payload?.username || payload?.account || '').trim().toLowerCase() + const role = String(payload?.role || '').trim().toLowerCase() + const normalizedRoleCodes = roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean) + + return ( + Boolean(payload?.isAdmin) + || username === 'admin' + || role === 'admin' + || role === '管理员' + || role === '系统管理员' + || normalizedRoleCodes.includes('admin') + ) +} + +function readStoredUser() { if (typeof window === 'undefined') { return buildAnonymousUser() } @@ -164,9 +179,9 @@ function readStoredUser() { roleCodes, email: String(payload.email || ''), avatar: String(payload.avatar || name.slice(0, 1).toUpperCase()), - isAdmin: Boolean(payload.isAdmin) - } - } + isAdmin: resolvePlatformAdminFlag(payload, roleCodes) + } + } } catch { return buildLegacyAdminUser(readStoredUsername()) } @@ -609,8 +624,14 @@ async function handleLogin(credentials) { password: credentials.password }) - const user = response?.user || buildAnonymousUser() - loggedIn.value = true + const responseUser = response?.user || buildAnonymousUser() + const responseRoleCodes = Array.isArray(responseUser.roleCodes) ? responseUser.roleCodes.filter(Boolean) : [] + const user = { + ...responseUser, + roleCodes: responseRoleCodes, + isAdmin: resolvePlatformAdminFlag(responseUser, responseRoleCodes) + } + loggedIn.value = true persistAuthState(true, user) currentUser.value = user touchAuthActivity(true) diff --git a/web/src/services/api.js b/web/src/services/api.js index ec7058b..1eed858 100644 --- a/web/src/services/api.js +++ b/web/src/services/api.js @@ -46,11 +46,13 @@ function readCurrentUserHeaders() { const username = String(payload?.username || '').trim() const name = String(payload?.name || username).trim() const roleCodes = Array.isArray(payload?.roleCodes) ? payload.roleCodes.filter(Boolean) : [] - const isAdmin = Boolean(payload?.isAdmin) + const isAdmin = resolveStoredUserAdminFlag(payload, roleCodes) const department = String(payload?.department || payload?.departmentName || '').trim() + const costCenter = String(payload?.costCenter || payload?.cost_center || '').trim() const safeUsername = pickSafeHeaderValue(username) const safeName = pickSafeHeaderValue(name) const safeDepartment = pickSafeHeaderValue(department) + const safeCostCenter = pickSafeHeaderValue(costCenter) if (!safeUsername && !safeName) { return {} @@ -73,11 +75,30 @@ function readCurrentUserHeaders() { headers['x-auth-department'] = safeDepartment } + if (safeCostCenter) { + headers['x-auth-cost-center'] = safeCostCenter + } + return headers } catch { return {} } } + +function resolveStoredUserAdminFlag(payload, roleCodes = []) { + const username = String(payload?.username || payload?.account || '').trim().toLowerCase() + const role = String(payload?.role || '').trim().toLowerCase() + const normalizedRoleCodes = roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean) + + return ( + Boolean(payload?.isAdmin) + || username === 'admin' + || role === 'admin' + || role === '管理员' + || role === '系统管理员' + || normalizedRoleCodes.includes('admin') + ) +} function normalizeApiBaseUrl(value) { return String(value || '/api/v1').replace(/\/$/, '') diff --git a/web/src/services/budgets.js b/web/src/services/budgets.js new file mode 100644 index 0000000..348f76f --- /dev/null +++ b/web/src/services/budgets.js @@ -0,0 +1,30 @@ +import { apiRequest } from './api.js' + +function buildQuery(params = {}) { + const search = new URLSearchParams() + Object.entries(params || {}).forEach(([key, value]) => { + if (typeof value === 'undefined' || value === null || value === '') return + search.set(key, String(value)) + }) + const query = search.toString() + return query ? `?${query}` : '' +} + +export function fetchBudgetSummary(params = {}) { + return apiRequest(`/budgets/summary${buildQuery(params)}`) +} + +export function fetchBudgetAllocations(params = {}) { + return apiRequest(`/budgets/allocations${buildQuery(params)}`) +} + +export function createBudgetAllocation(payload = {}) { + return apiRequest('/budgets/allocations', { + method: 'POST', + body: JSON.stringify(payload) + }) +} + +export function fetchBudgetTransactions(allocationId) { + return apiRequest(`/budgets/allocations/${encodeURIComponent(String(allocationId || '').trim())}/transactions`) +} diff --git a/web/src/utils/accessControl.js b/web/src/utils/accessControl.js index 4f56239..8e3f335 100644 --- a/web/src/utils/accessControl.js +++ b/web/src/utils/accessControl.js @@ -13,44 +13,82 @@ export const DEFAULT_APP_VIEW_ORDER = [ const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'policies']) const VIEW_ROLE_RULES = { overview: ['finance', 'executive'], - budget: ['finance', 'executive'], - audit: ['auditor', 'finance'], - logs: ['manager'], - employees: ['manager'], - settings: ['manager'] -} + budget: ['budget_monitor', 'executive'], + audit: ['finance'], + logs: ['manager'], + employees: ['manager'], + settings: ['manager'] +} const CLAIM_MANAGER_ROLE_CODES = new Set(['executive']) const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver']) const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver']) -function normalizedRoleCodes(user) { - if (!user) { - return [] - } - - return Array.isArray(user.roleCodes) - ? user.roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean) - : [] -} - +function normalizedRoleCodes(user) { + if (!user) { + return [] + } + + return Array.isArray(user.roleCodes) + ? user.roleCodes + .map((item) => normalizeRoleCode(item)) + .filter(Boolean) + : [] +} + +function normalizeRoleCode(value) { + const roleCode = String(value || '').trim().toLowerCase() + return roleCode === 'auditor' ? 'budget_monitor' : roleCode +} + +function hasPlatformAdminIdentity(user) { + if (!user) { + return false + } + + const username = String(user.username || user.account || '').trim().toLowerCase() + const role = String(user.role || '').trim().toLowerCase() + const roleCodes = normalizedRoleCodes(user) + + return ( + Boolean(user.isAdmin) + || username === 'admin' + || role === 'admin' + || role === '管理员' + || role === '系统管理员' + || roleCodes.includes('admin') + ) +} + export function isManagerUser(user) { - return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager') + return hasPlatformAdminIdentity(user) || normalizedRoleCodes(user).includes('manager') } export function isPlatformAdminUser(user) { - return Boolean(user?.isAdmin) + return hasPlatformAdminIdentity(user) } export function isFinanceUser(user) { return normalizedRoleCodes(user).includes('finance') } -export function isExecutiveUser(user) { - return normalizedRoleCodes(user).includes('executive') -} - +export function isExecutiveUser(user) { + return normalizedRoleCodes(user).includes('executive') +} + +export function isBudgetMonitorUser(user) { + return normalizedRoleCodes(user).includes('budget_monitor') +} + +export function canEditBudgetCenter(user) { + return isPlatformAdminUser(user) || isExecutiveUser(user) +} + +export function canSwitchBudgetDepartments(user) { + return isPlatformAdminUser(user) || isExecutiveUser(user) +} + export function canManageExpenseClaims(user) { - if (Boolean(user?.isAdmin)) { + if (isPlatformAdminUser(user)) { return true } @@ -58,21 +96,21 @@ export function canManageExpenseClaims(user) { } export function canDeleteArchivedExpenseClaims(user) { - return Boolean(user?.isAdmin) + return isPlatformAdminUser(user) } export function canReturnExpenseClaims(user) { - if (Boolean(user?.isAdmin)) { + if (isPlatformAdminUser(user)) { return true } return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode)) } -export function canApproveLeaderExpenseClaims(user) { - if (Boolean(user?.isAdmin)) { - return true - } +export function canApproveLeaderExpenseClaims(user) { + if (isPlatformAdminUser(user)) { + return true + } return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode)) } @@ -86,6 +124,14 @@ export function canAccessAppView(user, viewId) { return false } + if (viewId === 'budget') { + if (isPlatformAdminUser(user)) { + return true + } + const roleCodes = normalizedRoleCodes(user) + return VIEW_ROLE_RULES.budget.some((roleCode) => roleCodes.includes(roleCode)) + } + if (isManagerUser(user)) { return true } diff --git a/web/src/utils/budgetOntology.js b/web/src/utils/budgetOntology.js index eaac9f1..29b090d 100644 --- a/web/src/utils/budgetOntology.js +++ b/web/src/utils/budgetOntology.js @@ -119,16 +119,16 @@ export const BUDGET_CONTROL_ACTION_OPTIONS = ['正常', '提醒', '管控'] export const BUDGET_YEAR_OPTIONS = ['2026', '2027', '2028'] export const BUDGET_QUARTER_OPTIONS = ['Q1', 'Q2', 'Q3', 'Q4'] export const BUDGET_EXPENSE_TYPE_OPTIONS = Object.freeze([ - { value: 'travel', label: '差旅费' }, + { value: 'travel', label: '差旅' }, { value: 'hotel', label: '住宿费' }, { value: 'transport', label: '交通费' }, - { value: 'meal', label: '业务招待费' }, + { value: 'meal', label: '招待费' }, { value: 'meeting', label: '会务费' }, { value: 'marketing', label: '市场推广费' }, - { value: 'office', label: '办公用品费' }, + { value: 'office', label: '办公用品' }, { value: 'training', label: '培训费' }, { value: 'software', label: '软件服务费' }, - { value: 'communication', label: '通讯费' }, + { value: 'communication', label: '通信' }, { value: 'welfare', label: '福利费' } ]) @@ -139,6 +139,17 @@ const BUDGET_EXPENSE_TYPE_BY_CODE = Object.freeze( }, {}) ) +export const BUDGET_VISIBLE_EXPENSE_TYPE_CODES = Object.freeze([ + 'travel', + 'communication', + 'meal', + 'office' +]) + +export const BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS = Object.freeze( + BUDGET_VISIBLE_EXPENSE_TYPE_CODES.map((code) => BUDGET_EXPENSE_TYPE_BY_CODE[code]).filter(Boolean) +) + export function resolveBudgetExpenseTypeLabel(code, fallback = '') { return BUDGET_EXPENSE_TYPE_BY_CODE[String(code || '').trim()]?.label || fallback } diff --git a/web/src/utils/expenseApplicationOntology.js b/web/src/utils/expenseApplicationOntology.js index 79b3275..eab9bd0 100644 --- a/web/src/utils/expenseApplicationOntology.js +++ b/web/src/utils/expenseApplicationOntology.js @@ -60,7 +60,7 @@ const PROMPT_FIELD_LABELS = [ ] export const APPLICATION_EXAMPLES = [ - '申请下周去北京做客户现场验收,差旅预算18000元', + '申请下周去北京做客户现场验收,预计费用18000元', '申请上海产品发布会会务费32000元,需要场地和物料', '申请部门集中采购办公用品4800元,用于新员工入职' ] diff --git a/web/src/views/AppShellRouteView.vue b/web/src/views/AppShellRouteView.vue index 0f22f23..d8d081c 100644 --- a/web/src/views/AppShellRouteView.vue +++ b/web/src/views/AppShellRouteView.vue @@ -111,7 +111,7 @@ @summary-change="documentSummary = $event" /> - + diff --git a/web/src/views/AuditView.vue b/web/src/views/AuditView.vue index 462316e..19a2122 100644 --- a/web/src/views/AuditView.vue +++ b/web/src/views/AuditView.vue @@ -285,27 +285,16 @@
-
- 风险分数 - {{ selectedSkill.riskRuleScore ?? '待计算' }} -
+
是否上线 - - + {{ selectedSkill.isOnlineLabel || '待上线' }}
-
- 规则状态 - - - {{ selectedSkill.status || '-' }} - - -
+
测试状态 @@ -318,10 +307,7 @@ 创建者 {{ selectedSkill.creator || selectedSkill.publisher || '-' }}
-
- 审核人 - {{ selectedSkill.reviewer || '-' }} -
+
创建时间 @@ -829,7 +815,11 @@
-
+
+
+ + +
+
{{ skill.category }} - {{ skill.owner }} + + + {{ skill.riskLevelLabel || '-' }} + + + {{ skill.scope }} {{ skill.model }} {{ skill.versionDisplay || skill.version }} @@ -1389,7 +1430,7 @@ {{ riskRuleTestPassed ? '当前版本已通过测试确认' : '当前版本尚未确认测试通过' }} -

只有保存测试报告的风险规则,才能提交给高级管理人员审核。

+

只有保存测试报告的风险规则,才能提交给高级财务人员审核。

diff --git a/web/src/views/BudgetCenterView.vue b/web/src/views/BudgetCenterView.vue index d65b524..df7b8b0 100644 --- a/web/src/views/BudgetCenterView.vue +++ b/web/src/views/BudgetCenterView.vue @@ -59,7 +59,7 @@
- @@ -70,8 +70,8 @@
-
-
+ diff --git a/web/src/views/EmployeeManagementView.vue b/web/src/views/EmployeeManagementView.vue index 7d15018..86626f8 100644 --- a/web/src/views/EmployeeManagementView.vue +++ b/web/src/views/EmployeeManagementView.vue @@ -235,7 +235,7 @@

系统角色分配

-

为员工分配管理员、财务人员、使用者、高级管理人员等业务角色。

+

为员工分配管理员、财务人员、使用者、高级财务人员、预算监控员等业务角色。

{{ roleCount }} 个角色
diff --git a/web/src/views/TravelReimbursementCreateView.vue b/web/src/views/TravelReimbursementCreateView.vue index 74d791f..73b4d14 100644 --- a/web/src/views/TravelReimbursementCreateView.vue +++ b/web/src/views/TravelReimbursementCreateView.vue @@ -1402,7 +1402,7 @@ badge="提交确认" badge-tone="primary" title="确认提交当前费用申请?" - description="提交后申请将进入领导审核流程,并同步纳入预算管理口径,请确认关键申请信息和预计费用已经核对无误。" + description="提交后申请将进入领导审核流程,请确认关键申请信息和预计费用已经核对无误。" cancel-text="再检查一下" confirm-text="确认提交" busy-text="提交中..." diff --git a/web/src/views/scripts/AuditView.js b/web/src/views/scripts/AuditView.js index 777b421..ad74483 100644 --- a/web/src/views/scripts/AuditView.js +++ b/web/src/views/scripts/AuditView.js @@ -70,7 +70,8 @@ import { import { createDefaultRiskRuleForm, RISK_RULE_BUSINESS_STAGE_OPTIONS, - RISK_RULE_EXPENSE_CATEGORY_OPTIONS + RISK_RULE_EXPENSE_CATEGORY_OPTIONS, + RISK_RULE_LEVEL_OPTIONS } from './auditViewRiskRuleModel.js' export default { @@ -99,6 +100,7 @@ export default { const activeFilterPopover = ref('') const selectedDomain = ref('') const selectedOwner = ref('') + const selectedRiskLevel = ref('') const selectedStatus = ref('') const selectedRiskScenario = ref('') const selectedOnlineState = ref('') @@ -345,20 +347,29 @@ export default { const ownerOptions = computed(() => { const uniqueOwners = [...new Set(currentAssets.value.map((item) => item.owner).filter(Boolean))] return [ - { value: '', label: activeType.value === 'riskRules' ? '全部审核人' : '全部负责人' }, + { value: '', label: '全部负责人' }, ...uniqueOwners.map((value) => ({ value, label: value })) ] }) + const riskLevelOptions = computed(() => [ + { value: '', label: '全部风险等级' }, + ...RISK_RULE_LEVEL_OPTIONS + ]) const selectedDomainLabel = computed( () => domainOptions.value.find((item) => item.value === selectedDomain.value)?.label || '业务域' ) const selectedOwnerLabel = computed( () => ownerOptions.value.find((item) => item.value === selectedOwner.value)?.label || - (activeType.value === 'riskRules' ? '审核人' : '负责人') + '负责人' + ) + const selectedRiskLevelLabel = computed( + () => + riskLevelOptions.value.find((item) => item.value === selectedRiskLevel.value)?.label || + '风险等级' ) const selectedStatusLabel = computed( () => STATUS_OPTIONS.find((item) => item.value === selectedStatus.value)?.label || '状态' @@ -366,6 +377,8 @@ export default { const showRiskScenarioFilter = computed(() => ['financialRules', 'riskRules'].includes(activeType.value) ) + const showOwnerFilter = computed(() => activeType.value !== 'riskRules') + const showRiskLevelFilter = computed(() => activeType.value === 'riskRules') const showStatusFilter = computed(() => true) const showOnlineFilter = computed(() => false) const showEnabledFilter = computed(() => false) @@ -402,8 +415,11 @@ export default { if (showEnabledFilter.value && selectedEnabledState.value) { tokens.push(`是否启用:${selectedEnabledStateLabel.value}`) } - if (selectedOwner.value) { - tokens.push(`${activeType.value === 'riskRules' ? '审核人' : '负责人'}:${selectedOwner.value}`) + if (showOwnerFilter.value && selectedOwner.value) { + tokens.push(`负责人:${selectedOwner.value}`) + } + if (showRiskLevelFilter.value && selectedRiskLevel.value) { + tokens.push(`风险等级:${selectedRiskLevelLabel.value}`) } if (keyword.value.trim()) { tokens.push(`搜索:${keyword.value.trim()}`) @@ -415,7 +431,8 @@ export default { const hasFilters = activeFilterTokens.value.length > 0 const supportedFilters = [ '业务域', - activeType.value === 'riskRules' ? '审核人' : '负责人', + ...(showOwnerFilter.value ? ['负责人'] : []), + ...(showRiskLevelFilter.value ? ['风险等级'] : []), ...(showRiskScenarioFilter.value ? ['使用场景'] : []), ...(showStatusFilter.value ? ['状态'] : []), ...(showOnlineFilter.value ? ['是否上线'] : []), @@ -480,7 +497,7 @@ export default { return '当前为页面预览态,暂不执行真实审核和上线。' } if (!canManageSelected.value) { - return '仅高级管理人员可执行审核和上线。' + return '仅高级财务人员可执行审核和上线。' } if (!isDisplayingWorkingVersion.value) { return '请先切回当前工作版本,再执行审核或上线。' @@ -498,6 +515,7 @@ export default { keyword: keyword.value, selectedDomain: selectedDomain.value, selectedOwner: selectedOwner.value, + selectedRiskLevel: selectedRiskLevel.value, selectedStatus: selectedStatus.value, selectedRiskScenario: selectedRiskScenario.value, selectedOnlineState: selectedOnlineState.value, @@ -548,6 +566,7 @@ export default { keyword.value = '' selectedDomain.value = '' selectedOwner.value = '' + selectedRiskLevel.value = '' selectedStatus.value = '' selectedRiskScenario.value = '' selectedOnlineState.value = '' @@ -579,6 +598,9 @@ export default { if (name === 'owner') { selectedOwner.value = value } + if (name === 'riskLevel') { + selectedRiskLevel.value = value + } if (name === 'status') { selectedStatus.value = value } @@ -1832,22 +1854,27 @@ export default { detailError, selectedDomain, selectedOwner, + selectedRiskLevel, selectedStatus, selectedRiskScenario, selectedOnlineState, selectedEnabledState, selectedDomainLabel, selectedOwnerLabel, + selectedRiskLevelLabel, selectedStatusLabel, selectedRiskScenarioLabel, selectedOnlineStateLabel, selectedEnabledStateLabel, showRiskScenarioFilter, + showOwnerFilter, + showRiskLevelFilter, showStatusFilter, showOnlineFilter, showEnabledFilter, domainOptions, ownerOptions, + riskLevelOptions, statusOptions: STATUS_OPTIONS, riskScenarioOptions: RISK_SCENARIO_OPTIONS, onlineStateOptions: ONLINE_STATE_OPTIONS, diff --git a/web/src/views/scripts/BudgetCenterView.js b/web/src/views/scripts/BudgetCenterView.js index 17f222f..71ad3be 100644 --- a/web/src/views/scripts/BudgetCenterView.js +++ b/web/src/views/scripts/BudgetCenterView.js @@ -1,12 +1,20 @@ import { computed, onMounted, ref, watch } from 'vue' import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue' +import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' +import { createBudgetAllocation, fetchBudgetSummary } from '../../services/budgets.js' import { fetchEmployeeMeta } from '../../services/employees.js' +import { + canEditBudgetCenter, + canSwitchBudgetDepartments, + isBudgetMonitorUser, + isExecutiveUser +} from '../../utils/accessControl.js' import { BUDGET_CONTROL_ACTION_OPTIONS, - BUDGET_EXPENSE_TYPE_OPTIONS, BUDGET_QUARTER_OPTIONS, BUDGET_STATUS_OPTIONS, + BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS, BUDGET_WARNING_OPTIONS, BUDGET_YEAR_OPTIONS, buildBudgetOntologyContext, @@ -25,16 +33,9 @@ const FALLBACK_DEPARTMENTS = [ const EXPENSE_BUDGET_SEED = { travel: { total: 600000, used: 242300, occupied: 150000, warning: 80, action: '提醒' }, - hotel: { total: 360000, used: 139800, occupied: 84000, warning: 80, action: '提醒' }, - transport: { total: 280000, used: 104600, occupied: 56000, warning: 75, action: '提醒' }, - meal: { total: 420000, used: 168200, occupied: 118000, warning: 80, action: '管控' }, - meeting: { total: 260000, used: 84500, occupied: 52000, warning: 75, action: '提醒' }, - marketing: { total: 500000, used: 186400, occupied: 120000, warning: 80, action: '提醒' }, - office: { total: 300000, used: 68500, occupied: 60000, warning: 70, action: '正常' }, - training: { total: 200000, used: 42300, occupied: 20000, warning: 70, action: '正常' }, - software: { total: 600000, used: 249500, occupied: 240800, warning: 80, action: '管控' }, communication: { total: 120000, used: 38600, occupied: 18000, warning: 70, action: '正常' }, - welfare: { total: 240000, used: 96500, occupied: 42000, warning: 75, action: '提醒' } + meal: { total: 420000, used: 168200, occupied: 118000, warning: 80, action: '管控' }, + office: { total: 180000, used: 68500, occupied: 32000, warning: 70, action: '正常' } } const DEFAULT_EXPENSE_BUDGET = { @@ -45,7 +46,7 @@ const DEFAULT_EXPENSE_BUDGET = { action: '正常' } -const EXPENSE_BLUEPRINTS = BUDGET_EXPENSE_TYPE_OPTIONS.map((option) => ({ +const EXPENSE_BLUEPRINTS = BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS.map((option) => ({ ...DEFAULT_EXPENSE_BUDGET, ...EXPENSE_BUDGET_SEED[option.value], budgetSubjectCode: option.value, @@ -68,6 +69,67 @@ const parseBudgetAmount = (value) => Number(String(value || '').replace(/[^\d.-] const makeBudgetRowId = () => `budget-row-${Date.now()}-${Math.random().toString(16).slice(2)}` const BUDGET_PAGE_SIZE_OPTIONS = [5, 10] +const normalizePeriodKey = (year, quarter) => { + const normalizedYear = String(year || '').replace(/[^\d]/g, '') || '2026' + const normalizedQuarter = BUDGET_QUARTER_OPTIONS.includes(String(quarter || '').trim()) + ? String(quarter || '').trim() + : BUDGET_QUARTER_OPTIONS[0] + return `${normalizedYear}${normalizedQuarter}` +} + +const parsePercent = (value, fallback = 80) => { + const parsed = Number(String(value || '').replace(/[^\d.-]/g, '')) + return Number.isFinite(parsed) ? parsed : fallback +} + +const resolveControlActionCode = (value) => { + if (value === BUDGET_CONTROL_ACTION_OPTIONS[0]) return 'allow' + if (value === BUDGET_CONTROL_ACTION_OPTIONS[1]) return 'warn' + if (value === BUDGET_CONTROL_ACTION_OPTIONS[2]) return 'block' + return String(value || '').trim() || 'block' +} + +const resolveControlActionLabel = (value) => { + const normalized = String(value || '').trim().toLowerCase() + if (normalized === 'allow') return BUDGET_CONTROL_ACTION_OPTIONS[0] + if (normalized === 'warn') return BUDGET_CONTROL_ACTION_OPTIONS[1] + if (normalized === 'block') return BUDGET_CONTROL_ACTION_OPTIONS[2] + return value || BUDGET_CONTROL_ACTION_OPTIONS[2] +} + +function normalizeBudgetAllocationRow(item) { + const balance = item?.balance || {} + const totalAmount = Number(balance.total_amount ?? item?.original_amount ?? 0) + const usedAmount = Number(balance.consumed_amount ?? 0) + const occupiedAmount = Number(balance.reserved_amount ?? 0) + const leftAmount = Number(balance.available_amount ?? 0) + const rate = Number(balance.usage_rate ?? 0) + const warning = parsePercent(item?.warning_threshold, 80) + const budgetSubjectCode = String(item?.subject_code || '').trim() + const expenseType = item?.subject_name || resolveBudgetExpenseTypeLabel(budgetSubjectCode, budgetSubjectCode) + + return { + allocationId: item?.id || '', + budgetNo: item?.budget_no || '', + budgetSubjectCode, + expenseType, + totalAmount, + usedAmount, + occupiedAmount, + leftAmount, + rate, + rateTone: rate >= warning ? 'danger' : rate >= warning - 12 ? 'warn' : 'ok', + warning, + warningTone: warning >= 80 ? 'budget-warning-red' : 'budget-warning-yellow', + warningLine: `${warning}%`, + action: resolveControlActionLabel(item?.control_action), + total: currency(totalAmount), + used: currency(usedAmount), + occupied: currency(occupiedAmount), + left: currency(leftAmount) + } +} + function buildDepartmentRows(departmentCode) { const seed = Array.from(String(departmentCode || '')).reduce( (sum, char) => sum + char.charCodeAt(0), @@ -119,10 +181,17 @@ function buildTrendData(rows) { export default { name: 'BudgetCenterView', - components: { - BudgetTrendChart + props: { + currentUser: { + type: Object, + default: () => ({}) + } }, - setup() { + components: { + BudgetTrendChart, + ConfirmDialog + }, + setup(props) { const departments = ref(FALLBACK_DEPARTMENTS) const activeDepartmentCode = ref(FALLBACK_DEPARTMENTS[0].code) const departmentKeyword = ref('') @@ -134,6 +203,10 @@ export default { }) const budgetPage = ref(1) const budgetPageSize = ref(5) + const budgetRows = ref([]) + const budgetLoading = ref(false) + const budgetError = ref('') + const budgetSaving = ref(false) const budgetEditOpen = ref(false) const budgetEditForm = ref({ budgetYear: '2026', @@ -147,15 +220,24 @@ export default { budgetDescription: '' }) const budgetEditRows = ref([]) + const canEditBudget = computed(() => canEditBudgetCenter(props.currentUser)) + const canSwitchDepartments = computed(() => canSwitchBudgetDepartments(props.currentUser)) + const isDepartmentBudgetMonitor = computed( + () => isBudgetMonitorUser(props.currentUser) && !canSwitchDepartments.value && !isExecutiveUser(props.currentUser) + ) const activeDepartment = computed(() => departments.value.find((item) => item.code === activeDepartmentCode.value) || departments.value[0] ) const activeDepartmentName = computed(() => activeDepartment.value?.name || '市场部') - const departmentRows = computed(() => - buildDepartmentRows(activeDepartment.value?.code || activeDepartmentCode.value) + const currentUserDepartmentName = computed(() => + String(props.currentUser?.departmentName || props.currentUser?.department || '').trim() ) + const currentUserCostCenter = computed(() => + String(props.currentUser?.costCenter || props.currentUser?.cost_center || '').trim() + ) + const departmentRows = computed(() => budgetRows.value) const filteredBudgetRows = computed(() => departmentRows.value .filter((row) => filters.value.expenseType === '全部' || row.expenseType === filters.value.expenseType) @@ -271,7 +353,13 @@ export default { ) function buildEditableRows() { - return departmentRows.value.map((row) => ({ + const rows = departmentRows.value.length ? departmentRows.value : EXPENSE_BLUEPRINTS.map((row) => ({ + ...row, + totalAmount: row.total || 0, + warning: row.warning || 80, + action: row.action || BUDGET_CONTROL_ACTION_OPTIONS[2] + })) + return rows.map((row) => ({ id: makeBudgetRowId(), budgetSubject: row.expenseType, budgetSubjectCode: row.budgetSubjectCode || '', @@ -285,8 +373,8 @@ export default { function resolveNextExpenseTypeOption() { const usedCodes = new Set(budgetEditRows.value.map((row) => row.budgetSubjectCode)) return ( - BUDGET_EXPENSE_TYPE_OPTIONS.find((item) => !usedCodes.has(item.value)) || - BUDGET_EXPENSE_TYPE_OPTIONS[0] + BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS.find((item) => !usedCodes.has(item.value)) || + BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS[0] ) } @@ -295,6 +383,7 @@ export default { } function openBudgetEditDialog() { + if (!canEditBudget.value) return const department = activeDepartment.value const budgetPeriod = formatBudgetPeriod(filters.value.year, filters.value.quarter) budgetEditForm.value = { @@ -329,9 +418,26 @@ export default { }) } + const confirmDeleteOpen = ref(false) + const rowToDelete = ref(null) + function removeBudgetDetailRow(rowId) { if (budgetEditRows.value.length <= 1) return - budgetEditRows.value = budgetEditRows.value.filter((row) => row.id !== rowId) + rowToDelete.value = rowId + confirmDeleteOpen.value = true + } + + function confirmDeleteRow() { + if (rowToDelete.value !== null) { + budgetEditRows.value = budgetEditRows.value.filter((row) => row.id !== rowToDelete.value) + rowToDelete.value = null + } + confirmDeleteOpen.value = false + } + + function cancelDeleteRow() { + rowToDelete.value = null + confirmDeleteOpen.value = false } function goToBudgetPage(page) { @@ -342,14 +448,68 @@ export default { goToBudgetPage(currentBudgetPage.value + direction) } - function saveBudgetDraft() { - budgetEditForm.value.budgetStatus = '编制中' - closeBudgetEditDialog() + function buildBudgetPayloads(status) { + const department = activeDepartment.value || {} + return budgetEditRows.value.map((row) => ({ + fiscal_year: Number(String(budgetEditForm.value.budgetYear || filters.value.year || '2026').replace(/[^\d]/g, '')), + period_type: 'quarter', + period_key: normalizePeriodKey( + budgetEditForm.value.budgetYear || filters.value.year, + budgetEditForm.value.budgetQuarter || filters.value.quarter + ), + department_id: department.id || null, + department_name: department.name || '', + cost_center: budgetEditForm.value.costCenter || department.costCenter || '', + project_code: '', + subject_code: row.budgetSubjectCode || '', + subject_name: row.budgetSubject || resolveBudgetExpenseTypeLabel(row.budgetSubjectCode, row.budgetSubject), + original_amount: parseBudgetAmount(row.budgetAmount), + warning_threshold: parsePercent(row.warningThreshold, 80), + control_action: resolveControlActionCode(row.controlAction), + description: budgetEditForm.value.budgetDescription || status + })) } - function publishBudget() { - budgetEditForm.value.budgetStatus = '已发布' - closeBudgetEditDialog() + async function saveBudgetRows(status) { + if (!canEditBudget.value) return + budgetSaving.value = true + try { + const payloads = buildBudgetPayloads(status) + for (const payload of payloads) { + await createBudgetAllocation(payload) + } + await loadBudgetData() + closeBudgetEditDialog() + } finally { + budgetSaving.value = false + } + } + + + function resolveScopedDepartments(options) { + if (!isDepartmentBudgetMonitor.value) { + return options + } + + const userDepartment = currentUserDepartmentName.value + const userCostCenter = currentUserCostCenter.value + const scoped = options.filter((item) => { + if (userCostCenter && item.costCenter === userCostCenter) return true + return userDepartment && item.name === userDepartment + }) + + if (scoped.length) { + return scoped + } + + return [ + { + id: '', + code: userCostCenter || userDepartment || 'CURRENT-DEPARTMENT', + name: userDepartment || '当前部门', + costCenter: userCostCenter + } + ] } async function loadDepartments() { @@ -359,22 +519,53 @@ export default { const nextDepartments = options .filter((item) => item?.code && item?.name) .map((item) => ({ + id: String(item.id || ''), code: String(item.code), name: String(item.name), costCenter: String(item.costCenter || '') })) + const scopedDepartments = resolveScopedDepartments(nextDepartments) - if (nextDepartments.length) { - departments.value = nextDepartments - if (!nextDepartments.some((item) => item.code === activeDepartmentCode.value)) { - activeDepartmentCode.value = nextDepartments[0].code + if (scopedDepartments.length) { + departments.value = scopedDepartments + if (!scopedDepartments.some((item) => item.code === activeDepartmentCode.value)) { + activeDepartmentCode.value = scopedDepartments[0].code } } + await loadBudgetData() } catch (error) { console.warn('Failed to load budget departments from employee meta:', error) + await loadBudgetData() } } + async function loadBudgetData() { + const department = activeDepartment.value || {} + budgetLoading.value = true + budgetError.value = '' + try { + const payload = await fetchBudgetSummary({ + year: filters.value.year, + period: normalizePeriodKey(filters.value.year, filters.value.quarter), + department_id: department.id || '', + cost_center: department.costCenter || '' + }) + const allocations = Array.isArray(payload?.allocations) ? payload.allocations : [] + budgetRows.value = allocations.map(normalizeBudgetAllocationRow) + } catch (error) { + budgetError.value = error?.message || 'Failed to load budget data' + budgetRows.value = [] + console.warn('Failed to load budget data:', error) + } finally { + budgetLoading.value = false + } + } + + async function publishBudgetAction() { + budgetEditForm.value.budgetStatus = BUDGET_STATUS_OPTIONS[1] + await saveBudgetRows('published') + } + onMounted(() => { void loadDepartments() }) @@ -393,6 +584,13 @@ export default { } ) + watch( + [activeDepartmentCode, () => filters.value.year, () => filters.value.quarter], + () => { + void loadBudgetData() + } + ) + watch(totalBudgetPages, (pages) => { if (budgetPage.value > pages) { budgetPage.value = pages @@ -407,25 +605,32 @@ export default { budgetEditOpen, budgetEditRows, budgetEditTotal, + budgetError, + budgetLoading, budgetMetrics, budgetOntologyContext, budgetPage: currentBudgetPage, budgetPageNumbers, budgetPageSize, budgetPageSizeOptions: BUDGET_PAGE_SIZE_OPTIONS, + canEditBudget, + canSwitchDepartments, closeBudgetEditDialog, controlActionOptions: BUDGET_CONTROL_ACTION_OPTIONS, changeBudgetPage, departmentKeyword, departments, - expenseTypeOptions: BUDGET_EXPENSE_TYPE_OPTIONS, + expenseTypeOptions: BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS, expenseTypes: ['全部', ...EXPENSE_BLUEPRINTS.map((item) => item.expenseType)], filters, openBudgetEditDialog, quarters: BUDGET_QUARTER_OPTIONS, - publishBudget, + addBudgetDetailRow, removeBudgetDetailRow, - saveBudgetDraft, + confirmDeleteOpen, + confirmDeleteRow, + cancelDeleteRow, + publishBudget: publishBudgetAction, statusOptions: BUDGET_STATUS_OPTIONS, statuses: ['全部', '正常', '预警', '管控'], syncBudgetRowSubject, diff --git a/web/src/views/scripts/EmployeeManagementView.js b/web/src/views/scripts/EmployeeManagementView.js index 3128cd8..2b189ff 100644 --- a/web/src/views/scripts/EmployeeManagementView.js +++ b/web/src/views/scripts/EmployeeManagementView.js @@ -39,20 +39,20 @@ const FALLBACK_ROLE_OPTIONS = [ { id: 'executive', code: 'executive', - label: '高级管理人员', - desc: '可以查看跨部门数据看板与关键审批结果。' + label: '高级财务人员', + desc: '可以查看跨部门预算、经营看板与关键财务审批结果。' }, { - id: 'auditor', - code: 'auditor', - label: '审计观察员', - desc: '可以查看变更记录和权限调整历史。' + id: 'budget_monitor', + code: 'budget_monitor', + label: '预算监控员', + desc: '可以查看本部门预算执行、预警和占用情况。' }, { id: 'user', code: 'user', label: '使用者', - desc: '可以发起报销、查看个人单据和使用 AI 助手。' + desc: '可以发起费用申请、报销、查看个人单据和使用 AI 助手。' } ] diff --git a/web/src/views/scripts/TravelRequestDetailView.js b/web/src/views/scripts/TravelRequestDetailView.js index a6671c5..f4d0294 100644 --- a/web/src/views/scripts/TravelRequestDetailView.js +++ b/web/src/views/scripts/TravelRequestDetailView.js @@ -1633,7 +1633,7 @@ export default { toast( isArchivedRequest.value ? '已归档单据不能删除,只有高级管理员可以执行删除。' - : '当前单据已进入流程,只有高级管理人员可以删除。' + : '当前单据已进入流程,只有高级财务人员可以删除。' ) return } diff --git a/web/src/views/scripts/auditViewMetadata.js b/web/src/views/scripts/auditViewMetadata.js index 5065786..437624e 100644 --- a/web/src/views/scripts/auditViewMetadata.js +++ b/web/src/views/scripts/auditViewMetadata.js @@ -9,7 +9,7 @@ export const RULE_TABLE_COLUMNS = { export const RISK_RULE_TABLE_COLUMNS = { ...RULE_TABLE_COLUMNS, - owner: '审核人', + owner: '风险等级', status: '状态', metric: '创建者', updatedAt: '创建时间' @@ -80,7 +80,7 @@ export const TAB_META = { typeLabel: '风险规则', createButtonLabel: '新建风险规则', hintText: '仅展示平台风险规则;适用场景按差旅、发票、餐饮招待等分类,可用「使用场景」筛选。', - searchPlaceholder: '搜索风险规则名称、编码或审核人', + searchPlaceholder: '搜索风险规则名称、编码、风险等级或创建者', tableColumns: RISK_RULE_TABLE_COLUMNS, showRuntimeColumn: false, showVersionColumn: false, @@ -254,10 +254,12 @@ export const RISK_SCENARIO_OPTIONS = [ { value: '住宿费', label: '住宿费' }, { value: '交通费', label: '交通费' }, { value: '业务招待费', label: '业务招待费' }, + { value: '市场推广费', label: '市场推广费' }, { value: '会务费', label: '会务费' }, { value: '办公用品费', label: '办公用品费' }, { value: '培训费', label: '培训费' }, - { value: '通讯费', label: '通讯费' }, + { value: '软件服务费', label: '软件服务费' }, + { value: '通信费', label: '通信费' }, { value: '福利费', label: '福利费' }, { value: '差旅', label: '差旅' }, { value: '发票', label: '发票' }, diff --git a/web/src/views/scripts/auditViewModel.js b/web/src/views/scripts/auditViewModel.js index 40c78c1..1ff10b1 100644 --- a/web/src/views/scripts/auditViewModel.js +++ b/web/src/views/scripts/auditViewModel.js @@ -35,6 +35,20 @@ import { resolveRiskRuleSeverityLabel } from './auditViewRiskRuleModel.js' +const EXPENSE_TYPE_SCENARIO_LABELS = { + travel: '差旅费', + hotel: '住宿费', + transport: '交通费', + meal: '业务招待费', + meeting: '会务费', + marketing: '市场推广费', + office: '办公用品费', + training: '培训费', + software: '软件服务费', + communication: '通信费', + welfare: '福利费' +} + export { DETAIL_TITLES, DOMAIN_LABELS, @@ -375,7 +389,48 @@ export function inferRiskCategoryFromCode(code) { export function normalizeRiskScenarioCategory(value) { const normalized = normalizeText(value) - return RISK_SCENARIO_VALUES.has(normalized) ? normalized : '' + const alias = normalized === '通讯费' ? '通信费' : normalized + return RISK_SCENARIO_VALUES.has(alias) ? alias : '' +} + +export function normalizeExpenseTypeScenarioLabels(value) { + const values = Array.isArray(value) ? value : normalizeText(value) ? [value] : [] + const labels = [] + const seen = new Set() + + values.forEach((item) => { + const key = normalizeText(item).toLowerCase() + const label = EXPENSE_TYPE_SCENARIO_LABELS[key] || normalizeRiskScenarioCategory(item) + if (!label || seen.has(label)) { + return + } + seen.add(label) + labels.push(label) + }) + + return labels +} + +export function readRiskRuleExpenseTypes(source) { + const configJson = readConfigJson(source) + const metadata = isPlainObject(configJson.metadata) ? configJson.metadata : {} + const appliesTo = isPlainObject(configJson.applies_to) ? configJson.applies_to : {} + const values = [] + + ;[ + configJson.expense_types, + metadata.expense_types, + appliesTo.expense_types, + source?.expense_types + ].forEach((item) => { + if (Array.isArray(item)) { + values.push(...item) + } else if (normalizeText(item)) { + values.push(item) + } + }) + + return values } export function readScenarioItems(source) { @@ -390,6 +445,11 @@ export function readScenarioItems(source) { export function resolveRiskRuleCategory(source) { const configJson = readConfigJson(source) + const expenseScenarioLabels = normalizeExpenseTypeScenarioLabels(readRiskRuleExpenseTypes(source)) + if (expenseScenarioLabels.length) { + return formatScenarioList(expenseScenarioLabels) + } + const expenseCategoryLabel = normalizeText(configJson.expense_category_label) || normalizeText(configJson.metadata?.expense_category_label) || @@ -471,23 +531,43 @@ export function inferFinancialRuleCategory(source) { if (/(office|material|suppl|办公|物料|耗材)/i.test(haystack)) { return '办公物料' } - if (/(communication|telecom|phone|expense_standard|费用科目|费用标准|通信|通讯|手机|补贴|福利|科目)/i.test(haystack)) { + if (/(communication|telecom|phone|通信|通讯|手机)/i.test(haystack)) { + return '通信费' + } + if (/(welfare|福利)/i.test(haystack)) { + return '福利费' + } + if (/(expense_standard|费用科目|费用标准|补贴|科目)/i.test(haystack)) { return '费用科目' } return '通用' } export function resolveRuleScenarioCategory(source, tabId = '') { - const resolvedTabId = tabId || resolveRuleTabId(source) - if (resolvedTabId === 'riskRules' || isJsonRiskRuleSource(source)) { - return resolveRiskRuleCategory(source) - } - if (resolvedTabId === 'financialRules') { - return inferFinancialRuleCategory(source) + const scenarioList = resolveRuleScenarioList(source, tabId) + if (scenarioList.length) { + return formatScenarioList(scenarioList) } return '' } +export function resolveRuleScenarioList(source, tabId = '') { + const resolvedTabId = tabId || resolveRuleTabId(source) + if (resolvedTabId === 'riskRules' || isJsonRiskRuleSource(source)) { + const expenseScenarioLabels = normalizeExpenseTypeScenarioLabels(readRiskRuleExpenseTypes(source)) + if (expenseScenarioLabels.length) { + return expenseScenarioLabels + } + const riskCategory = resolveRiskRuleCategory(source) + return riskCategory ? [riskCategory] : [] + } + if (resolvedTabId === 'financialRules') { + const financialCategory = inferFinancialRuleCategory(source) + return financialCategory ? [financialCategory] : [] + } + return [] +} + export function buildRiskListSubtitle(text, maxLength = 42) { const normalized = normalizeText(text) if (!normalized) { @@ -950,6 +1030,18 @@ export function buildListItem(asset) { const businessStage = usesJsonRiskRule ? resolveRiskRuleBusinessStage(asset) : { value: '', label: '' } + const ruleScenarioList = typeKey === 'rules' ? resolveRuleScenarioList(asset, tabId) : [] + const riskScoreLevel = usesJsonRiskRule + ? resolveRiskRuleScoreLevel(asset.config_json, asset.config_json) + : '' + const riskLevelValue = usesJsonRiskRule + ? riskScoreLevel || resolveRiskRuleSeverity(asset.config_json) + : '' + const riskLevelLabel = usesJsonRiskRule + ? riskScoreLevel + ? resolveRiskRuleScoreLabel(asset.config_json, asset.config_json) || resolveRiskRuleSeverityLabel(asset.config_json) + : resolveRiskRuleSeverityLabel(asset.config_json) + : '' return { id: asset.id, @@ -966,12 +1058,16 @@ export function buildListItem(asset) { summary: listSubtitle, listSubtitle, category: resolveDomainLabel(asset.domain), - owner: isRiskRule ? reviewer : asset.owner, + owner: isRiskRule ? creator : asset.owner, reviewer, scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(asset.scenario_json), riskCategory: ruleScenarioCategory, + scenarioList: ruleScenarioList, businessStageValue: businessStage.value, businessStageLabel: businessStage.label, + riskLevelValue, + riskLevelLabel, + riskLevelTone: riskLevelValue, model: buildRowRuntime(asset, typeKey), version: workingVersion, versionDisplay: typeKey === 'rules' ? `${changeCount} 次` : workingVersion, @@ -1304,6 +1400,7 @@ export function buildDetailViewModel(detail, runs) { const ruleTemplateLabel = normalizeText(configJson.rule_template_label) || resolveRuleTemplateLabel(ruleTemplateKey) const runtimeKind = normalizeText(configJson.runtime_kind || previewRuntimeRule.kind) || 'policy_rule_draft' const ruleScenarioCategory = typeKey === 'rules' ? resolveRuleScenarioCategory(detail, tabId) : '' + const ruleScenarioList = typeKey === 'rules' ? resolveRuleScenarioList(detail, tabId) : [] const isEnabledValue = usesJsonRiskRule ? resolveRiskRuleEnabled(detail) : true const generationStatus = normalizeText(configJson.generation_status || detail.status) const riskRuleGenerationFailed = usesJsonRiskRule && (detail.status === 'failed' || generationStatus === 'failed') @@ -1404,8 +1501,8 @@ export function buildDetailViewModel(detail, runs) { latestTestSummary: detail.latest_test_summary || detail.latestTestSummary || null, riskCategory: typeKey === 'rules' ? ruleScenarioCategory : '', ruleDocument, - scenarioList: typeKey === 'rules' && ruleScenarioCategory - ? [ruleScenarioCategory] + scenarioList: typeKey === 'rules' && ruleScenarioList.length + ? ruleScenarioList : Array.isArray(detail.scenario_json) ? [...detail.scenario_json] : [], diff --git a/web/src/views/scripts/auditViewRuntimeModel.js b/web/src/views/scripts/auditViewRuntimeModel.js index b72da8c..cf7d77f 100644 --- a/web/src/views/scripts/auditViewRuntimeModel.js +++ b/web/src/views/scripts/auditViewRuntimeModel.js @@ -87,12 +87,15 @@ export function filterAuditAssets(assets = [], filters = {}) { return assets.filter((item) => { const matchesKeyword = normalizedKeyword - ? [item.name, item.code, item.summary, item.owner, item.scope] + ? [item.name, item.code, item.summary, item.owner, item.scope, item.riskLevelLabel] .filter(Boolean) .some((value) => String(value).toLowerCase().includes(normalizedKeyword)) : true const matchesDomain = filters.selectedDomain ? item.domainValue === filters.selectedDomain : true const matchesOwner = filters.selectedOwner ? item.owner === filters.selectedOwner : true + const matchesRiskLevel = filters.selectedRiskLevel + ? item.riskLevelValue === filters.selectedRiskLevel + : true const matchesStatus = filters.showStatusFilter ? filters.selectedStatus ? item.statusValue === filters.selectedStatus @@ -100,7 +103,9 @@ export function filterAuditAssets(assets = [], filters = {}) { : true const matchesRiskScenario = filters.showRiskScenarioFilter ? filters.selectedRiskScenario - ? item.riskCategory === filters.selectedRiskScenario + ? Array.isArray(item.scenarioList) && item.scenarioList.length + ? item.scenarioList.includes(filters.selectedRiskScenario) + : item.riskCategory === filters.selectedRiskScenario : true : true const matchesOnline = filters.showOnlineFilter @@ -118,6 +123,7 @@ export function filterAuditAssets(assets = [], filters = {}) { matchesKeyword && matchesDomain && matchesOwner && + matchesRiskLevel && matchesStatus && matchesRiskScenario && matchesOnline && diff --git a/web/tests/accessControl.test.mjs b/web/tests/accessControl.test.mjs index 89c09ee..0201009 100644 --- a/web/tests/accessControl.test.mjs +++ b/web/tests/accessControl.test.mjs @@ -5,8 +5,10 @@ import { canApproveLeaderExpenseClaims, canAccessAppView, canDeleteArchivedExpenseClaims, + canEditBudgetCenter, canManageExpenseClaims, - canReturnExpenseClaims + canReturnExpenseClaims, + canSwitchBudgetDepartments } from '../src/utils/accessControl.js' import { canProcessApprovalRequest } from '../src/utils/approvalInbox.js' @@ -45,6 +47,25 @@ test('legacy reimbursement approval and archive centers are no longer accessible assert.equal(canAccessAppView(adminUser, 'documents'), true) }) +test('budget center is visible to platform admin, budget monitor, and executive roles only', () => { + assert.equal(canAccessAppView({ isAdmin: true, roleCodes: ['manager'] }, 'budget'), true) + assert.equal(canAccessAppView({ username: 'admin', roleCodes: ['manager'] }, 'budget'), true) + assert.equal(canAccessAppView({ roleCodes: ['budget_monitor'] }, 'budget'), true) + assert.equal(canAccessAppView({ roleCodes: ['auditor'] }, 'budget'), true) + assert.equal(canAccessAppView({ roleCodes: ['executive'] }, 'budget'), true) + assert.equal(canAccessAppView({ roleCodes: ['finance'] }, 'budget'), false) + assert.equal(canAccessAppView({ roleCodes: ['manager'] }, 'budget'), false) +}) + +test('budget edit and department switching are limited to admin and senior finance', () => { + assert.equal(canEditBudgetCenter({ username: 'admin', roleCodes: ['manager'] }), true) + assert.equal(canSwitchBudgetDepartments({ username: 'admin', roleCodes: ['manager'] }), true) + assert.equal(canEditBudgetCenter({ roleCodes: ['executive'] }), true) + assert.equal(canSwitchBudgetDepartments({ roleCodes: ['executive'] }), true) + assert.equal(canEditBudgetCenter({ roleCodes: ['budget_monitor'] }), false) + assert.equal(canSwitchBudgetDepartments({ roleCodes: ['budget_monitor'] }), false) +}) + test('finance approval inbox only processes finance-stage requests', () => { const financeUser = { roleCodes: ['finance'], name: '财务' } diff --git a/web/tests/budget-ontology.test.mjs b/web/tests/budget-ontology.test.mjs index 39889d4..18d763b 100644 --- a/web/tests/budget-ontology.test.mjs +++ b/web/tests/budget-ontology.test.mjs @@ -5,6 +5,7 @@ import { BUDGET_EXPENSE_TYPE_OPTIONS, BUDGET_ONTOLOGY_FIELDS, BUDGET_QUARTER_OPTIONS, + BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS, BUDGET_YEAR_OPTIONS, buildBudgetOntologyContext } from '../src/utils/budgetOntology.js' @@ -44,7 +45,7 @@ test('budget ontology context maps dialog fields to ontology payload', () => { ], rows: [ { - budgetSubject: '差旅费', + budgetSubject: '差旅', budgetSubjectCode: 'travel', budgetAmount: '600,000.00', warningThreshold: '80%', @@ -63,7 +64,7 @@ test('budget ontology context maps dialog fields to ontology payload', () => { assert.equal(context.budget_header.cost_center, 'CC-4100') assert.equal(context.budget_details[0].budget_subject_code, 'travel') assert.equal(context.budget_details[0].expense_type, 'travel') - assert.equal(context.budget_details[0].expense_type_label, '差旅费') + assert.equal(context.budget_details[0].expense_type_label, '差旅') assert.equal(context.budget_details[0].warning_threshold, '80%') }) @@ -85,6 +86,12 @@ test('budget expense type options expose real expense type codes', () => { ]) }) +test('budget center visible expense type options only expose current supported budget subjects', () => { + const optionLabels = BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS.map((item) => item.label) + + assert.deepEqual(optionLabels, ['差旅', '通信', '招待费', '办公用品']) +}) + test('budget center exposes separate year and quarter dimensions', () => { assert.deepEqual(BUDGET_YEAR_OPTIONS, ['2026', '2027', '2028']) assert.deepEqual(BUDGET_QUARTER_OPTIONS, ['Q1', 'Q2', 'Q3', 'Q4']) diff --git a/web/tests/expense-application-submit-rich-confirm.test.mjs b/web/tests/expense-application-submit-rich-confirm.test.mjs index 05efa41..85c77fc 100644 --- a/web/tests/expense-application-submit-rich-confirm.test.mjs +++ b/web/tests/expense-application-submit-rich-confirm.test.mjs @@ -24,7 +24,7 @@ test('expense application submit uses rich text link and confirm dialog', () => ) assert.match(createViewTemplate, /:open="applicationSubmitConfirmDialog\.open"/) assert.match(createViewTemplate, /title="确认提交当前费用申请?"/) - assert.match(createViewTemplate, /description="提交后申请将进入领导审核流程,并同步纳入预算管理口径/) + assert.match(createViewTemplate, /description="提交后申请将进入领导审核流程,请确认关键申请信息和预计费用已经核对无误。"/) assert.match(createViewTemplate, /@confirm="confirmApplicationSubmit"/) assert.match(createViewScript, /const APPLICATION_SUBMIT_HREF = '#application-submit'/) assert.match( diff --git a/web/tests/requestProgressSteps.test.mjs b/web/tests/requestProgressSteps.test.mjs index 2da635f..547fad4 100644 --- a/web/tests/requestProgressSteps.test.mjs +++ b/web/tests/requestProgressSteps.test.mjs @@ -29,7 +29,7 @@ test('application claims are mapped as application documents', () => { assert.equal(request.typeLabel, '差旅费用申请') assert.equal(request.secondaryStatusLabel, '申请材料') assert.equal(request.secondaryStatusValue, '已进入审批流程') - assert.equal(request.expenseTableSummary, '预计金额已纳入预算管理口径') + assert.equal(request.expenseTableSummary, '预计金额已随申请提交') assert.deepEqual( request.progressSteps.map((step) => step.label), ['创建申请', '直属领导审批', '审批完成']