From e1e515ecae9e90d086721fb8c919e1ca48633fb2 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Tue, 26 May 2026 12:16:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=A2=84=E7=AE=97?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E6=9C=AC=E4=BD=93=E4=B8=8E=E9=A3=8E=E9=99=A9?= =?UTF-8?q?=E8=A7=84=E5=88=99=E8=AF=84=E5=88=86=E5=9B=9E=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端新增预算本体解析模块和风险规则评分回填服务,优化规则 生成本体对齐和提示词构建,增强费用类型关键词和本体验证, 完善报销查询和审计接口,前端预算中心页面增加对话框和本 体工具函数,重构审计页面元数据和视图模型,补充单元测试。 --- .../day_6_budget_analytics_ontology.md | 41 +- ...travel.generated_20260525155459576686.json | 88 +++- ...travel.generated_20260526071430001717.json | 99 ----- ...travel.generated_20260526071633392020.json | 170 -------- ...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 ++++++++ server/src/app/api/deps.py | 18 +- .../src/app/api/v1/endpoints/agent_assets.py | 29 +- server/src/app/schemas/agent_asset.py | 5 + server/src/app/schemas/ontology.py | 1 + .../services/agent_asset_risk_rule_level.py | 2 + .../services/agent_asset_risk_rule_testing.py | 56 ++- server/src/app/services/agent_assets.py | 6 + .../app/services/expense_claim_constants.py | 42 +- .../src/app/services/expense_type_keywords.py | 37 +- server/src/app/services/ontology_budget.py | 269 ++++++++++++ server/src/app/services/ontology_detection.py | 32 +- .../src/app/services/ontology_extraction.py | 210 +++++++-- server/src/app/services/ontology_rules.py | 85 +++- .../src/app/services/ontology_validation.py | 9 +- .../services/orchestrator_expense_query.py | 64 ++- .../src/app/services/risk_rule_generation.py | 40 ++ .../app/services/risk_rule_generation_jobs.py | 25 ++ .../services/risk_rule_generation_ontology.py | 5 + .../services/risk_rule_generation_prompt.py | 39 +- .../app/services/risk_rule_score_backfill.py | 227 ++++++++++ server/src/app/services/risk_rule_scoring.py | 81 +++- .../src/app/services/user_agent_constants.py | 13 +- server/storage/knowledge/.index.json | 144 +++---- server/tests/test_ontology_service.py | 131 +++++- server/tests/test_risk_rule_generation.py | 184 +++++++- web/UI/编辑预算.jpg | Bin 0 -> 127007 bytes .../styles/views/budget-center-dialog.css | 349 +++++++++++++++ .../styles/views/budget-center-view.css | 406 +++++++++++++----- web/src/composables/useNavigation.js | 22 +- web/src/composables/useRequests.js | 2 + web/src/data/icons.js | 2 +- web/src/utils/accessControl.js | 18 +- web/src/utils/budgetOntology.js | 199 +++++++++ web/src/utils/expenseApplicationOntology.js | 2 + web/src/utils/reimbursementTextInference.js | 2 + web/src/views/AuditView.vue | 114 +++-- web/src/views/BudgetCenterView.vue | 286 ++++++++++-- web/src/views/scripts/AuditView.js | 119 +++-- web/src/views/scripts/BudgetCenterView.js | 267 +++++++++++- web/src/views/scripts/auditViewMetadata.js | 48 +-- web/src/views/scripts/auditViewModel.js | 248 +++++------ .../views/scripts/auditViewRiskRuleModel.js | 6 + .../travelReimbursementReviewConstants.js | 8 + web/tests/budget-ontology.test.mjs | 91 ++++ 53 files changed, 4350 insertions(+), 921 deletions(-) delete mode 100644 server/rules/risk-rules/risk.expense.travel.generated_20260526071430001717.json delete mode 100644 server/rules/risk-rules/risk.expense.travel.generated_20260526071633392020.json create mode 100644 server/rules/risk-rules/risk.expense.travel.generated_20260526101144234987.json create mode 100644 server/rules/risk-rules/risk.expense.travel.generated_20260526101531166364.json create mode 100644 server/rules/risk-rules/risk.expense.travel.generated_20260526101826456519.json create mode 100644 server/rules/risk-rules/risk.expense.travel.generated_20260526101912249313.json create mode 100644 server/rules/risk-rules/risk.expense.travel.generated_20260526101948535257.json create mode 100644 server/src/app/services/ontology_budget.py create mode 100644 server/src/app/services/risk_rule_score_backfill.py create mode 100644 web/UI/编辑预算.jpg create mode 100644 web/src/assets/styles/views/budget-center-dialog.css create mode 100644 web/src/utils/budgetOntology.js create mode 100644 web/tests/budget-ontology.test.mjs diff --git a/document/development/budget-center/day_6_budget_analytics_ontology.md b/document/development/budget-center/day_6_budget_analytics_ontology.md index 4b0a1f2..a9ff6a1 100644 --- a/document/development/budget-center/day_6_budget_analytics_ontology.md +++ b/document/development/budget-center/day_6_budget_analytics_ontology.md @@ -32,6 +32,46 @@ over_budget budget_warning ``` +## 预算字段设计 + +预算中心字段分为四层,前端弹窗、预算台账、后端本体解析都必须使用同一套语义键。 + +### 预算主信息 + +- `budget_period`:预算周期,支持年度、季度、月份。 +- `department`:所属部门,来自真实组织/部门数据。 +- `cost_center`:成本中心,跟随部门归属。 +- `budget_owner`:预算负责人。 +- `budget_version`:预算版本,例如 `V1.0(初始版本)`。 +- `budget_status`:预算状态,第一版限定为 `编制中 / 已发布 / 已冻结`。 +- `budget_description`:预算说明。 + +### 预算明细 + +- `budget_subject`:预算科目,对应页面费用类型。 +- `budget_subject_code`:预算科目编码,例如 `travel / office / training`。 +- `budget_amount`:预算金额。 +- `warning_threshold`:预警线,例如 `70% / 80%`。 +- `control_action`:控制动作,第一版限定为 `正常 / 提醒 / 管控`。 +- `budget_remark`:明细备注。 + +### 预算执行 + +- `reserved_amount`:已占用/已预占金额。 +- `consumed_amount`:已发生/已核销金额。 +- `available_amount`:剩余可用金额。 +- `budget_usage_rate`:预算执行率。 +- `over_budget`:是否超预算。 +- `budget_warning`:是否触发预算预警。 + +### 本体映射规则 + +- 页面字段使用驼峰变量,但提交/上下文统一映射为 snake_case 本体字段。 +- 本体 `scenario=budget` 负责预算编制、预算查询、预算预警、预算占用、预算不足解释。 +- 费用申请/报销仍使用 `scenario=expense`,但预算占用字段必须引用 `budget_subject / budget_period / cost_center`。 +- 问句中出现“预算金额、可用预算、剩余预算、预算占用、成本中心、预警线、超预算、预算不足”等词,应优先识别为 `budget` 场景。 +- 本体输出中,预算字段优先进入 `entities`;金额类查询同步进入 `metrics`;筛选口径进入 `constraints`。 + ## AI解释能力 需要支持的问题: @@ -48,4 +88,3 @@ budget_warning - [ ] AI能解释预算不足原因。 - [ ] 首页预算看板来自后端真实汇总。 - [ ] 预算中心和AI回答的金额一致。 - diff --git a/server/rules/risk-rules/risk.expense.travel.generated_20260525155459576686.json b/server/rules/risk-rules/risk.expense.travel.generated_20260525155459576686.json index ecf5ca6..c86d4ee 100644 --- a/server/rules/risk-rules/risk.expense.travel.generated_20260525155459576686.json +++ b/server/rules/risk-rules/risk.expense.travel.generated_20260525155459576686.json @@ -69,8 +69,9 @@ "action": "continue" }, "fail": { - "severity": "medium", - "action": "manual_review" + "severity": "high", + "action": "manual_review", + "risk_score": 77 } }, "metadata": { @@ -93,10 +94,85 @@ "pass": "继续流转", "fail": "提示风险" }, - "risk_level": "medium", - "risk_level_label": "中风险", - "risk_level_updated_at": "2026-05-25T16:05:15.691638+00:00" + "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": "medium" + "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_20260526071430001717.json b/server/rules/risk-rules/risk.expense.travel.generated_20260526071430001717.json deleted file mode 100644 index d540751..0000000 --- a/server/rules/risk-rules/risk.expense.travel.generated_20260526071430001717.json +++ /dev/null @@ -1,99 +0,0 @@ -{ - "schema_version": "2.0", - "rule_code": "risk.expense.travel.generated_20260526071430001717", - "name": "123", - "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": "attachment.invoice_no", - "label": "发票号码", - "type": "text", - "source": "attachment" - }, - { - "key": "attachment.goods_name", - "label": "商品服务名称", - "type": "text", - "source": "attachment" - }, - { - "key": "claim.reason", - "label": "报销事由", - "type": "text", - "source": "claim" - } - ] - }, - "params": { - "template_key": "field_required_v1", - "field_keys": [ - "attachment.hotel_city", - "attachment.invoice_no", - "attachment.goods_name", - "claim.reason" - ], - "condition_summary": "检查住宿城市、发票号码、商品服务名称是否满足必填和完整性要求", - "natural_language": "差旅报销时,先检查是否上传了交通票据、住宿票据或其他差旅附件;再读取发票号码、购买方名称、商品服务名称、票据全文、报销人和部门。若票据已上传但发票号码、购买方名称或商品服务名称缺失,且报销事由、人员和部门信息能够说明费用归属,则标记为低风险,提醒补齐票据要素或重新上传清晰附件。", - "required_fields": [ - "attachment.hotel_city", - "attachment.invoice_no", - "attachment.goods_name", - "claim.reason" - ] - }, - "outcomes": { - "pass": { - "severity": "none", - "action": "continue" - }, - "fail": { - "severity": "medium", - "action": "manual_review" - } - }, - "metadata": { - "owner": "admin", - "stability": "generated_draft", - "source_ref": "自然语言风险规则", - "created_at": "2026-05-26T07:14:30.001717+08:00", - "created_by": "admin", - "requires_attachment": false, - "rule_title": "123", - "expense_category": "travel", - "expense_category_label": "差旅费", - "natural_language": "差旅报销时,先检查是否上传了交通票据、住宿票据或其他差旅附件;再读取发票号码、购买方名称、商品服务名称、票据全文、报销人和部门。若票据已上传但发票号码、购买方名称或商品服务名称缺失,且报销事由、人员和部门信息能够说明费用归属,则标记为低风险,提醒补齐票据要素或重新上传清晰附件。", - "business_explanation": "当差旅费业务满足“差旅报销时,先检查是否上传了交通票据、住宿票据或其他差旅附件;再读取发票号码、购买方名称、商品服务名称、票据全文、报销人和部门。若票据已上传但发票号码、购买方名称或商品服务名称缺失,且报销事由、人员和部门信息能够说明费用归属,则标记为低风险,提醒补齐票据要素或重新上传清晰附件。”时,系统会按中风险进行提示,并要求经办人或审核人补充核对依据。", - "condition_summary": "检查住宿城市、发票号码、商品服务名称是否满足必填和完整性要求", - "flow": { - "start": "差旅费单据提交", - "evidence": "读取住宿城市、发票号码、商品服务名称", - "decision": "检查住宿城市、发票号码、商品服务名称是否满足必填和完整性要求", - "pass": "未命中风险,继续业务流转", - "fail": "命中中风险,提示复核" - } - }, - "flow_diagram_svg": "\n 123流程说明\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" -} diff --git a/server/rules/risk-rules/risk.expense.travel.generated_20260526071633392020.json b/server/rules/risk-rules/risk.expense.travel.generated_20260526071633392020.json deleted file mode 100644 index 231361b..0000000 --- a/server/rules/risk-rules/risk.expense.travel.generated_20260526071633392020.json +++ /dev/null @@ -1,170 +0,0 @@ -{ - "schema_version": "2.0", - "rule_code": "risk.expense.travel.generated_20260526071633392020", - "name": "222", - "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": "travel_route_city_consistency", - "applies_to": { - "domains": [ - "expense" - ], - "expense_categories": [ - "travel" - ] - }, - "inputs": { - "fields": [ - { - "key": "attachment.route_cities", - "label": "行程城市", - "type": "list", - "source": "attachment" - }, - { - "key": "attachment.hotel_city", - "label": "住宿城市", - "type": "text", - "source": "attachment" - }, - { - "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": "claim.reason", - "label": "报销事由", - "type": "text", - "source": "claim" - }, - { - "key": "item.item_reason", - "label": "明细事由", - "type": "text", - "source": "item" - } - ] - }, - "params": { - "template_key": "field_compare_v1", - "field_keys": [ - "attachment.route_cities", - "attachment.hotel_city", - "claim.location", - "item.item_location", - "employee.location", - "claim.reason", - "item.item_reason" - ], - "condition_summary": "判断公式:A=交通票行程城市∪住宿发票城市,B=申报目的地∪明细发生地点,C=员工常驻地/合理出发地。若A或B为空则要求补充识别;若A与B无交集且无合理说明,或票据路线中存在不属于B∪C的额外城市,则命中目的地不一致/中途周转异常风险。", - "natural_language": "差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;再读取申报目的地、明细发生地点、交通票行程城市和住宿发票城市。若交通票或住宿票据中的城市均无法与申报目的地、明细地点形成一致关系,且报销事由中没有说明绕行、跨城办事或临时改签原因,则标记为高风险,要求补充行程说明或退回修改。", - "semantic_type": "travel_route_city_consistency", - "attachment_city_fields": [ - "attachment.route_cities", - "attachment.hotel_city" - ], - "reference_city_fields": [ - "claim.location", - "item.item_location" - ], - "home_city_fields": [ - "employee.location" - ], - "exception_fields": [ - "claim.reason", - "item.item_reason" - ], - "exception_keywords": [ - "绕行", - "跨城办事", - "跨城", - "临时改签", - "改签", - "变更" - ], - "keywords": [], - "route_anomaly_policy": "flag_unexpected_intermediate_cities", - "exception_handling": "exception_text_is_evidence_not_auto_pass_for_route_anomaly", - "formula": "A=UNION(attachment.route_cities, attachment.hotel_city); B=UNION(claim.location, item.item_location); C=UNION(employee.location); HIT WHEN (A∩B=∅ AND NOT CONTAINS_ANY(exception_fields, exception_keywords)) OR EXISTS(city IN A WHERE city NOT IN B∪C)", - "conditions": [ - { - "left_group": [ - "attachment.route_cities", - "attachment.hotel_city" - ], - "operator": "route_city_consistency", - "right_group": [ - "claim.location", - "item.item_location" - ], - "home_group": [ - "employee.location" - ], - "exception_fields": [ - "claim.reason", - "item.item_reason" - ], - "exception_keywords": [ - "绕行", - "跨城办事", - "跨城", - "临时改签", - "改签", - "变更" - ] - } - ] - }, - "outcomes": { - "pass": { - "severity": "none", - "action": "continue" - }, - "fail": { - "severity": "medium", - "action": "manual_review" - } - }, - "metadata": { - "owner": "admin", - "stability": "generated_draft", - "source_ref": "自然语言风险规则", - "created_at": "2026-05-26T07:16:33.392020+08:00", - "created_by": "admin", - "requires_attachment": false, - "rule_title": "222", - "expense_category": "travel", - "expense_category_label": "差旅费", - "natural_language": "差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;再读取申报目的地、明细发生地点、交通票行程城市和住宿发票城市。若交通票或住宿票据中的城市均无法与申报目的地、明细地点形成一致关系,且报销事由中没有说明绕行、跨城办事或临时改签原因,则标记为高风险,要求补充行程说明或退回修改。", - "business_explanation": "当差旅费业务满足“差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;再读取申报目的地、明细发生地点、交通票行程城市和住宿发票城市。若交通票或住宿票据中的城市均无法与申报目的地、明细地点形成一致关系,且报销事由中没有说明绕行、跨城办事或临时改签原因,则标记为高风险,要求补充行程说明或退回修改。”时,系统会按中风险进行提示,并要求经办人或审核人补充核对依据。", - "condition_summary": "判断公式:A=交通票行程城市∪住宿发票城市,B=申报目的地∪明细发生地点,C=员工常驻地/合理出发地。若A或B为空则要求补充识别;若A与B无交集且无合理说明,或票据路线中存在不属于B∪C的额外城市,则命中目的地不一致/中途周转异常风险。", - "flow": { - "start": "差旅报销单据提交,并上传交通票据、住宿票据或其他可识别城市的附件", - "evidence": "读取员工常驻地、申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由", - "decision": "附件城市是否覆盖申报行程,且票据路线是否出现申报目的地和常驻地之外的中转城市", - "pass": "票据城市覆盖申报行程,且未出现申报目的地和常驻地之外的额外城市", - "fail": "票据路线存在目的地不一致或额外中转城市,命中中风险并要求补充说明或退回修改" - } - }, - "flow_diagram_svg": "\n 222流程说明\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 判断公式:A=交通票行程城市∪住宿发票城市…\n \n \n \n \n \n \n \n" -} diff --git a/server/rules/risk-rules/risk.expense.travel.generated_20260526101144234987.json b/server/rules/risk-rules/risk.expense.travel.generated_20260526101144234987.json new file mode 100644 index 0000000..613b00e --- /dev/null +++ b/server/rules/risk-rules/risk.expense.travel.generated_20260526101144234987.json @@ -0,0 +1,180 @@ +{ + "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 new file mode 100644 index 0000000..0e1ef18 --- /dev/null +++ b/server/rules/risk-rules/risk.expense.travel.generated_20260526101531166364.json @@ -0,0 +1,179 @@ +{ + "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 new file mode 100644 index 0000000..86f2947 --- /dev/null +++ b/server/rules/risk-rules/risk.expense.travel.generated_20260526101826456519.json @@ -0,0 +1,179 @@ +{ + "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 new file mode 100644 index 0000000..e8b76a4 --- /dev/null +++ b/server/rules/risk-rules/risk.expense.travel.generated_20260526101912249313.json @@ -0,0 +1,213 @@ +{ + "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 new file mode 100644 index 0000000..e176945 --- /dev/null +++ b/server/rules/risk-rules/risk.expense.travel.generated_20260526101948535257.json @@ -0,0 +1,179 @@ +{ + "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/src/app/api/deps.py b/server/src/app/api/deps.py index f2af6b0..a7fc866 100644 --- a/server/src/app/api/deps.py +++ b/server/src/app/api/deps.py @@ -71,8 +71,8 @@ def get_current_user( 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: - return current_user + if current_user.is_admin or "manager" in current_user.role_codes: + return current_user raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -80,6 +80,18 @@ def require_admin_user( ) +def require_platform_admin_user( + current_user: Annotated[CurrentUserContext, Depends(get_current_user)], +) -> CurrentUserContext: + if current_user.is_admin: + return current_user + + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="只有 admin 管理员可以执行该操作。", + ) + + def require_rule_editor_user( current_user: Annotated[CurrentUserContext, Depends(get_current_user)], ) -> CurrentUserContext: @@ -102,5 +114,5 @@ def require_rule_reviewer_user( raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="只有高级管理人员可以审核、发布或恢复正式规则。", + detail="只有高级管理人员或 admin 管理员可以执行该操作。", ) diff --git a/server/src/app/api/v1/endpoints/agent_assets.py b/server/src/app/api/v1/endpoints/agent_assets.py index 79513a3..e37ba0a 100644 --- a/server/src/app/api/v1/endpoints/agent_assets.py +++ b/server/src/app/api/v1/endpoints/agent_assets.py @@ -10,7 +10,7 @@ from app.api.deps import ( CurrentUserContext, get_current_user, get_db, - require_admin_user, + require_platform_admin_user, require_rule_editor_user, require_rule_reviewer_user, ) @@ -58,7 +58,7 @@ RequestIdHeader = Annotated[ Header(description="外部请求 ID,用于串联审计日志和上游调用链。"), ] CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)] -AdminUser = Annotated[CurrentUserContext, Depends(require_admin_user)] +PlatformAdminUser = Annotated[CurrentUserContext, Depends(require_platform_admin_user)] RuleEditorUser = Annotated[CurrentUserContext, Depends(require_rule_editor_user)] RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_user)] @@ -187,7 +187,7 @@ def get_agent_asset_risk_rule_latest_test( def simulate_agent_asset_risk_rule_test( asset_id: str, payload: AgentAssetRiskRuleSimulationRequest, - _: RuleEditorUser, + _: PlatformAdminUser, db: DbSession, ) -> AgentAssetRiskRuleSimulationRead: try: @@ -205,7 +205,7 @@ def simulate_agent_asset_risk_rule_test( def run_agent_asset_risk_rule_sample_test( asset_id: str, payload: AgentAssetRiskRuleSampleTestRequest, - current_user: RuleEditorUser, + current_user: PlatformAdminUser, db: DbSession, x_actor: ActorHeader = None, x_request_id: RequestIdHeader = None, @@ -230,7 +230,7 @@ def run_agent_asset_risk_rule_sample_test( def run_agent_asset_risk_rule_scenario_test( asset_id: str, payload: AgentAssetRiskRuleScenarioTestRequest, - current_user: RuleEditorUser, + current_user: PlatformAdminUser, db: DbSession, x_actor: ActorHeader = None, x_request_id: RequestIdHeader = None, @@ -255,7 +255,7 @@ def run_agent_asset_risk_rule_scenario_test( def confirm_agent_asset_risk_rule_test_report( asset_id: str, payload: AgentAssetRiskRuleReportRequest, - current_user: RuleEditorUser, + current_user: PlatformAdminUser, db: DbSession, x_actor: ActorHeader = None, x_request_id: RequestIdHeader = None, @@ -301,12 +301,12 @@ def save_agent_asset_rule_json( response_model=AgentAssetRead, status_code=status.HTTP_201_CREATED, summary="根据自然语言新建风险规则草稿", - description="根据业务域、风险等级和自然语言描述生成 JSON 风险规则,并保存为待审核草稿资产。", + description="根据业务域、自然语言描述和风险评分模型生成 JSON 风险规则,并保存为待上线草稿资产。", ) def generate_agent_asset_risk_rule( payload: AgentAssetRiskRuleGenerateRequest, background_tasks: BackgroundTasks, - current_user: RuleEditorUser, + current_user: RuleReviewerUser, db: DbSession, x_actor: ActorHeader = None, x_request_id: RequestIdHeader = None, @@ -550,6 +550,7 @@ def list_agent_asset_spreadsheet_change_records( ) def create_agent_asset( payload: AgentAssetCreate, + current_user: RuleReviewerUser, db: DbSession, x_actor: ActorHeader = None, x_request_id: RequestIdHeader = None, @@ -557,7 +558,7 @@ def create_agent_asset( try: return AgentAssetService(db).create_asset( payload, - actor=(x_actor or payload.owner).strip() or "system", + actor=(x_actor or current_user.name or payload.owner).strip() or "system", request_id=x_request_id, ) except Exception as exc: @@ -583,15 +584,21 @@ def create_agent_asset( def update_agent_asset( asset_id: str, payload: AgentAssetUpdate, + current_user: CurrentUser, db: DbSession, x_actor: ActorHeader = None, x_request_id: RequestIdHeader = None, ) -> AgentAssetRead: try: + role_codes = {item.strip() for item in current_user.role_codes} + if (payload.status is not None or payload.published_version is not None) and not ( + current_user.is_admin or "manager" in role_codes + ): + raise PermissionError("只有高级管理员或 admin 管理员可以更改规则上线状态。") return AgentAssetService(db).update_asset( asset_id, payload, - actor=(x_actor or "system").strip() or "system", + actor=(x_actor or current_user.name or "system").strip() or "system", request_id=x_request_id, ) except Exception as exc: @@ -846,7 +853,7 @@ def publish_agent_asset_risk_rule( ) def delete_agent_asset( asset_id: str, - current_user: RuleEditorUser, + current_user: PlatformAdminUser, db: DbSession, x_actor: ActorHeader = None, x_request_id: RequestIdHeader = None, diff --git a/server/src/app/schemas/agent_asset.py b/server/src/app/schemas/agent_asset.py index 7752efa..c3dc754 100644 --- a/server/src/app/schemas/agent_asset.py +++ b/server/src/app/schemas/agent_asset.py @@ -112,6 +112,11 @@ class AgentAssetRuleJsonRead(BaseModel): class AgentAssetRiskRuleGenerateRequest(BaseModel): business_domain: AgentAssetDomain = AgentAssetDomain.EXPENSE + business_stage: str | None = Field( + default="reimbursement", + pattern="^(expense_application|reimbursement)$", + max_length=40, + ) expense_category: str | None = Field(default=None, max_length=40) rule_title: str | None = Field(default=None, max_length=80) risk_level: str | None = Field(default=None, pattern="^(low|medium|high|critical)$") diff --git a/server/src/app/schemas/ontology.py b/server/src/app/schemas/ontology.py index 3bd92e9..93b7afe 100644 --- a/server/src/app/schemas/ontology.py +++ b/server/src/app/schemas/ontology.py @@ -8,6 +8,7 @@ OntologyScenario = Literal[ "expense", "accounts_receivable", "accounts_payable", + "budget", "knowledge", "unknown", ] diff --git a/server/src/app/services/agent_asset_risk_rule_level.py b/server/src/app/services/agent_asset_risk_rule_level.py index 0a69f16..748092d 100644 --- a/server/src/app/services/agent_asset_risk_rule_level.py +++ b/server/src/app/services/agent_asset_risk_rule_level.py @@ -35,6 +35,8 @@ class AgentAssetRiskRuleLevelMixin: actor: str, request_id: str | None = None, ) -> AgentAsset: + del asset_id, risk_level, actor, request_id + raise ValueError("风险等级和分数由评分模型自动计算,不能手动修改。") asset = self._resolve_asset(asset_id) self._require_json_risk_asset(asset) normalized_level = self._normalize_risk_rule_level(risk_level) diff --git a/server/src/app/services/agent_asset_risk_rule_testing.py b/server/src/app/services/agent_asset_risk_rule_testing.py index 2e9b21c..31219de 100644 --- a/server/src/app/services/agent_asset_risk_rule_testing.py +++ b/server/src/app/services/agent_asset_risk_rule_testing.py @@ -148,11 +148,12 @@ class AgentAssetRiskRuleTestingMixin: if not body.confirm_passed: raise ValueError("请确认测试通过后再保存测试报告。") - summary = "测试报告已确认,当前版本可提交审核。" + summary = "测试报告已确认,当前版本可上线。" if scenario is None: summary = "快速样例测试已确认通过,真实场景试运行未执行。" elif not scenario.passed: summary = "快速样例测试已确认通过,真实场景试运行未找到可测样本。" + self._mark_risk_rule_operation(asset, action="test", actor=actor) return self._create_test_run( asset, version=version, @@ -162,9 +163,9 @@ class AgentAssetRiskRuleTestingMixin: input_json={"confirm_passed": True, "note": body.note or ""}, result_json={ "sample_test_run_id": sample.id, - "scenario_test_run_id": scenario.id, + "scenario_test_run_id": scenario.id if scenario else "", "sample_summary": sample.summary, - "scenario_summary": scenario.summary, + "scenario_summary": scenario.summary if scenario else "", }, actor=actor, request_id=request_id, @@ -308,6 +309,11 @@ class AgentAssetRiskRuleTestingMixin: config_json = dict(asset.config_json or {}) config_json["enabled"] = bool(enabled) + self._set_risk_rule_status_for_online_toggle(asset, enabled=enabled, actor=actor) + config_json["last_operation"] = self._build_last_operation( + action="online" if enabled else "offline", + actor=actor, + ) asset.config_json = config_json updated = self.repository.save_asset(asset) self.audit_service.log_action( @@ -321,6 +327,50 @@ class AgentAssetRiskRuleTestingMixin: ) return updated + def _set_risk_rule_status_for_online_toggle( + self, + asset: AgentAsset, + *, + enabled: bool, + actor: str, + ) -> None: + if enabled: + version = self._resolve_target_version(asset, None) + approved_review = self.repository.get_review( + asset.id, version, AgentReviewStatus.APPROVED.value + ) + if approved_review is None: + self.db.add( + AgentAssetReview( + asset_id=asset.id, + version=version, + reviewer=actor, + review_status=AgentReviewStatus.APPROVED.value, + review_note="直接上线风险规则。", + reviewed_at=datetime.now(UTC), + ) + ) + asset.published_version = version + asset.reviewer = actor + asset.status = AgentAssetStatus.ACTIVE.value + return + + asset.status = AgentAssetStatus.DISABLED.value + + def _mark_risk_rule_operation(self, asset: AgentAsset, *, action: str, actor: str) -> None: + config_json = dict(asset.config_json or {}) + config_json["last_operation"] = self._build_last_operation(action=action, actor=actor) + asset.config_json = config_json + self.db.add(asset) + + @staticmethod + def _build_last_operation(*, action: str, actor: str) -> dict[str, str]: + return { + "action": action, + "actor": str(actor or "system").strip() or "system", + "at": datetime.now(UTC).isoformat(), + } + def _load_risk_rule_for_test( self, asset_id: str, version: str | None ) -> tuple[AgentAsset, str, dict[str, Any]]: diff --git a/server/src/app/services/agent_assets.py b/server/src/app/services/agent_assets.py index 55d31fe..f28e9f7 100644 --- a/server/src/app/services/agent_assets.py +++ b/server/src/app/services/agent_assets.py @@ -37,6 +37,7 @@ from app.services.agent_asset_spreadsheet_helpers import AgentAssetSpreadsheetHe from app.services.agent_asset_timeline import AgentAssetTimelineMixin from app.services.agent_foundation import AgentFoundationService from app.services.audit import AuditLogService +from app.services.risk_rule_score_backfill import backfill_missing_risk_rule_score logger = get_logger("app.services.agent_assets") @@ -79,6 +80,11 @@ class AgentAssetService( asset = self.repository.get(asset_id) if asset is None: return None + try: + if backfill_missing_risk_rule_score(asset): + asset = self.repository.save_asset(asset) + except Exception: + logger.warning("Failed to backfill risk rule score asset_id=%s", asset_id, exc_info=True) working_version = self._resolve_working_version(asset) recent_versions = self._sort_versions( diff --git a/server/src/app/services/expense_claim_constants.py b/server/src/app/services/expense_claim_constants.py index 745b118..bc24453 100644 --- a/server/src/app/services/expense_claim_constants.py +++ b/server/src/app/services/expense_claim_constants.py @@ -17,8 +17,10 @@ EXPENSE_TYPE_LABELS = { "meal": "业务招待", "meeting": "会务", "entertainment": "招待", + "marketing": "市场推广", "office": "办公用品", "training": "培训", + "software": "软件服务", "communication": "通讯", "welfare": "福利", } @@ -52,8 +54,21 @@ DOCUMENT_TYPE_SCENE_MAP = { "meeting_invoice": "meeting", "training_invoice": "training", } -DOCUMENT_FACT_ITEM_TYPES = {"train_ticket", "flight_ticket", "hotel_ticket", "ride_ticket", "ship_ticket", "ferry_ticket"} -ROUTE_DESCRIPTION_ITEM_TYPES = {"train_ticket", "flight_ticket", "ship_ticket", "ferry_ticket", "ride_ticket"} +DOCUMENT_FACT_ITEM_TYPES = { + "train_ticket", + "flight_ticket", + "hotel_ticket", + "ride_ticket", + "ship_ticket", + "ferry_ticket", +} +ROUTE_DESCRIPTION_ITEM_TYPES = { + "train_ticket", + "flight_ticket", + "ship_ticket", + "ferry_ticket", + "ride_ticket", +} DOCUMENT_TRIP_DATE_LABELS = { "train_ticket": "列车出发时间", "flight_itinerary": "起飞日期", @@ -118,7 +133,17 @@ DOCUMENT_ROUTE_TEXT_PATTERN = re.compile( r"([A-Za-z0-9\u4e00-\u9fa5()()·]{2,40})\s*(?:至|到|→|->|—|–|-)\s*" r"([A-Za-z0-9\u4e00-\u9fa5()()·]{2,40})" ) -DOCUMENT_ROUTE_ORIGIN_LABELS = {"起点", "上车", "上车地点", "上车地址", "出发", "出发地", "出发站", "始发站", "乘车起点"} +DOCUMENT_ROUTE_ORIGIN_LABELS = { + "起点", + "上车", + "上车地点", + "上车地址", + "出发", + "出发地", + "出发站", + "始发站", + "乘车起点", +} DOCUMENT_ROUTE_DESTINATION_LABELS = { "终点", "下车", @@ -140,9 +165,11 @@ EXPENSE_SCENE_KEYWORDS = { "transport", "meal", "entertainment", + "marketing", "office", "meeting", "training", + "software", "communication", "welfare", ) @@ -158,9 +185,11 @@ EXPENSE_TYPE_ALLOWED_DOCUMENT_SCENES = { "transport": {"transport", "travel"}, "meal": {"meal", "entertainment"}, "entertainment": {"entertainment", "meal"}, + "marketing": {"marketing"}, "office": {"office"}, "meeting": {"meeting"}, "training": {"training"}, + "software": {"software"}, } DOCUMENT_SCENE_LABELS = { "travel": "差旅", @@ -168,9 +197,11 @@ DOCUMENT_SCENE_LABELS = { "transport": "交通", "meal": "业务招待", "entertainment": "业务招待", + "marketing": "市场推广", "office": "办公用品", "meeting": "会务", "training": "培训", + "software": "软件服务", "other": "其他票据", } DOCUMENT_ASSOCIATION_REVIEW_ACTIONS = { @@ -191,7 +222,10 @@ RETURN_REASON_OPTIONS = { "approval_question": "审批人需要补充说明", } MAX_CLAIM_NO_RETRY_ATTEMPTS = 3 -DOCUMENT_DATE_PATTERN = re.compile(r"((?:20\d{2}|19\d{2})[-/年.](?:1[0-2]|0?[1-9])[-/月.](?:3[01]|[12]\d|0?[1-9])日?)") +DOCUMENT_DATE_PATTERN = re.compile( + r"((?:20\d{2}|19\d{2})[-/年.](?:1[0-2]|0?[1-9])[-/月.]" + r"(?:3[01]|[12]\d|0?[1-9])日?)" +) SYSTEM_GENERATED_REASON_PREFIXES = ( "我上传了", "请按当前已识别信息", diff --git a/server/src/app/services/expense_type_keywords.py b/server/src/app/services/expense_type_keywords.py index 006fc27..a55bfb9 100644 --- a/server/src/app/services/expense_type_keywords.py +++ b/server/src/app/services/expense_type_keywords.py @@ -1,7 +1,6 @@ from __future__ import annotations -from typing import Iterable - +from collections.abc import Iterable EXPENSE_TYPE_KEYWORD_GROUPS: tuple[tuple[str, str, tuple[str, ...]], ...] = ( ( @@ -132,6 +131,22 @@ EXPENSE_TYPE_KEYWORD_GROUPS: tuple[tuple[str, str, tuple[str, ...]], ...] = ( "布展", ), ), + ( + "marketing", + "市场推广费", + ( + "市场推广费", + "市场推广", + "推广费", + "广告费", + "广告投放", + "投放费", + "品牌宣传", + "宣传费", + "营销物料", + "推广物料", + ), + ), ( "office", "办公用品费", @@ -177,6 +192,24 @@ EXPENSE_TYPE_KEYWORD_GROUPS: tuple[tuple[str, str, tuple[str, ...]], ...] = ( "认证", ), ), + ( + "software", + "软件服务费", + ( + "软件服务费", + "软件费", + "软件订阅", + "SaaS", + "SAAS", + "saas", + "SaaS订阅", + "系统服务费", + "云服务费", + "云资源", + "平台服务费", + "技术服务费", + ), + ), ( "communication", "通讯费", diff --git a/server/src/app/services/ontology_budget.py b/server/src/app/services/ontology_budget.py new file mode 100644 index 0000000..81ef08a --- /dev/null +++ b/server/src/app/services/ontology_budget.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +import re +from typing import Any + +from app.schemas.ontology import OntologyEntity, OntologyMetric +from app.services.ontology_rules import ( + BUDGET_CONTEXT_TYPES, + BUDGET_CONTROL_ACTION_KEYWORDS, + BUDGET_KEYWORDS, + BUDGET_REQUIRED_SLOT_KEYS, + BUDGET_STATUS_KEYWORDS, + BUDGET_SUBJECT_KEYWORDS, + BUDGET_SUBJECT_LABEL_BY_CODE, +) + + +class BudgetOntologyMixin: + @staticmethod + def _is_budget_context_value(context_json: dict[str, Any]) -> bool: + document_type = str(context_json.get("document_type") or "").strip() + entry_source = str(context_json.get("entry_source") or "").strip() + session_type = str(context_json.get("session_type") or "").strip() + conversation_scenario = str(context_json.get("conversation_scenario") or "").strip() + return ( + document_type in BUDGET_CONTEXT_TYPES + or entry_source in BUDGET_CONTEXT_TYPES + or session_type in BUDGET_CONTEXT_TYPES + or conversation_scenario == "budget" + ) + + @staticmethod + def _has_budget_signal(compact_query: str) -> bool: + return any(keyword in compact_query for keyword in BUDGET_KEYWORDS) + + @staticmethod + def _infer_budget_missing_slots( + entities: list[OntologyEntity], + context_json: dict[str, Any], + ) -> list[str]: + entity_types = {item.type for item in entities} + budget_values = context_json.get("budget_header") + if not isinstance(budget_values, dict): + budget_values = {} + detail_values = context_json.get("budget_details") + if not isinstance(detail_values, list): + detail_values = [] + + missing_slots: list[str] = [] + has_budget_period = str(budget_values.get("budget_period") or "").strip() + has_department = str(budget_values.get("department") or "").strip() + if "budget_period" not in entity_types and not has_budget_period: + missing_slots.append("budget_period") + if "department" not in entity_types and not has_department: + missing_slots.append("department") + has_subject = "budget_subject" in entity_types or any( + str(item.get("budget_subject") or "").strip() + for item in detail_values + if isinstance(item, dict) + ) + if not has_subject: + missing_slots.append("budget_subject") + has_amount = "budget_amount" in entity_types or any( + str(item.get("budget_amount") or "").strip() + for item in detail_values + if isinstance(item, dict) + ) + if not has_amount: + missing_slots.append("budget_amount") + return [item for item in BUDGET_REQUIRED_SLOT_KEYS if item in missing_slots] + + @staticmethod + def _extract_budget_metrics(compact_query: str) -> list[OntologyMetric]: + metrics: list[OntologyMetric] = [] + if any(keyword in compact_query for keyword in ("预算金额", "预算总额", "预算额度")): + metrics.append(OntologyMetric(name="budget_amount", aggregation="sum", unit="CNY")) + if any( + keyword in compact_query + for keyword in ("可用预算", "剩余预算", "可用余额", "剩余可用") + ): + metrics.append(OntologyMetric(name="available_amount", aggregation="sum", unit="CNY")) + if any( + keyword in compact_query + for keyword in ("已占用", "已预占", "预算占用", "占用金额") + ): + metrics.append(OntologyMetric(name="reserved_amount", aggregation="sum", unit="CNY")) + if any(keyword in compact_query for keyword in ("已发生", "已核销", "已消耗", "已使用")): + metrics.append(OntologyMetric(name="consumed_amount", aggregation="sum", unit="CNY")) + if any(keyword in compact_query for keyword in ("执行率", "使用率")): + metrics.append( + OntologyMetric(name="budget_usage_rate", aggregation="ratio", unit="percent") + ) + return metrics + + def _extract_budget_entities( + self, + query: str, + compact_query: str, + context_json: dict[str, Any], + ) -> list[OntologyEntity]: + entities: list[OntologyEntity] = [] + + if self._is_budget_context_value(context_json) or self._has_budget_signal(compact_query): + entities.append( + self._make_entity( + "document_type", + "预算", + "budget_plan", + role="target", + confidence=0.94, + ) + ) + entities.append( + self._make_entity( + "workflow_stage", + "预算控制", + "budget_control", + role="target", + confidence=0.9, + ) + ) + + period_pattern = ( + r"(?P20\d{2})\s*年\s*" + r"(?:(?PQ[1-4]|[一二三四]季度)|(?P\d{1,2})\s*月|度)?" + ) + for match in re.finditer(period_pattern, query, flags=re.IGNORECASE): + year = match.group("year") + quarter = match.group("quarter") + month = match.group("month") + if quarter: + quarter_text = quarter.upper() if quarter.upper().startswith("Q") else quarter + normalized = f"{year}年{quarter_text}" + elif month: + normalized = f"{year}年{int(month)}月" + else: + normalized = f"{year}年度" + entities.append( + self._make_entity( + "budget_period", + match.group(0).strip(), + normalized, + role="filter", + confidence=0.88, + ) + ) + + for code in re.findall(r"CC-\d+", query, flags=re.IGNORECASE): + entities.append( + self._make_entity( + "cost_center", + code, + code.upper(), + role="filter", + confidence=0.92, + ) + ) + + for label, normalized in BUDGET_SUBJECT_KEYWORDS.items(): + if label in query: + subject_label = BUDGET_SUBJECT_LABEL_BY_CODE.get(normalized, label) + entities.append( + self._make_entity( + "budget_subject", + label, + normalized, + role="filter", + confidence=0.9, + ) + ) + entities.append( + self._make_entity( + "expense_type", + subject_label, + normalized, + role="filter", + confidence=0.9, + ) + ) + + for label, normalized in BUDGET_STATUS_KEYWORDS.items(): + if label in query: + entities.append( + self._make_entity( + "budget_status", + label, + normalized, + role="filter", + confidence=0.86, + ) + ) + + for label, normalized in BUDGET_CONTROL_ACTION_KEYWORDS.items(): + if label in query: + entities.append( + self._make_entity( + "control_action", + label, + normalized, + role="target", + confidence=0.84, + ) + ) + + version_match = re.search(r"V\d+(?:\.\d+){0,2}", query, flags=re.IGNORECASE) + if version_match: + version = version_match.group(0).upper() + entities.append( + self._make_entity( + "budget_version", + version, + version, + role="filter", + confidence=0.86, + ) + ) + + warning_match = re.search(r"(?:预警线|预警阈值|预算预警)\s*(?P\d{1,3})\s*%", query) + if warning_match: + value = f"{warning_match.group('value')}%" + entities.append( + self._make_entity( + "warning_threshold", + value, + value, + role="threshold", + confidence=0.9, + ) + ) + + entities.extend(self._extract_budget_amount_entities(query)) + return entities + + def _extract_budget_amount_entities(self, query: str) -> list[OntologyEntity]: + entities: list[OntologyEntity] = [] + patterns = ( + ( + "budget_amount", + r"(?:预算金额|预算额度|预算总额)\s*(?P\d+(?:\.\d+)?)\s*(?P万元|万|元)?", + ), + ( + "available_amount", + r"(?:可用预算|剩余预算|可用余额|剩余可用)\s*(?P\d+(?:\.\d+)?)\s*(?P万元|万|元)?", + ), + ( + "reserved_amount", + r"(?:已占用|已预占|占用金额|预算占用)\s*(?P\d+(?:\.\d+)?)\s*(?P万元|万|元)?", + ), + ( + "consumed_amount", + r"(?:已发生|已核销|已消耗|已使用)\s*(?P\d+(?:\.\d+)?)\s*(?P万元|万|元)?", + ), + ) + for entity_type, pattern in patterns: + for match in re.finditer(pattern, query): + raw_value = match.group("value") + unit = match.group("unit") + amount_value = self._normalize_amount(raw_value, unit) + display_value = f"{raw_value}{unit or ''}" + entities.append( + self._make_entity( + entity_type, + display_value, + str(amount_value), + role="target", + confidence=0.9, + ) + ) + return entities diff --git a/server/src/app/services/ontology_detection.py b/server/src/app/services/ontology_detection.py index 2017ccb..e315e59 100644 --- a/server/src/app/services/ontology_detection.py +++ b/server/src/app/services/ontology_detection.py @@ -15,8 +15,10 @@ from app.schemas.ontology import ( OntologyTimeRange, ) from app.services.ontology_rules import ( - AR_CORE_KEYWORDS, AP_CORE_KEYWORDS, + AR_CORE_KEYWORDS, + BUDGET_DRAFT_KEYWORDS, + BUDGET_OPERATE_KEYWORDS, COMPARE_KEYWORDS, DRAFT_FOLLOW_UP_KEYWORDS, DRAFT_KEYWORDS, @@ -27,13 +29,13 @@ from app.services.ontology_rules import ( EXPLAIN_KEYWORDS, GENERIC_EXPENSE_PROMPTS, KNOWLEDGE_INTENTS, - LlmOntologyEntityHint, - LlmOntologyParseResult, OPERATE_KEYWORDS, QUERY_KEYWORDS, RISK_KEYWORDS, SCENARIO_KEYWORDS, STATUS_KEYWORDS, + LlmOntologyEntityHint, + LlmOntologyParseResult, ) logger = get_logger("app.services.ontology") @@ -99,6 +101,9 @@ class OntologyDetectionMixin: best_scenario = max(scores, key=scores.get) best_score = scores[best_scenario] + if scores.get("budget", 0.0) > 0 and scores["budget"] >= best_score: + best_scenario = "budget" + best_score = scores["budget"] if best_score <= 0: if "单据" in compact_query and any( keyword in compact_query for keyword in STATUS_KEYWORDS @@ -111,9 +116,10 @@ class OntologyDetectionMixin: scores["expense"], scores["accounts_receivable"], scores["accounts_payable"], + scores["budget"], ] if max(business_scores) > 0: - best_scenario = ("expense", "accounts_receivable", "accounts_payable")[ + best_scenario = ("expense", "accounts_receivable", "accounts_payable", "budget")[ business_scores.index(max(business_scores)) ] best_score = max(business_scores) @@ -130,6 +136,14 @@ class OntologyDetectionMixin: ) -> tuple[str, float]: if any(keyword in compact_query for keyword in OPERATE_KEYWORDS): return "operate", 0.30 + if scenario == "budget" and any( + keyword in compact_query for keyword in BUDGET_OPERATE_KEYWORDS + ): + return "operate", 0.30 + if scenario == "budget" and any( + keyword in compact_query for keyword in BUDGET_DRAFT_KEYWORDS + ): + return "draft", 0.28 status_document_query = ( "单据" in compact_query and any(keyword in compact_query for keyword in STATUS_KEYWORDS) @@ -383,13 +397,15 @@ class OntologyDetectionMixin: "你的任务是把用户输入解析为固定 JSON,用于后续路由、追问和权限判断。" "只输出 JSON 对象,不要输出 Markdown、代码块、解释、标题或 。" "场景 scenario 只能是:expense, accounts_receivable, " - "accounts_payable, knowledge, unknown。" + "accounts_payable, budget, knowledge, unknown。" "意图 intent 只能是:query, explain, compare, risk_check, draft, operate。" "如果用户是在描述一笔待处理费用、待报销事项、上传票据或希望整理报销," "即使没有明确说“生成草稿”,也优先使用 expense + draft。" "如果提供了 conversation_history,必须把最近轮次作为当前追问的上下文," "正确理解“这个”“那笔”“改成 800”“继续补充”这类省略表达。" "出现“客户”不等于应收,出现“供应商”不等于应付,必须结合动作词和业务目标判断。" + "预算编制、预算金额、成本中心、预算科目、预算预警、预算占用、" + "剩余预算、可用预算、超预算、预算不足等问题必须使用 budget 场景。" "只有明确查询、统计、列出、多少、明细、对比时才优先使用 query 或 compare。" "附件名称和 OCR 摘要只作为辅助证据,不能编造未出现的事实。" "如果用户明确提到打车、的士票、出租车票、网约车、乘车费、车费等交通票据," @@ -397,7 +413,8 @@ class OntologyDetectionMixin: "不要输出用户原文未出现、且与规则候选冲突的费用类型。" "信息不足时 clarification_required=true,并给出一句简短中文追问。" "missing_slots 使用简短 snake_case,例如 expense_type, amount, " - "customer_name, participants, attachments。" + "customer_name, participants, attachments, budget_period, " + "budget_subject, budget_amount。" "entity_hints 只填写你比较确定的业务对象;如果不确定,可以返回空数组。" "费用申请场景下,建议把干净的申请事由放入 type=reason," "把出行方式放入 type=transport_mode,取值优先为飞机、火车、轮船。" @@ -422,6 +439,9 @@ class OntologyDetectionMixin: '"confidence": 0.86},\n' ' {"type": "reason", "value": "服务客户业务部署", ' '"normalized_value": "服务客户业务部署", "role": "target", ' + '"confidence": 0.86},\n' + ' {"type": "budget_subject", "value": "差旅费", ' + '"normalized_value": "travel", "role": "filter", ' '"confidence": 0.86}\n' " ]\n" "}" diff --git a/server/src/app/services/ontology_extraction.py b/server/src/app/services/ontology_extraction.py index c4792c8..ba58265 100644 --- a/server/src/app/services/ontology_extraction.py +++ b/server/src/app/services/ontology_extraction.py @@ -14,28 +14,28 @@ from app.schemas.ontology import ( OntologyTimeRange, ) from app.services.document_numbering import DOCUMENT_NUMBER_EXTRACT_PATTERN +from app.services.ontology_budget import BudgetOntologyMixin from app.services.ontology_rules import ( AMOUNT_PATTERN, DATE_RANGE_PATTERN, - EXPLICIT_DATE_PATTERN, - EXPLICIT_MONTH_PATTERN, EXPENSE_APPLICATION_ATTACHMENT_REQUIRED_TYPES, EXPENSE_APPLICATION_CONTEXT_TYPES, EXPENSE_APPLICATION_KEYWORDS, EXPENSE_APPLICATION_REQUIRED_SLOT_KEYS, EXPENSE_TYPE_KEYWORDS, + EXPLICIT_DATE_PATTERN, + EXPLICIT_MONTH_PATTERN, GENERIC_EXPENSE_APPLICATION_PROMPTS, - GENERIC_EXPENSE_PROMPTS, LOCATION_KEYWORDS, MONTH_DAY_PATTERN, MONTH_DAY_RANGE_PATTERN, - ReferenceCatalog, STATUS_KEYWORDS, TOP_N_PATTERN, + ReferenceCatalog, ) -class OntologyExtractionMixin: +class OntologyExtractionMixin(BudgetOntologyMixin): @staticmethod def _is_expense_application_context_value(context_json: dict[str, Any]) -> bool: document_type = str(context_json.get("document_type") or "").strip() @@ -63,6 +63,9 @@ class OntologyExtractionMixin: time_range: OntologyTimeRange, context_json: dict[str, Any], ) -> list[str]: + if scenario == "budget" and intent == "draft": + return self._infer_budget_missing_slots(entities, context_json) + if scenario != "expense" or intent != "draft": return [] @@ -87,7 +90,8 @@ class OntologyExtractionMixin: for item in entities if item.type == "expense_type" } - if "expense_type" not in entity_types and not str(form_values.get("expense_type") or "").strip(): + form_expense_type = str(form_values.get("expense_type") or "").strip() + if "expense_type" not in entity_types and not form_expense_type: missing_slots.append("expense_type") if "amount" not in entity_types and not str(form_values.get("amount") or "").strip(): missing_slots.append("amount") @@ -103,7 +107,10 @@ class OntologyExtractionMixin: ).strip() if not reason_value and compact_query in GENERIC_EXPENSE_APPLICATION_PROMPTS: missing_slots.append("reason") - if attachment_count <= 0 and expense_type_codes & EXPENSE_APPLICATION_ATTACHMENT_REQUIRED_TYPES: + if ( + attachment_count <= 0 + and expense_type_codes & EXPENSE_APPLICATION_ATTACHMENT_REQUIRED_TYPES + ): missing_slots.append("attachments") ordered_keys = [*EXPENSE_APPLICATION_REQUIRED_SLOT_KEYS, "attachments"] return [item for item in ordered_keys if item in missing_slots] @@ -193,6 +200,9 @@ class OntologyExtractionMixin: ) ) + for entity in self._extract_budget_entities(query, compact_query, context_json): + upsert(entity) + for match in re.finditer(r"客户\s*([A-Za-z0-9一二三四五六七八九十]+)", query): suffix = match.group(1).strip() normalized = f"客户{suffix}".replace(" ", "") @@ -257,7 +267,15 @@ class OntologyExtractionMixin: upsert(self._make_entity("contract", code, code.upper())) for location in LOCATION_KEYWORDS: if location in query: - upsert(self._make_entity("location", location, location, role="filter", confidence=0.86)) + upsert( + self._make_entity( + "location", + location, + location, + role="filter", + confidence=0.86, + ) + ) for label, normalized in EXPENSE_TYPE_KEYWORDS.items(): if label in query: @@ -301,34 +319,139 @@ class OntologyExtractionMixin: "高速费", ) ): - upsert(self._make_entity("expense_type", "交通", "transport", role="filter", confidence=0.9)) - - if any(keyword in query for keyword in ("出差", "机票", "飞机票", "航班", "火车票", "火车", "高铁票", "高铁", "动车", "行程单")): - upsert(self._make_entity("expense_type", "差旅", "travel", role="filter", confidence=0.88)) - - if any(keyword in query for keyword in ("酒店", "酒店发票", "住宿", "住宿费", "宾馆", "民宿", "房费", "客房")): - upsert(self._make_entity("expense_type", "住宿", "hotel", role="filter", confidence=0.86)) - - if ( - not has_customer_entertainment_signal - and any(keyword in query for keyword in ("餐费", "用餐", "午餐", "晚餐", "早餐", "餐饮")) - ): - upsert(self._make_entity("expense_type", "业务招待费", "meal", role="filter", confidence=0.84)) + upsert( + self._make_entity( + "expense_type", + "交通", + "transport", + role="filter", + confidence=0.9, + ) + ) if any( keyword in query - for keyword in ("办公用品", "文具", "耗材", "办公耗材", "打印纸", "办公设备", "键盘", "鼠标", "白板", "硒鼓", "墨盒") + for keyword in ( + "出差", + "机票", + "飞机票", + "航班", + "火车票", + "火车", + "高铁票", + "高铁", + "动车", + "行程单", + ) ): - upsert(self._make_entity("expense_type", "办公用品费", "office", role="filter", confidence=0.87)) + upsert( + self._make_entity( + "expense_type", + "差旅", + "travel", + role="filter", + confidence=0.88, + ) + ) - if any(keyword in query for keyword in ("培训", "讲师费", "课时费", "课程费", "教材", "认证费", "考试费")): - upsert(self._make_entity("expense_type", "培训费", "training", role="filter", confidence=0.84)) + if any( + keyword in query + for keyword in ("酒店", "酒店发票", "住宿", "住宿费", "宾馆", "民宿", "房费", "客房") + ): + upsert( + self._make_entity( + "expense_type", + "住宿", + "hotel", + role="filter", + confidence=0.86, + ) + ) - if any(keyword in query for keyword in ("通讯费", "话费", "电话费", "手机费", "流量费", "宽带费", "网络费")): - upsert(self._make_entity("expense_type", "通讯费", "communication", role="filter", confidence=0.84)) + if ( + not has_customer_entertainment_signal + and any( + keyword in query + for keyword in ("餐费", "用餐", "午餐", "晚餐", "早餐", "餐饮") + ) + ): + upsert( + self._make_entity( + "expense_type", + "业务招待费", + "meal", + role="filter", + confidence=0.84, + ) + ) - if any(keyword in query for keyword in ("福利费", "团建", "慰问", "节日福利", "体检费", "员工关怀")): - upsert(self._make_entity("expense_type", "福利费", "welfare", role="filter", confidence=0.84)) + if any( + keyword in query + for keyword in ( + "办公用品", + "文具", + "耗材", + "办公耗材", + "打印纸", + "办公设备", + "键盘", + "鼠标", + "白板", + "硒鼓", + "墨盒", + ) + ): + upsert( + self._make_entity( + "expense_type", + "办公用品费", + "office", + role="filter", + confidence=0.87, + ) + ) + + if any( + keyword in query + for keyword in ("培训", "讲师费", "课时费", "课程费", "教材", "认证费", "考试费") + ): + upsert( + self._make_entity( + "expense_type", + "培训费", + "training", + role="filter", + confidence=0.84, + ) + ) + + if any( + keyword in query + for keyword in ("通讯费", "话费", "电话费", "手机费", "流量费", "宽带费", "网络费") + ): + upsert( + self._make_entity( + "expense_type", + "通讯费", + "communication", + role="filter", + confidence=0.84, + ) + ) + + if any( + keyword in query + for keyword in ("福利费", "团建", "慰问", "节日福利", "体检费", "员工关怀") + ): + upsert( + self._make_entity( + "expense_type", + "福利费", + "welfare", + role="filter", + confidence=0.84, + ) + ) for amount in self._extract_amount_entities(query): upsert(amount) @@ -380,6 +503,20 @@ class OntologyExtractionMixin: @staticmethod def _infer_scenario_from_entities(entities: list[OntologyEntity]) -> str | None: entity_types = {item.type for item in entities} + if entity_types & { + "budget_period", + "budget_subject", + "budget_status", + "budget_version", + "budget_amount", + "available_amount", + "reserved_amount", + "consumed_amount", + "cost_center", + "warning_threshold", + "control_action", + }: + return "budget" if entity_types & {"vendor", "payable"}: return "accounts_payable" if entity_types & {"customer", "receivable", "contract"}: @@ -548,9 +685,11 @@ class OntologyExtractionMixin: if any( keyword in compact_query - for keyword in ("多少钱", "金额", "总额", "支出", "回款", "应收", "应付") + for keyword in ("多少钱", "金额", "总额", "支出", "回款", "应收", "应付", "预算") ): upsert(OntologyMetric(name="amount", aggregation="sum", unit="CNY")) + for metric in self._extract_budget_metrics(compact_query): + upsert(metric) if any(keyword in compact_query for keyword in ("多少笔", "几笔", "数量", "条数", "单数")): upsert(OntologyMetric(name="count", aggregation="count", unit="records")) if "超标" in compact_query or "超预算" in compact_query: @@ -600,6 +739,17 @@ class OntologyExtractionMixin: "expense_type", "document_type", "workflow_stage", + "budget_period", + "budget_subject", + "budget_status", + "budget_version", + "budget_amount", + "available_amount", + "reserved_amount", + "consumed_amount", + "cost_center", + "warning_threshold", + "control_action", }: upsert( OntologyConstraint( diff --git a/server/src/app/services/ontology_rules.py b/server/src/app/services/ontology_rules.py index 1347c30..4a20062 100644 --- a/server/src/app/services/ontology_rules.py +++ b/server/src/app/services/ontology_rules.py @@ -6,7 +6,10 @@ from dataclasses import dataclass from pydantic import BaseModel, ConfigDict, Field from app.schemas.ontology import OntologyIntent, OntologyScenario -from app.services.expense_type_keywords import build_expense_type_keyword_map +from app.services.expense_type_keywords import ( + EXPENSE_TYPE_LABEL_BY_CODE, + build_expense_type_keyword_map, +) DATE_RANGE_PATTERN = re.compile( r"(?P\d{4}-\d{1,2}-\d{1,2})\s*(?:到|至|~|-)\s*(?P\d{4}-\d{1,2}-\d{1,2})" @@ -61,6 +64,27 @@ SCENARIO_KEYWORDS = { ("待付", 0.16), ("打款", 0.18), ), + "budget": ( + ("预算中心", 0.28), + ("预算管理", 0.26), + ("预算编制", 0.24), + ("预算", 0.20), + ("预算额度", 0.22), + ("预算金额", 0.22), + ("可用预算", 0.22), + ("剩余预算", 0.22), + ("预算余额", 0.20), + ("预算占用", 0.22), + ("预算预占", 0.22), + ("预占", 0.16), + ("核销", 0.16), + ("成本中心", 0.22), + ("预算科目", 0.22), + ("预算预警", 0.22), + ("预警线", 0.18), + ("超预算", 0.24), + ("预算不足", 0.24), + ), "knowledge": ( ("制度", 0.20), ("规则", 0.20), @@ -216,6 +240,56 @@ EXPENSE_APPLICATION_ATTACHMENT_REQUIRED_TYPES = { "office", "training", } +BUDGET_CONTEXT_TYPES = { + "budget", + "budget_plan", + "budget_center", + "budget_management", +} +BUDGET_KEYWORDS = tuple(keyword for keyword, _weight in SCENARIO_KEYWORDS["budget"]) +BUDGET_DRAFT_KEYWORDS = ( + "新建预算", + "创建预算", + "编制预算", + "编辑预算", + "调整预算", + "保存预算", + "预算草稿", +) +BUDGET_OPERATE_KEYWORDS = ( + "发布预算", + "冻结预算", + "解冻预算", + "启用预算", + "停用预算", +) +BUDGET_REQUIRED_SLOT_KEYS = ( + "budget_period", + "department", + "budget_subject", + "budget_amount", +) +BUDGET_SUBJECT_KEYWORDS = EXPENSE_TYPE_KEYWORDS +BUDGET_SUBJECT_LABEL_BY_CODE = EXPENSE_TYPE_LABEL_BY_CODE +BUDGET_STATUS_KEYWORDS = { + "编制中": "drafting", + "草稿": "draft", + "已发布": "published", + "发布": "published", + "已冻结": "frozen", + "冻结": "frozen", + "已关闭": "closed", + "关闭": "closed", +} +BUDGET_CONTROL_ACTION_KEYWORDS = { + "提醒": "remind", + "预警": "remind", + "正常": "allow", + "允许": "allow", + "管控": "control", + "阻断": "block", + "禁止": "block", +} MISSING_SLOT_LABELS = { "expense_type": "费用类型", "amount": "金额", @@ -226,6 +300,13 @@ MISSING_SLOT_LABELS = { "time_range": "发生时间", "reason": "事由说明", "document_id": "单据号", + "department": "所属部门", + "budget_period": "预算周期", + "budget_subject": "预算科目", + "budget_amount": "预算金额", + "cost_center": "成本中心", + "warning_threshold": "预警线", + "control_action": "控制动作", } STATUS_KEYWORDS = { @@ -278,7 +359,7 @@ LOCATION_KEYWORDS = ( ) PRIVILEGED_ROLE_CODES = {"manager", "finance", "approver", "executive"} -CONTEXTUAL_SCENARIOS = {"expense", "accounts_receivable", "accounts_payable", "knowledge"} +CONTEXTUAL_SCENARIOS = {"expense", "accounts_receivable", "accounts_payable", "budget", "knowledge"} KNOWLEDGE_INTENTS = {"query", "explain", "compare"} diff --git a/server/src/app/services/ontology_validation.py b/server/src/app/services/ontology_validation.py index 9f6bc88..4e8ef01 100644 --- a/server/src/app/services/ontology_validation.py +++ b/server/src/app/services/ontology_validation.py @@ -12,7 +12,6 @@ from app.schemas.ontology import ( OntologyTimeRange, ) from app.services.ontology_rules import ( - AMOUNT_PATTERN, EXPENSE_REVIEW_ACTIONS, MISSING_SLOT_LABELS, OPERATE_KEYWORDS, @@ -37,6 +36,14 @@ class OntologyValidationMixin: append("invoice_anomaly") if any(keyword in compact_query for keyword in ("超标", "超预算", "超限")): append("amount_over_limit") + if scenario == "budget" and any( + keyword in compact_query for keyword in ("预算不足", "超预算", "超支") + ): + append("budget_over_limit") + if scenario == "budget" and any( + keyword in compact_query for keyword in ("预算预警", "触发预警", "接近预算") + ): + append("budget_warning") if scenario == "accounts_receivable" and any( keyword in compact_query for keyword in ("逾期", "账龄", "欠款", "未回款") ): diff --git a/server/src/app/services/orchestrator_expense_query.py b/server/src/app/services/orchestrator_expense_query.py index 6f5b61a..bfc30cf 100644 --- a/server/src/app/services/orchestrator_expense_query.py +++ b/server/src/app/services/orchestrator_expense_query.py @@ -83,8 +83,10 @@ EXPENSE_TYPE_LABELS = { "meal": "业务招待费", "meeting": "会务费", "entertainment": "业务招待费", + "marketing": "市场推广费", "office": "办公用品费", "training": "培训费", + "software": "软件服务费", "communication": "通讯费", "welfare": "福利费", "other": "其他费用", @@ -131,7 +133,9 @@ class OrchestratorDatabaseQueryBuilder: message=message, ) count_stmt = select(func.count()).select_from(ExpenseClaim) - amount_stmt = select(func.coalesce(func.sum(ExpenseClaim.amount), 0)).select_from(ExpenseClaim) + amount_stmt = select(func.coalesce(func.sum(ExpenseClaim.amount), 0)).select_from( + ExpenseClaim + ) for condition in conditions: count_stmt = count_stmt.where(condition) amount_stmt = amount_stmt.where(condition) @@ -148,7 +152,9 @@ class OrchestratorDatabaseQueryBuilder: if recent_window_applied: reference_now = self._resolve_reference_now(context_json) - recent_window_start, recent_window_end = self._resolve_expense_recent_window_bounds(reference_now) + recent_window_start, recent_window_end = self._resolve_expense_recent_window_bounds( + reference_now + ) recent_condition = self._build_expense_recent_window_condition( recent_window_start, recent_window_end, @@ -157,9 +163,13 @@ class OrchestratorDatabaseQueryBuilder: window_start_date = recent_window_start.date().isoformat() window_end_date = (recent_window_end - timedelta(microseconds=1)).date().isoformat() - recent_count_stmt = select(func.count()).select_from(ExpenseClaim).where(recent_condition) - recent_amount_stmt = select(func.coalesce(func.sum(ExpenseClaim.amount), 0)).select_from(ExpenseClaim).where( - recent_condition + recent_count_stmt = ( + select(func.count()).select_from(ExpenseClaim).where(recent_condition) + ) + recent_amount_stmt = ( + select(func.coalesce(func.sum(ExpenseClaim.amount), 0)) + .select_from(ExpenseClaim) + .where(recent_condition) ) for condition in conditions: recent_count_stmt = recent_count_stmt.where(condition) @@ -189,7 +199,11 @@ class OrchestratorDatabaseQueryBuilder: "record_count": display_count, "total_amount": round(display_amount, 2), "scope_label": scope_label, - "title": f"最近 {len(preview_claims)} 条{scope_label}" if preview_claims else f"{scope_label}筛选结果", + "title": ( + f"最近 {len(preview_claims)} 条{scope_label}" + if preview_claims + else f"{scope_label}筛选结果" + ), "scoped_to_current_user": scoped_to_current_user, "recent_window_applied": recent_window_applied, "window_days": EXPENSE_QUERY_RECENT_WINDOW_DAYS if recent_window_applied else None, @@ -280,7 +294,8 @@ class OrchestratorDatabaseQueryBuilder: reference_now: datetime, ) -> tuple[datetime, datetime]: normalized_now = reference_now.astimezone(UTC) - window_end = normalized_now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1) + window_end = normalized_now.replace(hour=0, minute=0, second=0, microsecond=0) + window_end += timedelta(days=1) window_start = window_end - timedelta(days=EXPENSE_QUERY_RECENT_WINDOW_DAYS) return window_start, window_end @@ -300,7 +315,11 @@ class OrchestratorDatabaseQueryBuilder: self, conditions: list[Any], ) -> list[dict[str, Any]]: - stmt = select(ExpenseClaim.status, func.count()).select_from(ExpenseClaim).group_by(ExpenseClaim.status) + stmt = ( + select(ExpenseClaim.status, func.count()) + .select_from(ExpenseClaim) + .group_by(ExpenseClaim.status) + ) for condition in conditions: stmt = stmt.where(condition) @@ -356,7 +375,10 @@ class OrchestratorDatabaseQueryBuilder: "claim_no": claim.claim_no, "employee_name": claim.employee_name, "expense_type": claim.expense_type, - "expense_type_label": EXPENSE_TYPE_LABELS.get(claim.expense_type, claim.expense_type or "报销"), + "expense_type_label": EXPENSE_TYPE_LABELS.get( + claim.expense_type, + claim.expense_type or "报销", + ), "amount": round(float(claim.amount), 2), "status": claim.status, "status_label": status_label, @@ -378,7 +400,11 @@ class OrchestratorDatabaseQueryBuilder: normalized_flags: list[dict[str, str]] = [] for index, raw_flag in enumerate(raw_flags, start=1): if isinstance(raw_flag, dict): - raw_level = str(raw_flag.get("severity") or raw_flag.get("level") or "").strip().lower() + raw_level = ( + str(raw_flag.get("severity") or raw_flag.get("level") or "") + .strip() + .lower() + ) level = raw_level if raw_level in EXPENSE_RISK_LEVEL_LABELS else "medium" summary = str( raw_flag.get("message") @@ -397,7 +423,11 @@ class OrchestratorDatabaseQueryBuilder: raw_text = str(raw_flag or "").strip() if not raw_text: continue - level = "high" if any(keyword in raw_text for keyword in ("高风险", "超标", "重复", "异常")) else "medium" + level = ( + "high" + if any(keyword in raw_text for keyword in ("高风险", "超标", "重复", "异常")) + else "medium" + ) summary = raw_text detail = raw_text title = EXPENSE_RISK_LEVEL_LABELS[level] @@ -436,14 +466,16 @@ class OrchestratorDatabaseQueryBuilder: dict.fromkeys( str(item.normalized_value or item.value or "").strip().upper() for item in ontology.entities - if item.type == "expense_claim" and str(item.normalized_value or item.value or "").strip() + if item.type == "expense_claim" + and str(item.normalized_value or item.value or "").strip() ) ) expense_types = list( dict.fromkeys( str(item.normalized_value or item.value or "").strip() for item in ontology.entities - if item.type == "expense_type" and str(item.normalized_value or item.value or "").strip() + if item.type == "expense_type" + and str(item.normalized_value or item.value or "").strip() ) ) project_values = self._collect_expense_query_filter_values(ontology, "project") @@ -551,7 +583,11 @@ class OrchestratorDatabaseQueryBuilder: else: scope_label = "全部报销单" - return conditions, self._compose_expense_scope_label(scope_label, status_values), scoped_to_current_user + return ( + conditions, + self._compose_expense_scope_label(scope_label, status_values), + scoped_to_current_user, + ) @staticmethod def _resolve_expense_query_status_values( diff --git a/server/src/app/services/risk_rule_generation.py b/server/src/app/services/risk_rule_generation.py index fb6001a..a3ae3b5 100644 --- a/server/src/app/services/risk_rule_generation.py +++ b/server/src/app/services/risk_rule_generation.py @@ -22,6 +22,7 @@ from app.services.risk_rule_flow_diagram import ( from app.services.risk_rule_generation_ontology import ( BUSINESS_DOMAIN_LABELS, DOMAIN_FIELD_PREFIXES, + EXPENSE_BUSINESS_STAGE_LABELS, EXPENSE_RISK_CATEGORY_ALIASES, EXPENSE_RISK_CATEGORY_LABELS, FIELD_ONTOLOGY, @@ -75,6 +76,8 @@ class RiskRuleGenerationService: raise ValueError("规则标题至少需要 2 个字。") requires_attachment = bool(body.requires_attachment) + business_stage = self._normalize_business_stage(body.business_stage, domain) + business_stage_label = EXPENSE_BUSINESS_STAGE_LABELS.get(business_stage, "费用报销") expense_category = self._normalize_expense_category(body.expense_category, domain) expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "") @@ -83,6 +86,8 @@ class RiskRuleGenerationService: draft = self._compile_with_model( natural_language=natural_language, domain=domain, + business_stage=business_stage, + business_stage_label=business_stage_label, expense_category=expense_category, expense_category_label=expense_category_label, fields=fields, @@ -113,6 +118,8 @@ class RiskRuleGenerationService: draft, natural_language=natural_language, domain=domain, + business_stage=business_stage, + business_stage_label=business_stage_label, expense_category=expense_category, expense_category_label=expense_category_label, risk_level=risk_level, @@ -155,6 +162,8 @@ class RiskRuleGenerationService: "requires_attachment": requires_attachment, "tag": "风险规则", "detail_mode": "json_risk", + "business_stage": business_stage, + "business_stage_label": business_stage_label, "expense_category": expense_category, "expense_category_label": expense_category_label, "risk_category": payload.get("risk_category"), @@ -167,6 +176,11 @@ class RiskRuleGenerationService: "evaluator": payload.get("evaluator"), "generated_by": "natural_language", "source_ref": "自然语言风险规则", + "last_operation": { + "action": "create", + "actor": actor, + "at": datetime.now(UTC).isoformat(), + }, }, ) self.db.add(asset) @@ -192,6 +206,7 @@ class RiskRuleGenerationService: "risk_level": risk_level, "risk_score": risk_score["score"], "domain": domain, + "business_stage": business_stage, "expense_category": expense_category, "requires_attachment": requires_attachment, }, @@ -205,6 +220,8 @@ class RiskRuleGenerationService: *, natural_language: str, domain: str, + business_stage: str, + business_stage_label: str, expense_category: str | None, expense_category_label: str, fields: list[RiskRuleField], @@ -221,6 +238,8 @@ class RiskRuleGenerationService: messages = build_risk_rule_compiler_messages( domain=domain, domain_label=BUSINESS_DOMAIN_LABELS[domain], + business_stage=business_stage, + business_stage_label=business_stage_label, expense_category=expense_category, expense_category_label=expense_category_label, natural_language=natural_language, @@ -372,6 +391,8 @@ class RiskRuleGenerationService: *, natural_language: str, domain: str, + business_stage: str, + business_stage_label: str, expense_category: str | None, expense_category_label: str, risk_level: str, @@ -408,6 +429,8 @@ class RiskRuleGenerationService: "field_keys": field_keys, "condition_summary": condition_summary, "natural_language": natural_language, + "business_stage": business_stage, + "business_stage_label": business_stage_label, } semantic_type = str(draft.get("semantic_type") or "").strip() if semantic_type: @@ -431,6 +454,8 @@ class RiskRuleGenerationService: params["keywords"] = keywords params["search_fields"] = field_keys applies_to: dict[str, Any] = {"domains": [domain]} + if business_stage: + applies_to["business_stages"] = [business_stage] if expense_category: applies_to["expense_categories"] = [expense_category] @@ -485,6 +510,8 @@ class RiskRuleGenerationService: "rule_title": rule_title, "expense_category": expense_category, "expense_category_label": expense_category_label, + "business_stage": business_stage, + "business_stage_label": business_stage_label, "natural_language": natural_language, "business_explanation": self._clean_text(draft.get("description")), "condition_summary": condition_summary, @@ -558,6 +585,19 @@ class RiskRuleGenerationService: raise ValueError(f"费用领域仅支持:{allowed}。") return normalized + @staticmethod + def _normalize_business_stage(value: str | None, domain: str) -> str: + if domain != AgentAssetDomain.EXPENSE.value: + return "reimbursement" + + normalized = str(value or "reimbursement").strip().lower() + if not normalized: + normalized = "reimbursement" + if normalized not in EXPENSE_BUSINESS_STAGE_LABELS: + allowed = "、".join(EXPENSE_BUSINESS_STAGE_LABELS.values()) + raise ValueError(f"业务环节仅支持:{allowed}。") + return normalized + def _resolve_fields(self, text: str, *, domain: str) -> list[RiskRuleField]: prefixes = DOMAIN_FIELD_PREFIXES.get(domain, ()) candidates = [field for field in FIELD_ONTOLOGY if field.key.startswith(prefixes)] diff --git a/server/src/app/services/risk_rule_generation_jobs.py b/server/src/app/services/risk_rule_generation_jobs.py index 88de06a..8d7119a 100644 --- a/server/src/app/services/risk_rule_generation_jobs.py +++ b/server/src/app/services/risk_rule_generation_jobs.py @@ -12,6 +12,7 @@ from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY from app.services.audit import AuditLogService from app.services.risk_rule_generation import ( BUSINESS_DOMAIN_LABELS, + EXPENSE_BUSINESS_STAGE_LABELS, EXPENSE_RISK_CATEGORY_LABELS, RiskRuleGenerationService, ) @@ -49,6 +50,8 @@ class RiskRuleGenerationJobService: natural_language = self._validate_natural_language(body) rule_title = self._validate_rule_title(body) requires_attachment = bool(body.requires_attachment) + business_stage = self.generator._normalize_business_stage(body.business_stage, domain) + business_stage_label = EXPENSE_BUSINESS_STAGE_LABELS.get(business_stage, "费用报销") expense_category = self.generator._normalize_expense_category(body.expense_category, domain) expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "") @@ -82,6 +85,8 @@ class RiskRuleGenerationJobService: "requires_attachment": requires_attachment, "tag": "风险规则", "detail_mode": "json_risk", + "business_stage": business_stage, + "business_stage_label": business_stage_label, "expense_category": expense_category, "expense_category_label": expense_category_label, "risk_category": category_label, @@ -94,6 +99,11 @@ class RiskRuleGenerationJobService: "generation_status": AgentAssetStatus.GENERATING.value, "generation_started_at": created_at.isoformat(), "generation_request": self._dump_generation_request(body), + "last_operation": { + "action": "generate", + "actor": actor, + "at": created_at.isoformat(), + }, }, ) self.db.add(asset) @@ -107,6 +117,7 @@ class RiskRuleGenerationJobService: after_json={ "rule_code": rule_code, "domain": domain, + "business_stage": business_stage, "expense_category": expense_category, }, request_id=request_id, @@ -181,6 +192,8 @@ class RiskRuleGenerationJobService: natural_language = self._validate_natural_language(body) rule_title = self._validate_rule_title(body) requires_attachment = bool(body.requires_attachment) + business_stage = self.generator._normalize_business_stage(body.business_stage, domain) + business_stage_label = EXPENSE_BUSINESS_STAGE_LABELS.get(business_stage, "费用报销") expense_category = self.generator._normalize_expense_category(body.expense_category, domain) expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "") created_at = asset.created_at or datetime.now(UTC) @@ -189,6 +202,8 @@ class RiskRuleGenerationJobService: draft = self.generator._compile_with_model( natural_language=natural_language, domain=domain, + business_stage=business_stage, + business_stage_label=business_stage_label, expense_category=expense_category, expense_category_label=expense_category_label, fields=fields, @@ -219,6 +234,8 @@ class RiskRuleGenerationJobService: draft, natural_language=natural_language, domain=domain, + business_stage=business_stage, + business_stage_label=business_stage_label, expense_category=expense_category, expense_category_label=expense_category_label, risk_level=risk_level, @@ -247,6 +264,8 @@ class RiskRuleGenerationJobService: "requires_attachment": requires_attachment, "tag": "风险规则", "detail_mode": "json_risk", + "business_stage": business_stage, + "business_stage_label": business_stage_label, "expense_category": expense_category, "expense_category_label": expense_category_label, "risk_category": payload.get("risk_category"), @@ -261,6 +280,11 @@ class RiskRuleGenerationJobService: "source_ref": "自然语言风险规则", "generation_status": "completed", "generation_completed_at": datetime.now(UTC).isoformat(), + "last_operation": { + "action": "create", + "actor": actor, + "at": datetime.now(UTC).isoformat(), + }, } asset.code = rule_code @@ -296,6 +320,7 @@ class RiskRuleGenerationJobService: "risk_level": risk_level, "risk_score": risk_score["score"], "domain": domain, + "business_stage": business_stage, "expense_category": expense_category, "requires_attachment": requires_attachment, }, diff --git a/server/src/app/services/risk_rule_generation_ontology.py b/server/src/app/services/risk_rule_generation_ontology.py index 992cd41..09c8478 100644 --- a/server/src/app/services/risk_rule_generation_ontology.py +++ b/server/src/app/services/risk_rule_generation_ontology.py @@ -46,6 +46,11 @@ EXPENSE_RISK_CATEGORY_ALIASES = { "entertainment": "meal", } +EXPENSE_BUSINESS_STAGE_LABELS: dict[str, str] = { + "expense_application": "费用申请", + "reimbursement": "费用报销", +} + FIELD_ONTOLOGY: tuple[RiskRuleField, ...] = ( RiskRuleField("claim.reason", "报销事由", "text", "claim", ("事由", "说明", "理由", "用途")), RiskRuleField( diff --git a/server/src/app/services/risk_rule_generation_prompt.py b/server/src/app/services/risk_rule_generation_prompt.py index 37147e7..e181d18 100644 --- a/server/src/app/services/risk_rule_generation_prompt.py +++ b/server/src/app/services/risk_rule_generation_prompt.py @@ -8,6 +8,8 @@ def build_risk_rule_compiler_messages( *, domain: str, domain_label: str, + business_stage: str, + business_stage_label: str, expense_category: str | None, expense_category_label: str, natural_language: str, @@ -74,6 +76,9 @@ def build_risk_rule_compiler_messages( } guardrails = [ "只能输出 JSON 对象,不能输出 Markdown 或解释。", + "必须区分业务环节:费用申请是事前风控,费用报销是事后核验;不要把二者的字段和流程语义混用。", + "费用申请阶段更关注预算余额、申请金额、申请事由、预计行程、预计费用科目、是否超预算或缺少前置审批。", + "费用报销阶段更关注真实票据、报销明细、发生日期、附件识别结果和申请/行程/票据一致性。", "字段必须来自 available_fields,不能编造字段。", "多步骤规则要使用 composite_rule_v1:先抽取事实变量,再写 conditions 和 hit_logic,不要压扁成单个关键词判断。", "城市/地点/路线一致性必须用 field_compare_v1 或 semantic_type=travel_route_city_consistency。", @@ -88,6 +93,8 @@ def build_risk_rule_compiler_messages( "keyword_match_v1 只用于品名、摘要、票据全文中出现明确风险词的规则。", "不要直接指定 risk_level 或 risk_score;只输出 risk_scoring_evidence,后端会按固定评分模型计算 0-100 分和风险等级。", "评分证据必须围绕六个指标:业务影响、违规确定性、证据强度、例外/规避空间、处置强度、场景敏感度。", + "若规则语义是可修复的低风险提醒,例如资料要素缺失但归属清晰、仅提醒/提示/补齐且不退回不阻断,则 impact_level 和 control_action 应保持低强度。", + "只有涉及造假、重复报销、金额超标、城市/日期不一致、禁止提交、退回修改、阻断或审计复核时,才应给 high 或 critical 的评分证据。", ] examples = [ { @@ -114,6 +121,26 @@ def build_risk_rule_compiler_messages( "keywords": [], "exception_keywords": ["绕行", "跨城办事", "临时改签"], }, + }, + { + "user_rule": ( + "差旅报销时,票据已上传但发票号码或商品服务名称缺失,且报销事由、人员和部门" + "能够说明费用归属,则标记为低风险,仅提醒补齐票据要素。" + ), + "expected": { + "template_key": "field_required_v1", + "field_keys": ["attachment.invoice_no", "attachment.goods_name", "claim.reason"], + "condition_summary": "票据要素缺失但费用归属清晰时,仅提示补齐。", + "risk_scoring_evidence": { + "impact_level": "low", + "violation_certainty": "medium", + "evidence_strength": "medium", + "exception_dependence": "low", + "control_action": "remind", + "business_sensitivity": "medium", + "reason": "命中后只做补齐提醒,不阻断、不退回,也不涉及舞弊或金额越权。", + }, + }, } ] return [ @@ -133,11 +160,13 @@ def build_risk_rule_compiler_messages( "content": json.dumps( { "business_domain": domain, - "business_domain_label": domain_label, - "expense_category": expense_category, - "expense_category_label": expense_category_label, - "natural_language": natural_language, - "available_fields": available_fields, + "business_domain_label": domain_label, + "business_stage": business_stage, + "business_stage_label": business_stage_label, + "expense_category": expense_category, + "expense_category_label": expense_category_label, + "natural_language": natural_language, + "available_fields": available_fields, "required_json_shape": schema, "examples": examples, }, diff --git a/server/src/app/services/risk_rule_score_backfill.py b/server/src/app/services/risk_rule_score_backfill.py new file mode 100644 index 0000000..abfe87d --- /dev/null +++ b/server/src/app/services/risk_rule_score_backfill.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +from app.models.agent_asset import AgentAsset +from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager +from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY +from app.services.risk_rule_scoring import RISK_SCORE_MODEL_VERSION, calculate_risk_rule_score + + +def backfill_missing_risk_rule_score( + asset: AgentAsset, + *, + rule_library_manager: AgentAssetRuleLibraryManager | None = None, +) -> bool: + config_json = dict(asset.config_json or {}) + if str(config_json.get("detail_mode") or "").strip().lower() != "json_risk": + return False + if _has_current_score(config_json): + return False + + manager = rule_library_manager or AgentAssetRuleLibraryManager() + library = str(config_json.get("rule_library") or RISK_RULES_LIBRARY).strip() or RISK_RULES_LIBRARY + file_name = _resolve_rule_file_name(asset, config_json) + if not file_name: + return False + + manifest = manager.read_rule_library_json(library=library, file_name=file_name) + if _has_current_score(manifest) or _has_current_score(manifest.get("metadata")): + score = _read_existing_score(manifest) + else: + score = _calculate_score(asset, manifest, config_json) + _apply_score_to_manifest(manifest, score) + manager.write_rule_library_json(library=library, file_name=file_name, payload=manifest) + + _apply_score_to_config(config_json, manifest, score) + asset.config_json = config_json + return True + + +def _resolve_rule_file_name(asset: AgentAsset, config_json: dict[str, Any]) -> str: + rule_document = config_json.get("rule_document") + if isinstance(rule_document, dict): + file_name = str(rule_document.get("file_name") or "").strip() + if file_name: + return file_name + code = str(asset.code or "").strip() + return f"{code}.json" if code else "" + + +def _calculate_score( + asset: AgentAsset, + manifest: dict[str, Any], + config_json: dict[str, Any], +) -> dict[str, Any]: + metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {} + params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {} + fields = _read_fields(manifest) + field_keys = _read_field_keys(manifest, fields) + draft = { + "template_key": manifest.get("template_key") or params.get("template_key"), + "field_keys": field_keys, + "description": manifest.get("description") or asset.description, + "condition_summary": metadata.get("condition_summary") or params.get("condition_summary"), + "formula": params.get("formula"), + "message_template": params.get("message_template"), + "conditions": params.get("conditions") if isinstance(params.get("conditions"), list) else [], + "keywords": params.get("keywords") if isinstance(params.get("keywords"), list) else [], + "exception_keywords": params.get("exception_keywords") + if isinstance(params.get("exception_keywords"), list) + else [], + "flow": metadata.get("flow") if isinstance(metadata.get("flow"), dict) else {}, + } + if isinstance(params.get("rule_ir"), dict): + draft["rule_ir"] = params["rule_ir"] + + generation_request = ( + config_json.get("generation_request") + if isinstance(config_json.get("generation_request"), dict) + else {} + ) + natural_language = str( + metadata.get("natural_language") + or params.get("natural_language") + or generation_request.get("natural_language") + or manifest.get("description") + or asset.description + or "" + ) + expense_category = str( + metadata.get("expense_category") or config_json.get("expense_category") or "" + ).strip() or None + expense_category_label = str( + metadata.get("expense_category_label") + or config_json.get("expense_category_label") + or manifest.get("risk_category") + or "" + ).strip() + requires_attachment = bool( + manifest.get("requires_attachment") or config_json.get("requires_attachment") + ) + return calculate_risk_rule_score( + natural_language=natural_language, + draft=draft, + fields=fields, + expense_category=expense_category, + expense_category_label=expense_category_label, + requires_attachment=requires_attachment, + ) + + +def _read_fields(manifest: dict[str, Any]) -> list[Any]: + inputs = manifest.get("inputs") if isinstance(manifest.get("inputs"), dict) else {} + rows = inputs.get("fields") if isinstance(inputs.get("fields"), list) else [] + return [ + SimpleNamespace( + key=str(row.get("key") or "").strip(), + label=str(row.get("label") or "").strip(), + field_type=str(row.get("type") or "").strip(), + source=str(row.get("source") or "").strip(), + ) + for row in rows + if isinstance(row, dict) and str(row.get("key") or "").strip() + ] + + +def _read_field_keys(manifest: dict[str, Any], fields: list[Any]) -> list[str]: + params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {} + raw_keys = params.get("field_keys") or params.get("required_fields") + if isinstance(raw_keys, list): + keys = [str(item or "").strip() for item in raw_keys if str(item or "").strip()] + if keys: + return keys + return [str(getattr(field, "key", "") or "").strip() for field in fields] + + +def _apply_score_to_manifest(manifest: dict[str, Any], score: dict[str, Any]) -> None: + level = str(score.get("level") or "medium") + manifest["severity"] = level + manifest["risk_score"] = int(score.get("score") or 0) + manifest["risk_level"] = level + manifest["risk_level_label"] = str(score.get("level_label") or "") + manifest["risk_score_detail"] = score + + outcomes = manifest.setdefault("outcomes", {}) + if isinstance(outcomes, dict): + fail = outcomes.setdefault("fail", {}) + if isinstance(fail, dict): + fail["severity"] = level + fail["risk_score"] = int(score.get("score") or 0) + + metadata = manifest.setdefault("metadata", {}) + if isinstance(metadata, dict): + metadata["risk_score"] = int(score.get("score") or 0) + metadata["risk_level"] = level + metadata["risk_level_label"] = str(score.get("level_label") or "") + metadata["risk_score_model"] = score.get("model") + metadata["risk_score_detail"] = score + + +def _apply_score_to_config( + config_json: dict[str, Any], + manifest: dict[str, Any], + score: dict[str, Any], +) -> None: + level = str(score.get("level") or manifest.get("risk_level") or "medium") + config_json["severity"] = level + config_json["risk_score"] = int(score.get("score") or 0) + config_json["risk_level"] = level + config_json["risk_level_label"] = str(score.get("level_label") or "") + config_json["risk_score_detail"] = score + + +def _has_score(value: Any) -> bool: + if not isinstance(value, dict): + return False + try: + score = int(value.get("risk_score") if value.get("risk_score") is not None else value.get("score")) + except (TypeError, ValueError): + return False + return 0 <= score <= 100 + + +def _has_current_score(value: Any) -> bool: + if not _has_score(value): + return False + return _read_score_model(value) == RISK_SCORE_MODEL_VERSION + + +def _read_score_model(value: Any) -> str: + if not isinstance(value, dict): + return "" + detail = value.get("risk_score_detail") + if isinstance(detail, dict): + model = str(detail.get("model") or "").strip() + if model: + return model + metadata = value.get("metadata") + if isinstance(metadata, dict): + detail = metadata.get("risk_score_detail") + if isinstance(detail, dict): + model = str(detail.get("model") or "").strip() + if model: + return model + model = str(metadata.get("risk_score_model") or "").strip() + if model: + return model + return str(value.get("risk_score_model") or value.get("model") or "").strip() + + +def _read_existing_score(manifest: dict[str, Any]) -> dict[str, Any]: + metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {} + detail = metadata.get("risk_score_detail") + if isinstance(detail, dict) and _has_score(detail): + return dict(detail) + detail = manifest.get("risk_score_detail") + if isinstance(detail, dict) and _has_score(detail): + return dict(detail) + score = int(metadata.get("risk_score") or manifest.get("risk_score") or 0) + level = str(metadata.get("risk_level") or manifest.get("risk_level") or "medium") + return { + "score": score, + "level": level, + "level_label": str(metadata.get("risk_level_label") or manifest.get("risk_level_label") or ""), + "model": metadata.get("risk_score_model"), + } diff --git a/server/src/app/services/risk_rule_scoring.py b/server/src/app/services/risk_rule_scoring.py index 6f62868..bafcd25 100644 --- a/server/src/app/services/risk_rule_scoring.py +++ b/server/src/app/services/risk_rule_scoring.py @@ -11,7 +11,7 @@ RISK_LEVEL_LABELS: dict[str, str] = { "critical": "极高风险", } -RISK_SCORE_MODEL_VERSION = "risk_score_v1" +RISK_SCORE_MODEL_VERSION = "risk_score_v3" RISK_SCORE_WEIGHTS: dict[str, float] = { "impact": 0.35, @@ -115,6 +115,7 @@ def calculate_risk_rule_score( draft.get("formula"), draft.get("message_template"), ) + hard_signal_text = _strip_negated_risk_context(text) template_key = str(draft.get("template_key") or "").strip() field_keys = _read_string_list(draft.get("field_keys")) condition_count = len(draft.get("conditions") if isinstance(draft.get("conditions"), list) else []) @@ -122,7 +123,7 @@ def calculate_risk_rule_score( components = { "impact": _component_score( evidence.get("impact_level"), - _infer_impact_score(text, template_key=template_key), + _infer_impact_score(hard_signal_text, template_key=template_key), ), "certainty": _component_score( evidence.get("violation_certainty"), @@ -142,12 +143,18 @@ def calculate_risk_rule_score( ), "sensitivity": _component_score( evidence.get("business_sensitivity"), - _infer_sensitivity_score(text, expense_category=expense_category), + _infer_sensitivity_score(hard_signal_text, expense_category=expense_category), ), } - score = _clamp_score( + raw_score = _clamp_score( round(sum(components[key] * RISK_SCORE_WEIGHTS[key] for key in RISK_SCORE_WEIGHTS)) ) + score, calibration = _calibrate_score( + raw_score, + text=text, + hard_signal_text=hard_signal_text, + components=components, + ) level = risk_level_from_score(score) return { "score": score, @@ -156,6 +163,7 @@ def calculate_risk_rule_score( "model": RISK_SCORE_MODEL_VERSION, "weights": RISK_SCORE_WEIGHTS, "components": components, + "calibration": calibration, "ai_evidence": evidence, "basis": { "template_key": template_key, @@ -277,6 +285,8 @@ def _infer_action_score(text: str, draft: dict[str, Any]) -> int: return 78 if _contains_any(corpus, "人工复核", "复核", "审核"): return 65 + if _contains_any(corpus, "提醒", "提示", "补齐"): + return 35 if _contains_any(corpus, "补充", "说明"): return 48 return 35 @@ -292,6 +302,69 @@ def _infer_sensitivity_score(text: str, *, expense_category: str | None) -> int: return 45 +def _calibrate_score( + score: int, + *, + text: str, + hard_signal_text: str, + components: dict[str, int], +) -> tuple[int, dict[str, Any]]: + calibration: dict[str, Any] = {"raw_score": score, "rules": []} + if _is_low_control_rule(text, hard_signal_text, components): + calibrated = min(score, 30) + calibration["rules"].append( + { + "name": "explicit_low_control_cap", + "score_before": score, + "score_after": calibrated, + "reason": "规则语义明确为低风险,且控制动作仅为提醒、提示、补齐或补充说明。", + } + ) + score = calibrated + return score, calibration + + +def _is_low_control_rule(text: str, hard_signal_text: str, components: dict[str, int]) -> bool: + if not _contains_any(text, "低风险", "轻微风险", "轻微", "提醒", "提示", "补齐"): + return False + if _contains_any( + hard_signal_text, + "高风险", + "极高风险", + "严重", + "重大", + "造假", + "虚假", + "伪造", + "重复报销", + "骗取", + "套取", + "不一致", + "超预算", + "超标准", + "阻断", + "禁止", + "退回", + "驳回", + ): + return False + return components.get("action", 100) <= ACTION_SCORE_MAP["supplement"] + + +def _strip_negated_risk_context(text: str) -> str: + normalized = str(text or "") + if not normalized: + return "" + negated_risk_pattern = ( + r"(?:暂未|未|没有|无|不存在)" + r"(?:发现|存在)?" + r"[^,。;;,.]*" + r"(?:冲突|异常|重复报销|造假|虚假|伪造|超标|超预算|高风险|不一致|迹象)" + r"[^,。;;,.]*" + ) + return re.sub(negated_risk_pattern, "", normalized) + + def _replace_or_append_risk_label(value: str, level_label: str) -> str: normalized = str(value or "").strip() if not normalized: diff --git a/server/src/app/services/user_agent_constants.py b/server/src/app/services/user_agent_constants.py index ea9f9b7..3901609 100644 --- a/server/src/app/services/user_agent_constants.py +++ b/server/src/app/services/user_agent_constants.py @@ -38,8 +38,10 @@ EXPENSE_TYPE_LABELS = { "meal": "业务招待费", "meeting": "会务费", "entertainment": "业务招待费", + "marketing": "市场推广费", "office": "办公用品费", "training": "培训费", + "software": "软件服务费", "communication": "通讯费", "welfare": "福利费", "other": "其他费用", @@ -49,10 +51,12 @@ GROUP_SCENE_LABELS = { "travel": "差旅费", "entertainment": "业务招待费", "meal": "业务招待费", + "marketing": "市场推广费", "transport": "交通费", "hotel": "住宿费", "office": "办公用品费", "training": "培训费", + "software": "软件服务费", "communication": "通讯费", "welfare": "福利费", "other": "其他费用", @@ -64,8 +68,10 @@ EXPENSE_SCENE_SELECTION_OPTIONS = ( ("hotel", "住宿费", "单独住宿、酒店发票等场景。"), ("meal", "业务招待费", "客户接待、工作餐、加班餐、餐饮票据等场景。"), ("meeting", "会务费", "会议、论坛、会场、参会等场景。"), + ("marketing", "市场推广费", "广告投放、品牌宣传、营销物料等推广场景。"), ("office", "办公用品费", "办公用品、耗材、办公设备等采购场景。"), ("training", "培训费", "培训课程、讲师费、教材、认证等场景。"), + ("software", "软件服务费", "软件订阅、云资源、平台服务等技术服务场景。"), ("communication", "通讯费", "话费、流量、宽带、网络等场景。"), ("welfare", "福利费", "团建、体检、慰问、节日福利等场景。"), ("other", "其他费用", "暂不属于以上分类的报销场景。"), @@ -110,7 +116,10 @@ AMOUNT_TEXT_PATTERN = re.compile( r"(\d+(?:\.\d+)?)\s*(?:万元|万员|万圆|万园|万块|万元整|元整|块钱|块|元|员|圆|园|万)" ) TRAVEL_REVIEW_HOTEL_NIGHT_PATTERN = re.compile(r"(\d+)\s*(?:晚|间夜)") -TRAVEL_ROUTE_PATTERN = re.compile(r"([\u4e00-\u9fa5]{2,12})\s*(?:至|→|->|-|—)\s*([\u4e00-\u9fa5]{2,12})") +TRAVEL_ROUTE_PATTERN = re.compile( + r"([\u4e00-\u9fa5]{2,12})\s*(?:至|→|->|-|—)\s*" + r"([\u4e00-\u9fa5]{2,12})" +) SOURCE_LABELS = { "user_text": "用户描述", @@ -137,8 +146,10 @@ INFERRED_REASON_LABELS = { "meal": "业务招待", "meeting": "会务活动", "entertainment": "客户接待", + "marketing": "市场推广", "office": "办公用品采购", "training": "培训学习", + "software": "软件服务", "communication": "通讯使用", "welfare": "员工福利", "other": "其他费用", diff --git a/server/storage/knowledge/.index.json b/server/storage/knowledge/.index.json index a17b24e..bc1e54a 100644 --- a/server/storage/knowledge/.index.json +++ b/server/storage/knowledge/.index.json @@ -14,8 +14,8 @@ "updated_at": "2026-05-17T09:28:28.999515+00:00", "uploaded_by": "admin", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-17T10:01:33.272539+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:00.726523+00:00", "ingest_completed_at": "2026-05-17T10:01:33.272539+00:00", "ingest_document_name": "远光《公司支出管理办法(2024)》.pdf", "ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00", @@ -35,8 +35,8 @@ "updated_at": "2026-05-22T07:00:22.328877+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T09:22:25.565409+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:00.731130+00:00", "ingest_completed_at": "2026-05-22T09:22:25.565409+00:00", "ingest_document_name": "远光软件会计科目使用说明.xlsx", "ingest_document_updated_at": "2026-05-22T07:00:22.328877+00:00", @@ -56,8 +56,8 @@ "updated_at": "2026-05-22T07:00:22.011016+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-23T14:30:33.605531+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:00.735501+00:00", "ingest_completed_at": "2026-05-23T14:30:33.605531+00:00", "ingest_document_name": "远光软件财务基础知识手册.docx", "ingest_document_updated_at": "2026-05-22T07:00:22.011016+00:00", @@ -77,8 +77,8 @@ "updated_at": "2026-05-22T07:00:22.352133+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T09:23:11.334499+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:00.739842+00:00", "ingest_completed_at": "2026-05-22T09:23:11.334499+00:00", "ingest_document_name": "远光软件财务术语解释手册.docx", "ingest_document_updated_at": "2026-05-22T07:00:22.352133+00:00", @@ -98,8 +98,8 @@ "updated_at": "2026-05-22T07:00:22.304623+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T09:24:18.933073+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:00.744555+00:00", "ingest_completed_at": "2026-05-22T09:24:18.933073+00:00", "ingest_document_name": "远光软件高新技术企业税收优惠政策汇总.pdf", "ingest_document_updated_at": "2026-05-22T07:00:22.304623+00:00", @@ -119,8 +119,8 @@ "updated_at": "2026-05-22T07:00:18.153373+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:01:43.168774+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:00.762391+00:00", "ingest_completed_at": "2026-05-22T16:01:43.168774+00:00", "ingest_document_name": "远光软件公司内部控制基本规范.pdf", "ingest_document_updated_at": "2026-05-22T07:00:18.153373+00:00", @@ -140,8 +140,8 @@ "updated_at": "2026-05-22T07:00:18.190399+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:03:00.735908+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:00.773116+00:00", "ingest_completed_at": "2026-05-22T16:03:00.735908+00:00", "ingest_document_name": "远光软件公司合同管理制度.docx", "ingest_document_updated_at": "2026-05-22T07:00:18.190399+00:00", @@ -161,8 +161,8 @@ "updated_at": "2026-05-22T07:00:17.798679+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:03:46.921675+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:00.784020+00:00", "ingest_completed_at": "2026-05-22T16:03:46.921675+00:00", "ingest_document_name": "远光软件公司财务管理制度总则.docx", "ingest_document_updated_at": "2026-05-22T07:00:17.798679+00:00", @@ -182,8 +182,8 @@ "updated_at": "2026-05-22T07:00:18.531598+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:04:58.719410+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:00.799323+00:00", "ingest_completed_at": "2026-05-22T16:04:58.719410+00:00", "ingest_document_name": "远光软件公司资产管理制度.pdf", "ingest_document_updated_at": "2026-05-22T07:00:18.531598+00:00", @@ -203,8 +203,8 @@ "updated_at": "2026-05-22T07:00:18.221073+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:06:08.172318+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:00.814611+00:00", "ingest_completed_at": "2026-05-22T16:06:08.172318+00:00", "ingest_document_name": "远光软件公司采购管理办法.xlsx", "ingest_document_updated_at": "2026-05-22T07:00:18.221073+00:00", @@ -224,8 +224,8 @@ "updated_at": "2026-05-22T07:00:19.734422+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:06:48.466110+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:00.830249+00:00", "ingest_completed_at": "2026-05-22T16:06:48.466110+00:00", "ingest_document_name": "远光软件公司差旅费管理办法.docx", "ingest_document_updated_at": "2026-05-22T07:00:19.734422+00:00", @@ -245,8 +245,8 @@ "updated_at": "2026-05-22T07:00:20.095824+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:07:23.262328+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:00.847094+00:00", "ingest_completed_at": "2026-05-22T16:07:23.262328+00:00", "ingest_document_name": "远光软件出差审批流程说明.pdf", "ingest_document_updated_at": "2026-05-22T07:00:20.095824+00:00", @@ -266,8 +266,8 @@ "updated_at": "2026-05-22T07:00:20.128471+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:08:02.190081+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:00.865452+00:00", "ingest_completed_at": "2026-05-22T16:08:02.190081+00:00", "ingest_document_name": "远光软件国际出差管理规定.docx", "ingest_document_updated_at": "2026-05-22T07:00:20.128471+00:00", @@ -287,8 +287,8 @@ "updated_at": "2026-05-22T07:00:19.759954+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:09:23.091744+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:00.888420+00:00", "ingest_completed_at": "2026-05-22T16:09:23.091744+00:00", "ingest_document_name": "远光软件差旅费标准速查表.xlsx", "ingest_document_updated_at": "2026-05-22T07:00:19.759954+00:00", @@ -308,8 +308,8 @@ "updated_at": "2026-05-22T07:00:18.922298+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:11:04.764727+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:00.905615+00:00", "ingest_completed_at": "2026-05-22T16:11:04.764727+00:00", "ingest_document_name": "远光软件公司发票审核标准.xlsx", "ingest_document_updated_at": "2026-05-22T07:00:18.922298+00:00", @@ -329,8 +329,8 @@ "updated_at": "2026-05-22T07:00:18.560177+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:11:54.017817+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:00.919568+00:00", "ingest_completed_at": "2026-05-22T16:11:54.017817+00:00", "ingest_document_name": "远光软件公司发票管理规范.docx", "ingest_document_updated_at": "2026-05-22T07:00:18.560177+00:00", @@ -350,8 +350,8 @@ "updated_at": "2026-05-22T07:00:18.888128+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:12:23.821434+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:00.934348+00:00", "ingest_completed_at": "2026-05-22T16:12:23.821434+00:00", "ingest_document_name": "远光软件公司增值税发票操作指南.pdf", "ingest_document_updated_at": "2026-05-22T07:00:18.888128+00:00", @@ -371,8 +371,8 @@ "updated_at": "2026-05-22T07:00:18.953110+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:13:15.450300+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:00.949214+00:00", "ingest_completed_at": "2026-05-22T16:13:15.450300+00:00", "ingest_document_name": "远光软件公司电子发票管理办法.docx", "ingest_document_updated_at": "2026-05-22T07:00:18.953110+00:00", @@ -392,8 +392,8 @@ "updated_at": "2026-05-22T07:00:21.585718+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:13:44.636629+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:00.963406+00:00", "ingest_completed_at": "2026-05-22T16:13:44.636629+00:00", "ingest_document_name": "远光软件企业所得税汇算清缴操作手册.pdf", "ingest_document_updated_at": "2026-05-22T07:00:21.585718+00:00", @@ -413,8 +413,8 @@ "updated_at": "2026-05-22T07:00:20.881351+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:14:50.092490+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:00.976986+00:00", "ingest_completed_at": "2026-05-22T16:14:50.092490+00:00", "ingest_document_name": "远光软件公司税务管理制度.docx", "ingest_document_updated_at": "2026-05-22T07:00:20.881351+00:00", @@ -434,8 +434,8 @@ "updated_at": "2026-05-22T07:00:21.606227+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:15:56.676286+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:00.995972+00:00", "ingest_completed_at": "2026-05-22T16:15:56.676286+00:00", "ingest_document_name": "远光软件研发费用加计扣除管理办法.xlsx", "ingest_document_updated_at": "2026-05-22T07:00:21.606227+00:00", @@ -455,8 +455,8 @@ "updated_at": "2026-05-22T07:00:21.202633+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:16:06.540773+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:01.010947+00:00", "ingest_completed_at": "2026-05-22T16:16:06.540773+00:00", "ingest_document_name": "远光软件软件产品增值税即征即退操作指南.pdf", "ingest_document_updated_at": "2026-05-22T07:00:21.202633+00:00", @@ -476,8 +476,8 @@ "updated_at": "2026-05-22T07:00:22.379307+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:23:24.252614+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:01.025910+00:00", "ingest_completed_at": "2026-05-22T16:23:24.252614+00:00", "ingest_document_name": "远光软件公司预算管理制度.docx", "ingest_document_updated_at": "2026-05-22T07:00:22.379307+00:00", @@ -497,8 +497,8 @@ "updated_at": "2026-05-22T07:00:22.760169+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:23:29.997956+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:01.044022+00:00", "ingest_completed_at": "2026-05-22T16:23:29.997956+00:00", "ingest_document_name": "远光软件年度预算编制指南.pdf", "ingest_document_updated_at": "2026-05-22T07:00:22.760169+00:00", @@ -518,8 +518,8 @@ "updated_at": "2026-05-22T07:00:22.848272+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:24:37.382612+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:17.402454+00:00", "ingest_completed_at": "2026-05-22T16:24:37.382612+00:00", "ingest_document_name": "远光软件预算执行分析报告模板.docx", "ingest_document_updated_at": "2026-05-22T07:00:22.848272+00:00", @@ -539,8 +539,8 @@ "updated_at": "2026-05-22T07:00:22.803708+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:24:45.161319+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:17.417444+00:00", "ingest_completed_at": "2026-05-22T16:24:45.161319+00:00", "ingest_document_name": "远光软件预算编制模板.xlsx", "ingest_document_updated_at": "2026-05-22T07:00:22.803708+00:00", @@ -560,8 +560,8 @@ "updated_at": "2026-05-22T07:00:21.971983+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:25:33.968414+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:17.433923+00:00", "ingest_completed_at": "2026-05-22T16:25:33.968414+00:00", "ingest_document_name": "远光软件财务共享服务SLA标准.xlsx", "ingest_document_updated_at": "2026-05-22T07:00:21.971983+00:00", @@ -581,8 +581,8 @@ "updated_at": "2026-05-22T07:00:21.634300+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:26:05.301987+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:17.450037+00:00", "ingest_completed_at": "2026-05-22T16:26:05.301987+00:00", "ingest_document_name": "远光软件财务共享服务中心运营管理办法.docx", "ingest_document_updated_at": "2026-05-22T07:00:21.634300+00:00", @@ -602,8 +602,8 @@ "updated_at": "2026-05-22T07:00:21.945868+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:26:54.048075+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:17.471635+00:00", "ingest_completed_at": "2026-05-22T16:26:54.048075+00:00", "ingest_document_name": "远光软件财务共享服务操作手册.pdf", "ingest_document_updated_at": "2026-05-22T07:00:21.945868+00:00", @@ -623,8 +623,8 @@ "updated_at": "2026-05-22T07:00:19.662743+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:27:31.775974+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:17.489793+00:00", "ingest_completed_at": "2026-05-22T16:27:31.775974+00:00", "ingest_document_name": "远光软件报销流程培训手册.pdf", "ingest_document_updated_at": "2026-05-22T07:00:19.662743+00:00", @@ -644,8 +644,8 @@ "updated_at": "2026-05-22T07:00:19.323921+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:27:44.244066+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:17.505506+00:00", "ingest_completed_at": "2026-05-22T16:27:44.244066+00:00", "ingest_document_name": "远光软件新员工财务培训课件.pdf", "ingest_document_updated_at": "2026-05-22T07:00:19.323921+00:00", @@ -665,8 +665,8 @@ "updated_at": "2026-05-22T07:00:18.988700+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:28:24.573683+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:17.520887+00:00", "ingest_completed_at": "2026-05-22T16:28:24.573683+00:00", "ingest_document_name": "远光软件财务制度培训手册.docx", "ingest_document_updated_at": "2026-05-22T07:00:18.988700+00:00", @@ -686,8 +686,8 @@ "updated_at": "2026-05-22T07:00:19.686485+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:29:03.349502+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:17.542919+00:00", "ingest_completed_at": "2026-05-22T16:29:03.349502+00:00", "ingest_document_name": "远光软件财务培训课程安排.xlsx", "ingest_document_updated_at": "2026-05-22T07:00:19.686485+00:00", @@ -707,8 +707,8 @@ "updated_at": "2026-05-22T07:00:20.476077+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:29:29.050791+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:17.558881+00:00", "ingest_completed_at": "2026-05-22T16:29:29.050791+00:00", "ingest_document_name": "远光软件报销问题处理指引.xlsx", "ingest_document_updated_at": "2026-05-22T07:00:20.476077+00:00", @@ -728,8 +728,8 @@ "updated_at": "2026-05-22T07:00:20.453567+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:35:03.548506+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:17.575410+00:00", "ingest_completed_at": "2026-05-22T16:35:03.548506+00:00", "ingest_document_name": "远光软件财务制度问答汇总.pdf", "ingest_document_updated_at": "2026-05-22T07:00:20.453567+00:00", @@ -749,8 +749,8 @@ "updated_at": "2026-05-22T07:00:20.158497+00:00", "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-22T16:35:27.056080+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-26T02:39:17.593165+00:00", "ingest_completed_at": "2026-05-22T16:35:27.056080+00:00", "ingest_document_name": "远光软件财务报销常见问题解答.docx", "ingest_document_updated_at": "2026-05-22T07:00:20.158497+00:00", diff --git a/server/tests/test_ontology_service.py b/server/tests/test_ontology_service.py index 66f5e64..57c00d7 100644 --- a/server/tests/test_ontology_service.py +++ b/server/tests/test_ontology_service.py @@ -3,16 +3,15 @@ from __future__ import annotations from collections.abc import Generator import pytest -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.schemas.ontology import OntologyParseRequest -from app.services.ontology import LlmOntologyParseResult, SemanticOntologyService +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.schemas.ontology import OntologyParseRequest +from app.services.ontology import LlmOntologyParseResult, SemanticOntologyService def build_session_factory() -> sessionmaker[Session]: @@ -25,9 +24,11 @@ def build_session_factory() -> sessionmaker[Session]: return sessionmaker(bind=engine, autoflush=False, autocommit=False) -def build_client() -> tuple[TestClient, sessionmaker[Session]]: - session_factory = build_session_factory() - app = create_app() +def build_client() -> tuple[TestClient, sessionmaker[Session]]: + session_factory = build_session_factory() + from app.main import create_app + + app = create_app() def override_db() -> Generator[Session, None, None]: db = session_factory() @@ -253,13 +254,113 @@ def test_semantic_ontology_service_extracts_entities_time_and_constraints() -> N user_id="pytest", ) ) - - assert result.scenario == "expense" + + assert result.scenario == "expense" assert result.intent == "query" assert result.time_range.start_date == "2026-04-01" assert result.time_range.end_date == "2026-04-30" +def test_semantic_ontology_service_extracts_budget_query_fields() -> None: + session_factory = build_session_factory() + with session_factory() as db: + result = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="查询 CC-4100 2026年度差旅费可用预算和预算占用", + user_id="pytest", + ) + ) + + entity_map = {item.type: item.normalized_value for item in result.entities} + metric_names = {item.name for item in result.metrics} + + assert result.scenario == "budget" + assert result.intent == "query" + assert entity_map["cost_center"] == "CC-4100" + assert entity_map["budget_period"] == "2026年度" + assert entity_map["budget_subject"] == "travel" + assert entity_map["expense_type"] == "travel" + assert {"available_amount", "reserved_amount"}.issubset(metric_names) + + +def test_semantic_ontology_service_extracts_budget_edit_fields() -> None: + session_factory = build_session_factory() + with session_factory() as db: + result = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="编辑预算:2026年度 CC-4100 差旅费预算金额60万元,预警线80%,控制动作提醒", + user_id="pytest", + context_json={ + "document_type": "budget_plan", + "entry_source": "budget_center", + "conversation_scenario": "budget", + }, + ) + ) + + entity_map = {item.type: item.normalized_value for item in result.entities} + + assert result.scenario == "budget" + assert result.intent == "draft" + assert result.permission.level == "draft_write" + assert entity_map["budget_period"] == "2026年度" + assert entity_map["budget_subject"] == "travel" + assert entity_map["expense_type"] == "travel" + assert entity_map["budget_amount"] == "600000" + assert entity_map["warning_threshold"] == "80%" + assert entity_map["control_action"] == "remind" + + +def test_semantic_ontology_service_extracts_quarter_budget_period() -> None: + session_factory = build_session_factory() + with session_factory() as db: + result = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="查询 CC-4100 2026年Q3 住宿费预算金额", + user_id="pytest", + ) + ) + + entity_map = {item.type: item.normalized_value for item in result.entities} + + assert result.scenario == "budget" + assert entity_map["budget_period"] == "2026年Q3" + assert entity_map["budget_subject"] == "hotel" + assert entity_map["expense_type"] == "hotel" + + +@pytest.mark.parametrize( + "query,expected_code,expected_label", + [ + ("查询2026年度市场推广费预算余额", "marketing", "市场推广费"), + ("查看2026年度软件服务费已占用金额", "software", "软件服务费"), + ("统计2026年度业务招待费预算金额", "meal", "业务招待费"), + ], +) +def test_semantic_ontology_service_links_budget_subject_to_expense_type( + query: str, + expected_code: str, + expected_label: str, +) -> None: + session_factory = build_session_factory() + with session_factory() as db: + result = SemanticOntologyService(db).parse( + OntologyParseRequest(query=query, user_id="pytest") + ) + + assert result.scenario == "budget" + assert any( + item.type == "budget_subject" and item.normalized_value == expected_code + for item in result.entities + ) + assert any( + item.type == "expense_type" + and item.normalized_value == expected_code + and item.value == expected_label + for item in result.entities + ) + + def test_semantic_ontology_service_extracts_new_document_numbers() -> None: session_factory = build_session_factory() with session_factory() as db: diff --git a/server/tests/test_risk_rule_generation.py b/server/tests/test_risk_rule_generation.py index b340090..8cef6ec 100644 --- a/server/tests/test_risk_rule_generation.py +++ b/server/tests/test_risk_rule_generation.py @@ -3,7 +3,9 @@ from __future__ import annotations import json from datetime import UTC, date, datetime from decimal import Decimal +from types import SimpleNamespace +import pytest from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool @@ -32,6 +34,7 @@ from app.services.risk_rule_flow_diagram import ( from app.services.risk_rule_generation import RiskRuleGenerationService from app.services.risk_rule_generation_jobs import RiskRuleGenerationJobService from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest +from app.services.risk_rule_scoring import calculate_risk_rule_score, risk_level_from_score from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor @@ -113,6 +116,8 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None: assert asset.config_json["evaluator"] == "template_rule" assert asset.config_json["expense_category"] == "travel" assert asset.config_json["risk_category"] == "差旅费" + assert asset.config_json["business_stage"] == "reimbursement" + assert asset.config_json["business_stage_label"] == "费用报销" assert asset.scenario_json == ["差旅费"] assert asset.current_version == "v0.1.0" @@ -122,9 +127,15 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None: assert payload["rule_code"] == asset.code assert payload["name"] == "差旅住宿城市一致性校验" assert payload["applies_to"]["expense_categories"] == ["travel"] + assert payload["applies_to"]["business_stages"] == ["reimbursement"] assert payload["risk_category"] == "差旅费" assert payload["metadata"]["expense_category"] == "travel" + assert payload["metadata"]["business_stage"] == "reimbursement" + assert payload["metadata"]["business_stage_label"] == "费用报销" assert payload["metadata"]["rule_title"] == "差旅住宿城市一致性校验" + assert isinstance(payload["metadata"]["risk_score"], int) + assert payload["metadata"]["risk_level"] == payload["outcomes"]["fail"]["severity"] + assert asset.config_json["risk_score"] == payload["metadata"]["risk_score"] assert payload["outcomes"]["fail"]["severity"] == "high" assert payload["template_key"] == "field_compare_v1" assert payload["metadata"]["natural_language"].startswith("住宿城市") @@ -147,7 +158,141 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None: assert "feDropShadow" not in payload["flow_diagram_svg"] -def test_set_risk_rule_level_updates_manifest_config_and_flow_svg(tmp_path) -> None: +def test_risk_score_model_thresholds_and_critical_level() -> None: + assert risk_level_from_score(30) == "low" + assert risk_level_from_score(31) == "medium" + assert risk_level_from_score(61) == "high" + assert risk_level_from_score(81) == "critical" + + result = calculate_risk_rule_score( + natural_language="同一发票号码重复报销时禁止提交并进入审计复核。", + draft={ + "template_key": "composite_rule_v1", + "field_keys": ["attachment.invoice_no", "claim.amount"], + "conditions": [{"id": "duplicate_invoice", "operator": "overlap"}], + "risk_scoring_evidence": { + "impact_level": "critical", + "violation_certainty": "critical", + "evidence_strength": "high", + "exception_dependence": "medium", + "control_action": "block", + "business_sensitivity": "critical", + }, + }, + fields=[], + expense_category="travel", + expense_category_label="差旅费", + requires_attachment=True, + ) + + assert result["score"] >= 81 + assert result["level"] == "critical" + assert result["level_label"] == "极高风险" + + +def test_generate_expense_application_risk_rule_marks_business_stage(tmp_path) -> None: + with build_session() as db: + manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules") + service = RiskRuleGenerationService( + db, + rule_library_manager=manager, + runtime_chat_service=NullRuntimeChatService(), + ) + + asset_id = service.generate_rule_asset( + AgentAssetRiskRuleGenerateRequest( + business_domain=AgentAssetDomain.EXPENSE, + business_stage="expense_application", + expense_category="travel", + rule_title="差旅申请预算余额校验", + natural_language="费用申请时,若差旅申请金额超过可用预算余额,则提示风险并要求补充审批说明。", + ), + actor="pytest", + ) + + asset = db.get(AgentAsset, asset_id) + assert asset is not None + assert asset.config_json["business_stage"] == "expense_application" + assert asset.config_json["business_stage_label"] == "费用申请" + + payload = manager.read_rule_library_json( + library=RISK_RULES_LIBRARY, + file_name=asset.config_json["rule_document"]["file_name"], + ) + assert payload["applies_to"]["business_stages"] == ["expense_application"] + assert payload["metadata"]["business_stage_label"] == "费用申请" + assert payload["params"]["business_stage_label"] == "费用申请" + + +def test_risk_score_model_keeps_explicit_low_control_rules_low() -> None: + field_keys = ["attachment.invoice_no", "attachment.goods_name", "claim.reason"] + result = calculate_risk_rule_score( + natural_language=( + "差旅报销时,票据已上传但发票号码或商品服务名称缺失," + "且报销事由、人员和部门能够说明费用归属,则标记为低风险," + "仅提醒补齐票据要素。" + ), + draft={ + "template_key": "field_required_v1", + "field_keys": field_keys, + "condition_summary": "票据要素缺失但归属清晰时提醒补齐。", + }, + fields=[SimpleNamespace(key=key, source=key.split(".", 1)[0]) for key in field_keys], + expense_category="travel", + expense_category_label="差旅费", + requires_attachment=True, + ) + + assert result["score"] <= 30 + assert result["level"] == "low" + assert result["calibration"]["rules"][0]["name"] == "explicit_low_control_cap" + + +def test_risk_score_model_ignores_negated_hard_risk_words_for_low_rules() -> None: + result = calculate_risk_rule_score( + natural_language=( + "差旅费报销提交时,若缺少申报目的地、明细地点或明细事由," + "但暂未发现票据城市冲突、金额异常或重复报销迹象,则标记为低风险," + "提示经办人补齐基础差旅信息后继续提交。" + ), + draft={ + "template_key": "field_required_v1", + "field_keys": ["claim.location", "item.item_location", "item.item_reason"], + "condition_summary": "基础差旅字段缺失但暂无硬风险迹象时提示补齐。", + }, + fields=[], + expense_category="travel", + expense_category_label="差旅费", + requires_attachment=False, + ) + + assert result["score"] <= 30 + assert result["level"] == "low" + + +def test_risk_score_model_does_not_cap_hard_risk_signals() -> None: + result = calculate_risk_rule_score( + natural_language=( + "差旅报销时,交通票或住宿票据城市均无法与申报目的地一致," + "且没有绕行、跨城办事或改签说明,则标记为高风险,要求补充说明或退回修改。" + ), + draft={ + "template_key": "composite_rule_v1", + "field_keys": ["claim.destination_city", "attachment.route_cities"], + "conditions": [{"id": "city_mismatch", "operator": "not_overlap"}], + }, + fields=[], + expense_category="travel", + expense_category_label="差旅费", + requires_attachment=True, + ) + + assert result["score"] >= 61 + assert result["level"] == "high" + assert not result["calibration"]["rules"] + + +def test_set_risk_rule_level_rejects_manual_override(tmp_path) -> None: with build_session() as db: manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules") generator = RiskRuleGenerationService( @@ -169,31 +314,16 @@ def test_set_risk_rule_level_updates_manifest_config_and_flow_svg(tmp_path) -> N asset_service = AgentAssetService(db) asset_service.rule_library_manager = manager - updated = asset_service.set_risk_rule_level( - asset_id, - risk_level="low", - actor="pytest", - ) + with pytest.raises(ValueError, match="评分模型"): + asset_service.set_risk_rule_level( + asset_id, + risk_level="low", + actor="pytest", + ) - assert updated.config_json["severity"] == "low" asset = db.get(AgentAsset, asset_id) assert asset is not None - assert asset.config_json["risk_level_label"] == "低风险" - file_name = asset.config_json["rule_document"]["file_name"] - payload = manager.read_rule_library_json( - library=RISK_RULES_LIBRARY, - file_name=file_name, - ) - assert payload["outcomes"]["fail"]["severity"] == "low" - assert payload["metadata"]["risk_level"] == "low" - assert payload["metadata"]["risk_level_label"] == "低风险" - assert "低风险" in payload["metadata"]["flow"]["fail"] - assert "#2563eb" in payload["flow_diagram_svg"] - assert "#dc2626" not in payload["flow_diagram_svg"] - - version = asset_service.repository.get_version(asset_id, asset.working_version) - assert version is not None - assert '"severity": "low"' in version.content + assert asset.config_json["severity"] != "low" def test_enqueue_risk_rule_generation_creates_visible_generating_asset(tmp_path) -> None: @@ -774,7 +904,10 @@ def test_risk_rule_requires_test_report_before_review_and_publish(tmp_path) -> N enabled=False, actor="manager", ) + assert disabled.status == AgentAssetStatus.DISABLED.value + assert disabled.published_version == asset.working_version assert disabled.config_json["enabled"] is False + assert disabled.config_json["last_operation"]["action"] == "offline" rule_document = disabled.config_json["rule_document"] manifest = manager.read_rule_library_json( library=RISK_RULES_LIBRARY, @@ -782,6 +915,11 @@ def test_risk_rule_requires_test_report_before_review_and_publish(tmp_path) -> N ) assert manifest["enabled"] is False + enabled = service.set_risk_rule_enabled(asset_id, enabled=True, actor="manager") + assert enabled.status == AgentAssetStatus.ACTIVE.value + assert enabled.config_json["enabled"] is True + assert enabled.config_json["last_operation"]["action"] == "online" + attachment_required_id = generator.generate_rule_asset( AgentAssetRiskRuleGenerateRequest( business_domain=AgentAssetDomain.EXPENSE, diff --git a/web/UI/编辑预算.jpg b/web/UI/编辑预算.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fec1ec19b9b8114948bd05b9a3cbff40b45b7c2f GIT binary patch literal 127007 zcmbTdbwE^I_dj}u1_{9dX;6^v?ht7ahL9AI9*`~pDHWBWq)T9kp+QngN*bhwlvI%t z0f8YT?-_lc=lkC8{rz$8MbDXi&e>=0y<+dR_GhhgIdl01xT>Y5sRlqG000610he=t z3P6B|kB^T>0GF1~z6|`Wy7r)Xdy0H`s4+ za&gi!@e1&A2(WQ*a$p;Q5D*d)6A@F9kWg{ZQPXk!fBw5{2Plc~6!431AvXaWN(e3` z1*}ka6~KYu;^N@pf+XO9wS&Ot03Ibi)eT`~ zf@`oxgf~5)B4KekL~OUKI;i!>ezA*Mdp;v3p}9^=M}LchlZ%^2Ok6@zN?Jzcj;flv zhNhN*q0xO~6H_xATRVFPN4S%hw~w#i6aRqcFT!8GdL4m?Pe@EkPDxEm&&|t66%-Z~ zmsHo(*1fN9Xl(k}+11_C+t)vU9-o+;nx6SQ`}N!M_m$PP^^G69d;6G!!=vNhCs?{5 z0PbJ1!1G_i{uf=8AYC|kc({0kSh^rMe&B&iiHCnfn1D(dM)=6%+D(x#BIxb7oT?6D zHc|aw)YhJ3BsA<|UvKSVN&7?D|2x8-{r{ruU&8)}u33N-7Xk_oml99}8daeNR|mLq zLs?YmptgEAV`{2&1voHOIwS#16$+ei4bdU-bEC%60WEG}RUuqWZA}EA^-zne8<1D{ zg34FkBk|=?;>tw;F3{RqV3ykviJzB#_jD{f0MG)PWm1LeLBIpo5v<|^P0J0Xi5gP{ z_&L9-ISv6(m@2?Hmd=H=UQH33H3LM|9CJfSu{ZE?V;qrqdFj%5*=B$=_?4ILlC-fbP2^GdDNx_ z_${k1>-=tOJAv(ogkX^Py)Cpyd1U9806I$Y=n~KZ9`?lcWMG?7V*p#PVelLwt=0o%5^1OhQgLhC34_Ykn(=jS`plE#+JdkDj+gj2UGbWBRa=W%&&2^QMc3GIx=!@@GI+I%#6QzviO8Q>S`>TiwnGnC zWE-gI7U$1~w5W}r@2n_Y={<^O40(cfaof2BcqHmU5|RK#1xy_?ML9p9fZ+P8axi69 zZfhmnsfGu-T_@p_m%!)RkT~tfu>eg-@Du7xt*U}LnaX3c)Rz-7Op^)?R|Ky)On0~(mjA^At8Zp#8Bj3&+EqS}(@t${F zRV-f3opdd1+^w8ZX?no!L9DPt2(UP<*U8E&8h=1&ex)&%7|NJQ{Q~Wf4cit`_*Jwj z7co(4+Nxgo_=g^k->LHF@VJ@!etVHzP2S7M z=k;>J-9}Saoia7iR;$L@mg9<>wsr4c_p~{@PnzH>8Q1V&!Lx~xq_9k!b6i9@kiW{| zwdm730_yC(|UUrcWfcV&`D1MmiW3)=#&QzWm_^ zQLJA5O;+tYW8XLzVglNbm~PFd!lj#Bvst!|V((`==+ zh$z}F_b)yM>*_Z5DrAoVug#wKs(wcX9QLhE<$()@x(t0$+fs6!#6$7@{y$w_`LE`>qq*XS7{ zBPxbx($qYt-Ye{47%>&WZcI)o63Nak5^)ax{x{<|XSMmb2sGYM_5}Eq>qf$5XW)WV ziv2~d^T@ACmYVXdbpz^rFS}jMex+(&0_KNlw>@@hppd0wLG#Ij4!&ucS@+r)waB2_ z;ZGLcUv_H`hHQUiaqhBTKQf*Quod07;>~w6#(wX+$Qu6cG+iI2G@DreNm(5n6{GVm zq|w$+>WvHIGo`+xGC^wiRe09Tn)kEy@lG7(#T@Z7BLaSmT(B8Eg77uOQw;%Ehnm{T zJ7}UokO?+N_@+{~kIJO$hO53eF!dI%t-UMu0Dd@}d4!+~ROS3+8 zqC0zQAGj(Jvr9m?9cY~AID2iwOnm5N=F?qmxdIcEFpd=;q(?E5ag>0xRcw0XC2ES{ z-Z-mnE&m7GPec3u8Ro26gmlABsHLb6%8z~C>U`Zu(f*tqqo(M%a0$p=xMff$p*H-E zvhW)bE|yD!^kKr?ey^9+P04@Omj)Qw)dgw8cExj;Y3lvW#P>f8m0rh9rPx10QrN$b z%dZcg2S%tGagpq}`m4ENzUJ+Sfv?s^^M$Nm)oJm{QTb~G2jQcFleMLux7~x3j91pBOIA)e*k9xSjWEwgbHXGZ-I6Z zmM#ifJNu|nh^DqGaElQ%&x~V3U{^!fB8Nhv%?5ZM{0%m&1(!k1bTfck8kHMLqGhIq zHO-*0UX5|={oPHgPz$yO8h20%x6a@bMBLGQn$1tcd~JLKZSC_b0=^3SdvPYo@O)~p z{wzHkK%uJzEXJ7FJs2B^YVm4D9*EO5*O6b_qO^_rwT!ae$Vjv(94@Q6>Cw-!Y#Jw~ zBW=I5f@I5Cs`Wct$1O@wzBe!y@O@+#|gxtwjJ2bjf^?jIFe&RG*Dt$}8m` zK|Jr1llOF84W35jZ!@by2vshDX(U{pm3y89mRJy|)Vs9P%@Yo0H%}LeyO~QjH^|z> z3bP7WY$*zD=#)Y#QiQZt*s>(7MC;7o8cv4iUmU^W(mr2Y0x2Kr7Y^u_soUz0i-9S- zI}X0Hr4xrm`OS4P_anC1j16AimtviC@$uUj#`Bc!>riWFJUT4fNLR88%hh`)J=3_* zQZJya(3uX*`^O92jfx1%P79UFrOQoIj=A$R>Z=Jeuc-O?K}TIgu0P6x?}=>H#Qug6 zCg}8iE*B5#VG)L`s=j#Aywb3V;=T%7p_sc?H*;n> zeYewoc$k}szN(6KdZfg7Bt4GfXmCTZ4(5Lj0uFpyD5!{5urN?9Me?#7UB*9U z8ASxxwT*~#Q)jZNrLQ}`p*92F|GWhJe1jilTUdt*%*`u%hJQ(7*K^m(dbNZ`wU;p4 zQdDr&JAR&`**~j^n{!n7UAmkO=w7tk47omiqJIe-Y+O5Ttp#0RP}Ltt6*I_I8o`n| zkxWA})n|IAagYPI94*6)gdg`M=}2~?rTDKJ@Ms~HZ(e7s7=OT- zYPT?7v(0`M8BbV&?@y5S)wvBCQRlp+YdhX(N7@`Szq=G>S#fQ}?j#o7=*%lzs3q*# zzoKSfjgb*wM_p--+ca;XS}ogOmT;{TbyB8NozX} zOsAa@`AG$Fj1d%f8RLU^>tE_I1w~6Whu=vUoi7;)bWEXm?25dW>*g5s-2I`2Pq&4o zA&b+poLYVM99nd)e-lXG7*q3C-v{lfP=!UMX{w@T^enRa45y6w}$*hCS@OSgPb_nA#|B4*D%A* zuN#-n!*hREnsV>EKCNxiiK7;NkM5MamL>lqvtWlm?dcBR;;e0?n4OeHBRrt^5DM9- zHOOZXK1t6hW|*0hjTAS1aVO2CuOlglx>@LlEOR2n=`ESv#*t+G5y9qwvpG{k40i(6 zK*$I6U$M%IwM7t&0T)uac`3P|;kfU43Hhz;(~+Ejdyj{P(|{Yn$s{{-AlbDxS>oBTCfbY>^brtg~_JllFPH ztD@N}CS8#N<}%v_UemXWZDkh6hzp{e`dNfl=hc-6dCha)R&;qtP3@rby#le{v8Bwq zo3IUfg>01VY{%(GAv>?+-sw6=MR85@`!26?xHKm+KBQ$pl_)#zrV^9052HF&mFoN zZH7bYPgsm8a>?0q75C2@vpW^*i{o#&arZ;!&;w%W{Jg|>)@4bDQl~ERRudH*3N+0^ z$p)^^+> z>@pc}&u)}ihlkvNja~XfK0G_tH$k8btTU7t6KB;bvaX>q+P0PPj=35I@tb^A)QmE* z-&iq4b03WZp3-#zzQD9i?v1NsF*-H}rQfHB_`Dzm>5Lm1d!TVish*#a@MP zo93(Sc~lW`mC;sB;V2;1;1^8>%ge%=@$iXy>!rtP+V6?b0bA=3OvNX(9-;i#9dgqR z%Gr6I`N-NAY_3fXI$ckfxWCoNJ<4%1tAFjiIcMU6dNw<`98#;bE=j{4^pOGO>%EA9 ztodb8Os>?X-KZN|PI?n=*VMpA@z|~^yWHn6z;5u^X-+r!Fi=f^jbg2JbdYJ-IkIR(S4)$ zh|mdR#)Wg`R_5a`=BRK7BD`l54HTyelk(8SKOO`tv_Z@&Ah?z^Ku@Tf7PUMZ!vz7i zSn?ZB@(_SfkYmR|9zqI{`fnD6y<`J zm;sm)MQ%EG2zEO@Qb%`)#6gH|sRb1Qxo@(a?#6;u&0N({TnZCFW1blH^(Zt9bdKmQ z0jWI2HP97P3mN7b`P~gb7vZ15oB4`s2;d%k;`!OGcJ@93csLLrbqTzMVar@=voVT& z`O9YrK*VvRj+Qwag*^a4F)z*D!VUF5Wrf7?bPJN(gukJ{+Y|k<*nNcXg@+P(U;Y`q z5yVW=MRjsRp+SykA!fQCQ^bI;2e}Cf6OBl`wW-*im>_OxByP5u^g(`Z==J~gOB#s& z7N4jG87b$G-*D&V0@mCMeb3;{jZjnr62Grx#NpcS=I?JJGQ6LMEH2Y3Oe%y7b2ow{ zAxU;;=2W3?I$NdQTIE)?pBP-ztuwZt)!J5LWZ0r{poalN9IADn&&bGkmyug|ZxX9xIR0&eI8zxoX&6Q7SNa=O>lQa$=fL>xZ~LZH6C zl?rwa&$(6f@H~{_xJ~w;@$P{R?JG&QQ(xjGAFtsNv0*-Enaucy^bxI zn$@NKK2hvOGR%2r=8M(Vdgr`fv1FkBb0x|V*LdD9)nW+BG;(6_iN^zJ19i?Xu9 z1Oc?&kGa+s))07M=*kQN8my!U5pGGroU@|&)>nH->+dkl#vqCnwH1tF5+H-n8N5(s z07nccl89U&=7U=Xx12 zhe(nwyT?!C<6ht8Grb$aeMC4BXo%U`+|k7L4KzZzH}4J!1c8P^-}Xd*6k=+2k*$zN zU9I9CM`TAOq7@#0mmGpSx^GtdDNv74G$f=9esq(!bP%c*8KA{)QOdGS>W4Dt_cE-0 zpZ@I!diFMrA^=&$jbizXXsc1&4GK2U4c>0ssES5{=qJ@qTsP}ef5Q@gBmCGe z0kduI2&*<;{@SrY8m}gadEPhKUwev6AKad;C?|FA3bnp9|1L5{b!X#zjSS^8+DqWNa;#R2&b7M^zNb7r1#BewAw0QpFJ}pFKk47> zwp`6tqLVlg$fRfYR6ZPv_s`iLN$u{NOUQnN@ECinFzDt~VEdBz!zIA~5J!DjImCkH zJFgmo5AVgeW^R=JRedxTp6nUZ%?-WD1pk&QGv19uA`D_piS*n=J9qKq;bc%f9GJG| zU3?I9g1|wq6skWCR|Wyk1Tk>C1#ZJY=<`j25*2x3;|2`pHac0(FpR^ouc)40lEn;& zM?`uYcKe|jxGp9=JsRZL|rVLn6jI< zTU3q38WXTsBPg~oSnOeOnnD|jL-*Z=5{06fBE7x&G$xOK^F$B#5};S4cLgu==NH>h zfZ{H=3^r%{^R%d7r6oj51p9=aaoiL9rE`0T4rLi)92Z&4+d~ZoTqFX!>6Sr1t^<6=i#DeN8R= zJ9J=eZR{&bRAVq#u#4y|?BH+Z<}NOQsOX>5A42o~UtP~*U;100@89<05s2l52bRxw(U(A09rH2>xGtan z0Q~a{wiOq}=0S)&*l|dd6RZXvNMf#{y`T^+i)@fh>}%+lARale7zJ_%>=6_ZNazB|L)*8dpNcWWbeOSYG((C z?!dmETqN3p2Wf%=#$gd8{hSMXK+aCbMp{HU!Pc?8E7VKlTy!o)VSA%viP!Y#Xb3L*Tr? zdwViAQm&q`2~MN`!)0n7HS<5hx&|9kO~$rsj%A$Gb2TziEGyK9{!G6PRL4P4HC71+ zL%=`)u|&E=dMrOySZ&zZngB<26jfgW*yn!-Ug-$lSX#{E2X!o%j8^Q^e-Y)O#v|X0 zWmxQ=z=0}s2z!NvkyLRQ64Z1+LevkDQoo$>21{->8fy)4hlf5a*?*=3hQiWdvNQC6^ap<}^sW}Ld zdAOr`cp=d&(O}=YAVlVaeFMXaEtFRD>hIbqUBj>C`rZljP6%^<570+(0^&3WYifMj<>t5N;iAJR$MlzHqk_0_cz7%VVX;mC0+GTz zVY8t`3DaNRP*X^RkWmXP?IR>c@ty`Cqzb39w0W>Ohn$vf&k#YLyYf;3%-1&jY9pNIdYZ`Z+K-ao{u$Eiav zsk|dKO3`{1cpGhN*|pW_3_bhKAtJA3;m!DI5rZq=UEvbI|%oL$SL& zJ}vXJ(3>L-c?#c9;3z<80+F0kQQ2lGxT7#o$8m%Ypuys-e=c}JV|Ss!H?ult3_^Io z!0rz&T5x)6yo4Ev%N~0lC*_l|S2IH&HM?b;0?pX2`Yc-O3SYy%K8tH5iT=(tzV7|W+s0C+}w1N%6v+=@tW<_kpfks zpE#?-T)mI?*nJc~q)c=vz7`CzT##0J47*#G%v*Fn5U%g|Fv(k*@a)9M(KSEeVfNkG zFzY(a{WDX0HWrkSfI)Feh+~Y9OL_9j{#Y(Hh>r#AK`k&rfD0<7{n259mA9po)c;J%2$XFR=UacfEa z{L^r}b4Oe!#HWcm!)HJE6&AJ@w+)g_ZAq#)eO*ItES7Z^ndua@yzsh_%9)z#^XR4@ z@3R0uCP$ow8-)gr01BE|3-}^^P)Hvn!7@V2&k2qsEJSB7p(JF2gOk|km$XECwe*7% zU;RSj=c88NWTS9MxJ9+ofx9r>)i-NXVNto6L!64=K@U(TONCKH<7(eP{tL_UEfbOB zIGdWlsquWF8$7zR*H+nByt7-Tkx24q$<^5=M&mRGj~5~bs!I6!+SeLgffC(sr7%8? z#|SKpw)K5uq-DX7L%HZ)y5dI zfkz#XiBg%YCXUYb$kunc)n$2J$PF+wU&%sz||>VXaa^F!0b>_chdqtZpd&GSp7<&CrC>@_4Q0Ii*Eo^hL1Edu; zP;BW=7_t4SP^#ppbUj_{<{H*5pmT$L6r!y;QVxvXfIHHmI*@qZVnFYioSUm;`iRQB zEciinGDJ35$27O*x_(2<6Sf9)#SP8xl*FX3+l4pYda0FG%)qO4py+oQP;Kt} z?v14Fkda_)5Pq_ow!}9eGief&>^`r`9_ku97-ylCy-2Ij5)I4lIx#fXc|4A^ZKnBT z@`jL3a2&a@nGVe6=DcsLZ;I-7;TZ`1QoZ(WS%3A9c_cuOf#H~Lyz%0+JRgujARs}c zg-yc1jn$IEBMYDA-#p*YMfpEZ}}_ek!dCcH zkn3pPy@&bY2rQ#x)Xm$ zjfOYetkqgC>^8>{bQWk3T3iq!>6|8degAx~y*fQg^gDhH8U4b= zGcFu0VO7S7P<tVPty)8ey;K!|gK;S!2n6f!p+@rhhq6};9)H2-|2|%5zHyE1w3De@cIyauDJn$mpjHQEnf+MFU8f?HY>Ayulzq_UxR5GE`OsP*I}@bl%73zHuo*Rw^xMISq_8kU94dHY;Z)-%SM*<{_38a8*$@&?MBc0O5|><+0&mI(sF?$ zR(Dq%cDwHVc4ND@`1Txa@%+SC#pJ{?_ErJ%Xj&%mXk6?zwMa>H33*`PR1aL}Mk9zj zb3FSdp3HgL*LmYr3#=*ndRG8XZaA}1J3wkf(jw2R|0bNT!jQ(|%W_;+9jmerM`oUi z_}m?~{cJBj&6KneC8&>$iT%{c;=?U4+`BVL*Pk^j8gIph!x})NC%WzRKww*WTPDze zXh=xpJGcwNlO))GZ1Bv6L$bf40}ua8%$KSAvE>_amd;d~{#EuZ`)nL^3U)ID>VrRc zOK_nucOlpE#{viEBI@sBU>?x!L5c$elzJpsW-`$HdegMHcAO3Lt2n7xx%av;QOi|# zj|VrdG)NGv3{!aM-X}3Bnl<^p+<1T_H-119@+)3WQgrt7yU$e0?8%Ybagmh$tQhFR zt)Cf|_3vN)7(S;WXfQ^MSv$@P!TJCUM^jC0>mI@+iiU@{7bTQ?ovcoU96}{}H?jxbt1fOJuX4Tfd%0c2|D*QT6y0?q{ym-5*;Okmy z?Pncf=T7en{Y#ge(QtaB0nq8Ub2YT(hQfVw7Ilj+N`5$r9T7@z-3$FY(asu>6AK!1*$XZm*9Jpin(&|)5adFGS zt|h)Z-&Et4sCeI-&MS}lY~uC=b9y0*XF<2^w3M=fX{& zSP7RC45+HmcP=J^(Lxm5-8-w6QC4;3N3%bTepV30Tdx-Y3|IPlbS>;#h;yT3%)}KX zK+OvZuCw7XHHk_Z9*;@GAjzG(NUNP-G8PtQSNv%RGBu(#QwZUF6o7!{L3@$6dDVFd z?YzZ9`7Uc*=|rtKAd5 zdm>|cFd&V~)BvV&jj2M3gt*$lfau-8QNk8kFar&{Wd3uIp2D)7w76&+5AzfzKrDN0 zHj%{%To+Eb8^w^Tp$wcx1qN^TxD$czr)|-)l?8`tby>Ao0|v6<9O#tpT8kE0l3_@#7k zM9th-s$5`}oI<7b<@ML?wKI7gr=r1GF7aZy-Fk5h_a*QVo_8{T2`s(4Xz@nOg<9Ps z>U$@g(s}as(4{!&E#0jIMTZFgj-8k#EzFEznNTS==~WyGSKiX=YSH4?HIEucZu{H{ zanw|W8fW?7+dYZwd-yY@Aqg4bruIAT5c>O`^p;s|zoCqa6`ihLC+h_iGP1a^o_x6J zd-7>I_VFg4dr*Dw5sNouep2xIz52&*@>JXoZ`X{-Aw)lTRu=b|#`xn(Rb>CH+&xWg zSUU$pz~OS8*FTxgRb1s24ZE%F- zDKa{)gXcMJK{q9b*a1Kuf>X|12Zhty-icri*wPc#|MoZ00!*e13&%#Exbjg@B4IUd z4>c}GY94f(>su%TLiir&(?C41A(B5kVFCg9_1+dv><(FpG&nmN`fhwTzvOE11bW<{ zxDcDyIHr`3E5I2;WCmEv`%OLXhdou*k`cdPS3Z9J=Qo9nJ!cA4 zBlZM5+8$+QIq88st!q~yjfPGGOeWgY8P9L1qB)fGy)f}KVkdk17NeLc+oD?qj4k`_3{XR zA<1uV2NJn#ySbdff)4Wtp1e8hHeSJVDK#z#x_zWluUglTc>9Pd62+IwgQOz#^m@Sl zLa4*A_cS+ehL{krvhtV>l^Ud#d-wDF*G4I?lY?Uf?Oi2%LRZ}dmVxG1n|Eg$hbQ}X zY900qLGPwepB~Jfg@aB^ezzjj3iRQ@H9ZmQ>I8|rmC?h6e|+Nu%7orUA^nLMK%O1V zo=Jg@nTTg**f6|NTYS_;;=&WO^D&eoUo1_GL(vZ9X7KC^q|%Tp{`~yshb1c;@Dm?s5>>v6IE#_vbS(xL09@b zQGZqEC?0ud4leuKPnbMSzW4X0TBoaFe7s-mLGk2r47?z_qr)KS(HnIXo`KBE=cbk% zk;CK3!h^M?Rd{xTl2QQq#TsHFH&kv%yFL}J;DWwYpIj=s4KZ+BBr;wPewF^Y zz7o(*OD#Ox2b+ALYxL+17*M#$kR7nuf^ zfO0A33wJG`Kc=npEE#l$xgGy5^H&j|rl{glg-sy{%>dZFg93zKx!z03d9iWy+a>pL zFe^%ndqNM~^eBNoA=m%$zy=Y(kTw|lybC6IaphycEh>;m0#gF_r3<-n6ocCOIP~y! zI0h{x*1m#S%IGzFckXcskZio4mPCd09h87+P?YDq1+E1-jK^+aC)F=oLYR|KgF!dl z_I^m*Ma=y>#v z*Sx5LJOS?Gg+qj13nqqL1dQAJG@j=!E~>a1m@wZOBQKY`24+PEYc1o-tu~(-U-6PC z6?}~`D4A7#tgpN9dSnsr?XjC4npFU+G*DH_3_{QD7&eWedR-c zIME4|=T*r(zxQlZef9U}-@Z1|Bk26Jfje6(zmeRZo<2SEUPJ6l3BI%F{pv54#&8$m z<+l=4af8i#`?ocx?$#8Xf z4Mn<`(Z}se!0~`>Ly3vV%IONfx^7$6>f-PgXYyE=7s&BJ3Hh31mVrt+yHYR|+wD@& z>BCgMn!75Nb$_;!l|}BwN!a*2V)!_aBhKX;nQ!OahOnFkx9=qbw0(H$9)!m+{+tt| z1QV^izkVfsqmL7avAJR8%MjKInpebDby)Bt02Pz;SU_;?YL5xeZv3YJfRKI4;nhUp>oXYDB~WZ$WC2K# zkKy$0wwIVSm9@%`>ffe&^u94&4ztuw_=CU^7m2PjC%ey?{PBxc(H!Gdqqr72UDgSj z)|;c-?~;4Sv?MZTN}lGkN6GS1J_}CjJx=#8J6NL9eB5pJ0MKR=By_jVX>H)BeQE%8 ze=7aFPp=ftRF-^9JNq>OFMwGGM~2MC5?z~u;62S_tbR7g=!llaZG}28A9xPz%^pt4 zmTD$HVP}ZHBTdT7d0Ry=@=VwB64+cm730!1(iN0q4o!eP5uxCD5KPqYjOo5nw7O~^ zUf6Mh5(9lf@gvm7>LuNkS1wh##SUKUCDLLhOP`l!&#TcJx9a=xPh8lFtb#X~_Dxy0 zkKbhUbyvh&vK(q_s0bak<21Hi8$R4S{vl;i=BaACeLad!i1AzlN#1sEmnOPsA<|rX zCH{WDl3~#sauhMG+sC@i)ZLG&v-Z``DQ3_4?~-YZ{*b_^>HBme z8OaQ-Em6FlvwNR|Z)G~DI_&LuN0~3SOA!fG5ZV}u1rdDP#{8H?yS{zfd(B&UE@ts# zTIr8^i3`^8LxzI)hGaaq8_DlI=?$nnnQY&szgnMd{duL@U(QXj8&gqKGLV#1XWVA) zE0Rz+vVW0Qa+JL5bfw_?dEwx`#By|-S;M>Z<(;})kv%szf7X+cs2)Cj@nhaozO*cA zjj*c1Y@!kR(8ueEZu-R?jGhe4qUU)@-OyHf%TQhIc$wD&VG~$eR9HM=D^GjbL;AVj zC$t({S?2GAA);*#f(X+@ z)yBQOiw&36k8Vea{=Ndo==S!vy63R_$p)|YqG(hu1}UFEpdy>>I&Rx~^fRxmI%e*6 zeM`i}Bx%lfJeD_)FYt#UY<0d$P4qy2M%M*1Vtr1tIJY5$ueC?`Rx!%E#^{ePXKS!bcc9t3hO=LkMAkmP zVCp!)%HO~8vFhXv;hGY|eY5bG9D6rD*LX(=Yh~Z8x}Ng7dW!@6f@^eVyBk%0?4Kcv z%f^+$+n9M7iV-!z2?2Y#O_OLqu7aZr{iyvtiKksrus@AZ*M7lLfW`} z%8M-$Bi-nnGPGnL&2Kx!`9b2aFNgG%On!Kpvm144wDrjLl~!9a)tv}8-WyD>^;JFn zDCc^Ag$!5_P2V+rDXSSe*V^1XN(d|cG3y&2_ddtt`|tc0s<*d~H^Ujf7^Vkp734Xk z_(~Fc;9|VxPoJ-=qpa5VH9eNK(+XBiydz@b_t%l0bbICh`_S=k$x+^ysf%7#?4W9!9P6fae*t9$`AV%s8Uc0t&jBpD;Qp=>9 zIr)ecgfeNr_0kz(#%OpjJ1O;!&3UJclgCQ;eoY^}rpBRRrb?x_V6a$p>?-2jZ8MVk zbJFwTC%9U zr)@;=`!Y^^`W6y%+G%R-HoA$j0?N3zDrH6qHBrv9mc^poHlB9g3~6Q(DzUIPBHX3l zT!14}N3jO!G6%#CN0DQwM-Mos`EUQE3Kw-k_40cz{Y9LF$RXXDtgI zif1EB8D2DmzIC-k9^v^A&Q^bDJARiX30e{x^tC>lGuaH%&iOB1dX7YFyRi}9B5wES zeCUFNwWO>n4us0cZqCUo%q%>#Kz(2hpI_)-2N)Az@PLZ!694Q+4f~{bh$$2EauiDZ zsiT*P+0CP`nhIu?puWZr6Rp1gCCJiT2f~9+=~LddB5=!zYFeaiuZ4INNR@ffgxdU#f62fbl;kC_^gt0h`Y5vF=bE5gE=Pm zyewYwJ%&fWoUONJ#3oZi+K%djycVOW(?jSVSj;o((j+*tJ>J6wOk#_XWapx=_Y*Gn zw+Frous#i44~#*cus9{aCVtk=xz#QWvqn|wX|UHYBaoiDm1PuHR9B7@x_x(n%3DA0 zK}ms6X>JqxKQ!BVo1mUdW2(+xa#k2BchLh}F-RloM>2h_Ab)s!qS_q0KFU&beZZgk z9t))-dMOAYi-AY}cJ3e=mMLi!(yjBlxY>|C(v|-6{fh{e&OPlLOr}Yd`KMp9oO;@r z%0fucQkA7{Hh7g+WfTWGUbSkjEr!aRaN|q)ARi&LJT^d(H44nM!R0`3K>-8X^@;7C zxD!cqkqWoLPB1jFc={KZeUXcJbJeH9_@nmfrrB0!5cs7Cr$Ucyg)sJ6`I#yJ}3qG&<5yTx) zSN%IK7^jPy*k2n>^R&NY&TFU;HTV@>>P$mnS9pg{`4Z54aNM`mvHGewi-RiOqEI;} z&3gO>!ubh*l#Nrbn_y~=x=OZXoqA7(-LHl1uSeJ16~0WK^71}VxaQ`7(@Fkq?s|ca z*M(#gKb#nSbSD2SZa&*}3GkP|?}^vP*!HPv0>`Z{DomhqScn zTSSt{_XGUbbcE?w>`n(W%*CTC3Qg|BgP(gGXU5>f1`JJ!Z!u_R|D2v4B>h5QOEj#< zycO!MP}hI#Y}`8O(|oPuvus7Y*`%%MGVfs&fUX?YoC_7s(k!2ojm#u8n$mwja|(N) zwzhS32u|PNZp>`3w!KXF4*b$5|0FuPC2WGD86hi_)3ws7PjQ!WT49nrrDCHgKVJ2b zFMB-em7amVFU+st!17|lpm^NbH5Auf%Yy3Fo{^ohnCkaz3J8(;!yadAiIxzCZiQwS zCe%z-9H0Kehlz~LVR~IySU3hrCa>EyE0F0uRd0OipqyedmcQ3~YOc-_?b6keC?t(% zOTXVn*xE+;e95@(xvu+`_)vS=+)&evnB_M@A8-JLqy>1rW?$PE6igeYo?3> zw`9Fmxf;Ou5|!DZhUZJ`u*ZZCGF!g7Q{&m&J`rpl6lAyV{m8(yWeHQ*% zfDO%&E;7HWI#IZ~_4Fx}PuvprQfqNe90)6xB?3$9|^V zi2AJk{?BK^IHO!hy+7Y!aXQJ4&BkDW%%UC588Cl2cKbqXdt&A!SnOhwl@$eofRO$l zzOw}x-{^dT6uOo=J!nnYR9-&ZN4OoXwp{|Z>qgEGLq0Yw-<_>38xFA^e_HKy@k%It zu?2hvPRFBha33n(;&%}aZ`&yVH}>m?<+8tWM;c^&6f@5U&tjBa4uYH?RDEb3zn6)h z!Wl^QFzZ>J+akxh_B1auE$r*1NZV(Z>{A3+Xf67&viB z49NF*xX_CeVb>60kY(X!0&5V9;5UJz_JXIsLVD7JX)LpUM|WR> z=~KXyp`q~Al&=fQ-u(qllND)i=ilIdC{vmb1PEQ*4bx+yrmIY(+TxkiIth5&b5b*# z&E4fyY~n0CL7^1vTs((`EIq#vqX?HY9XXr`Ir=_I7`Guqswa*)+HcMC56%(bh{Jgn zdX>`9y#!KJF2`kKE~{N`|7}OB+UE&h)-A^30RoRfe9YlH&|?WV^#gIGe2`#{*khym z5DbGZcD$i-g+^$*6a$%xj1uhvOU0VD%FC=$4yOq5a0zq>9y6+oy&AS7#C1qKRAz+4O@ zme@-mn&nr?JH>Tzmh<0=%StA@k%AQWs%N6rO&ca$yutW=2fJ3A5Uu`C@`t#ETI-R< zx&OZY@SoQ!E`fFN>wn+hPuIeP92*&jVCX>=$le8c5pLuUGNq_(rytBj#p%t|w{Ky% zpf*}NCji^RYR11+LjF`C@E@XHU-~;L3T&%b%12Jfz%K)Xb&p30->xRy{L{kHIRO^u z|9`ds?3ajIMuYM#+5aQogbT5dk0oGkFmmGWpaK8R>}E~NSZtNQDgai={#Ei{lrVc| z`(|3cZX@}hd%EC2jj;m$4=uocfL_q%Sld@4%aBngmeakE9#HDW*%aS6; zk-cYa9hX25Ny5)o!QtQ~!gCDoEZFS>a7H)@p6>nO;6DQ^53aog;uSsFW-bB$^R)|^*Sv8zEhE4&R{V!qSc$Zqkg5KW$h5RW)^nJ}+~!!GihnP5JuxVP>}znyqsq|gWy201c0hRBUdq}jxOIS-wF@^Q0zjB z5Eaw<&UM5(E*4EPt}BygE}4AtqCi)KD~b84F@xT6*wXlH%fk!m2cRbXk1iSqVnzIq zE|T?xdj);(D0NPFXsUX%&t<4d&DL(kuTm~*Ka{}f{sS%DYZjlz2hzQLOClHmD$Ufr z&c^okGv_JAuSSbl`G9%>FZ}x8>t;Dtfpu`IzR5U*A86B6*gx;qCw9$#ik5#SEB#Z% z9Qjk`eheyS&`UgXs}*&tqR=tRq*)`3io54a|L8L{k1XD4kf9I%sM;^A$c;hY7dy}n zB%eP=MotHQ^m)tAkr06b&6afK+QGO`01U>B8t{7)pAAwLJA3_*cu&f_xTtaaQQ{-* zN=0eh!Zz`r;Pl68xG2T>@xPNq@#xE_=#a#x`lIUW3NIwH$u&zNfOX@T_VoNGC}=bgI*z8WlsjJkqLG+gnk;omv&>;Gczt)r@H+W+B0 zD=7*{hk%rHcPK60At;D+NrP}O06la|BOL-F-5?FprF3_vq`x^wz3=;ZzR&Zn?|R?$ zuJ!xl?0xp^nc1`V%yrGob$#YD7APHM6n71h6mZm=)gqM|VBFWEY4wQYI(tVof@Tl% ztBF!5qb=#~c(9o#Yw)(VRN}BCXQO04fgMpHWQh#O64hQhP#7nd7&LIr>ML>@Oz#P5 z{*O|?`!NVtN(lKDM<^x8-zo&W7*HX91WXmUM}f9STG1nTR(I=)Pzluh6Obv!g(c89 zcE3LaP=u6D@%o3xpajOh6&Waj8Bkg9&I|)|X1%#(t3M&T`RwAW+_!(L=L8^1pjUyb zXN&~78TJMF>N@&*0(id#q!jc62|$GY?sB$SD4mm(n2KcJ`tP^&0x2g24F@jRcH;0P zfqtO^IV)sy5hAnI(S2EPnbJgLh`1YjMJNq=*z{j-AgcBLELk?YJLkK8W{G71Lu}ij z9p0Ko-O=e!$a~F{xFTQ$^V4z(J;iB9$P41DRsXFOpUdJgW)cs(8l^1SGZ#nJz%Ep1 z&h0?v5^+#{2J4LpxALn#i;Je^z8yKlpF&0neEiiHD_nsZK5QA0_4?c|1ABlpshtIeh%hnCs6e;`Gj-x4~fJ#{krOY$wzLg@z&t7wLeHHKYC z*mo`>vDHgWc>VAm<>9G&Fx06Ii3sOewk>S>Fh?6LeT|em#;>WN(q@goc+#&^%KznW zeK-W96yEp|Rqk87Bvc^swX2O>E?|d+M&y;paUM&?v?VD~K5Nv$ z%!baJjxl-(^RV4(MlUs8e+?*f;C<(Npp4)Wu=7hz-SsVA)Q<^dT`Ej$SBs#KRY6Lx z)xuUJY+mxb;mtVc{478%Qhm5+aoBGB7IKM9afV~^2hCe!RlM*@+W`&sPp?|j_p8Ao zRHxGwzTf$sHKn{szPAw`uNu=1d$*F!AH{9KNK7surEhH01|HtXJr2ZO|0`Dmg zLZ`rcliz9r#7B$>-~WmaIj9|oKUDdl6$?d-kg4{vp;O6C1iJd*gH||zEhMZWzxnJ?7%4C5O7ph&R$d6QdZ7-|IPx~MKHgPmA-Yc z7N<|>j>BCwqfl%3Qo8yfk};P}r}*pljT0f$K&l%}cZ9H62icY!*Bvi5)LuxY$<_Gh z+8|vWz88-+ieX$1YQJZFhic9R)ko&@;_ek}`%&p?1(j6u+3@%Gj3fTxmPMjn`k}CU zVID#zV(HH9n8mr{cvU+;G@Mz979Z6Y<$v;86PtIz$5ScVFPN|vR#Zi+t93-WY;C$& z_cYa9TmVJXPvEn)9i?<(I!VvFVWpzoSSQbk~}2Bo4GbLWk?@g z(#hMI%y%y2A3*&g&kN}ll+<8KSf{coY~qK%m!h5oI$?zjGw18F&W^tR(Nq0U@_ufv zmFCgsmaatF>YY!=o!+tDRe9gmoJ)PVxwtaMb1r&k)~eX5x-e@zc%+)k;3Z}=xn%tw z=Jig2Hu(Y?8I=OaWBIvUb*uuhKKJnO?%F$_UxNvYZW75Re*3lZ*h-#ZfbODSN zqwKN=_AWRN(_nV)5j6{T z&bN*;mor-Lq9&T=6L$x=-Zmu&w^VWMpolz0RIFMzVe1FmcSMbtH&Vl=iib}M8_=sR zuZfKJd>t_CgqmUd=s!~Nx91k;kU@!_H7~bLO})l@$MjRBgC_p18+yms3qci6c8s3k zEVbE>+U|AVlY3t+Xp$NB&9AGXDVgK)V%{PgLe_ z+h{Abo+U8Ql`sl>LzaD+co+L-14CyFJMZ*;QuwM`c_2GwvtlpMYstE4_jJt8wxNVg zq_pJbHE1~*rXqfg>yBM?F=eph-Itb`-F9KAaoR3i7|8UtE$oF35OPO#!&N>`D}J4r zGm5Ca%Q#aQIrFRY5Ra{PpF<>t>Gk!QW_>KW{p)N~CJ~aiQyBJ~sao7Owlo(ShFNdv zSBLB$av84RWk$;gpe)idfpWCx~KEA@i|5R{gsf%c|L}pxmM_W2wU5o9I6Ax z;}@QHz&O~*Q)*?VCcv6@SI_zsHxIt#&qY{^f@egf!a zDg~&OjW^Q>HG)M7j`l$ye#X6Neo==7yN+kfCSuRJ@Fu!wom)+K$1gZO+}aP>Jo7Oe zjf~x&=^m$}UAo7Gbnq z(+CwRKW~QF9o7rh3=u0r%0WJeSC05-z4h?p>Ue>>52{j4UaO42!{$5feVTg> zi~=2#+!A=Oec2diBEW?rU*i1u@h+az&cmA85GM0?T`Vmh!m%FLONtNX%G(Uo+T$kf z@y7=lD&Xy5ABqfr?sOiD{lNN8FIpn(6Uug&_jx~;fnrA?frq&h8d_Iq)J-9j>4^O! z))$|;dItqm^S34XhQihLcm|ni4T#kR8avykDc_5P0A%tMp9h_#Nj0RTO{&ymoZ$7C zxQ4{dz}dU)VOOeQ4+i&qSIUKs7I>e}Ee6X*qdaUmRL5Ai3LIC?+>$W9Bj3`dH2E@2 zuDwwDwr$SCKyue}!uRhmzamY1L)ke~MSEwzHKc4XDJW~0_VR$gj7xFY;PrKx#zr=| zMtV1-Q@0$uk6G~}wXsBtQl`4surU@kl;b;>_EBtEgA_T*!!V{Upkb-kw-ygR=($GY zbYK(;lg8w(9iQBYJt*lw+1c(yiQL2^*ZN8dVN6F4A_Zg(mW(&ne|>QT&heVpAZ2NG z9nFR&O~DD-n!K{OV{UdPqEUfX$%tHb6db*)=3$A`X!J@G2j)K^Ke(kAKcQUi2PgvN z8aqTobMluXd`aKtd{C2m`3=(r)TlHoVx6KKyxCxd%PCtj=0Vb;um@*Vk#S4YGYN13 z`-D2bf-Qu-Xf(`)pe`4{tCYXMl)psy(rJt*!A#~eIx>)bc&G|62olwiz<}ltDG@}9 zsq{>Wk+{o2AT>zC0$I?P`TN>yV`+DlE9p80s)Lo=SFc!BEXBacv)Xz!<@!%ZM@=R` zP$e3lT}#DzEiX-zibwY&cVU%Bh0Nki<+wD4Z``alzAfvMT$*B(+GiFsjgyLELCP6&ypXv+#zaurbWe}*2It;Jx|+W#7Idy((t(q#}&(4!{A&~{)vz@ zcJ96X9EaZQm}|(%0T`l8NIR5xKN1S90m>~o6XTez)R~4%zt86L_le$2EJ4=0a+pXL zbq9v+uP@9OPJO60N~Tnv1(lE8X33efO2{sak*p{FYFDXs8cQN7&Kx@D^XY8t^@W+` zf>)i@!UJ2Op11K&969tJd?>%BNhxQ<;psG8nS$4v-Q&GSQ&3Q=#+NwG(?q82f?r6D z&X;89eUY<&{t^aRb2cXXk#ers+$|nd6s>K$a znHzl8sXtOQ%OW)&R1{Z~`p;~3J0_RN%|@f%aHk0NK!0;Aq}A?g$7@#R-*0szAs|ur zo|o%$-3M5+<~xFC2S!bM315V8OXiKQO^Ut|w-`CnDogfJ=)Uo?0~O_Uztffk@s;=< zMzC{%w6hXkD<$2COXa4mc?1~+^SGjIz*2))l@zkiE_KjT%2K4I zX>u^f4RZbo;WXJZ%FVxT@!2k9L*-q}{4O{8TZNt~6P#CMg#z;xVr1@tt!~p?gBKjr ze4`K4C(0I}7M92Rr9p+*;qR_L`~KQbRMswf@%YR?#rmVM9_!)lYlHHx6?f#H1l^0c zc_Uwz3W$06u=uJh4123Z`O@ z>}f|h^Nmt!%c*X*3Z)|PBG8bnObob>gTT1Q6S=-iHZ)@8}{5c?d4y?gzrjoXkX z9=%8`P2?FXjgqv1rzIOigP52s;mT{_M>Sr}Q2f@Rw-kkP8_PGM2O(-F^im(+kP}iL zQoj<%v_*1O7q>59%=%2m0@F8wHzqj{a`i3=cWZf z?B7oD1ncm{+V|l$RtylZtTD&R~icsPi+$LZYg~2(Ghx|jwX!!2% z(W5(9`D?cI-N*0H^=>8F_T4|Al;w@R{pBYFOPaML;}%-E;scmC&z71EIdGYLC>G1A zsb)lp*wuN-V~^44d8Rm7G2UYFU_TZ=!>jzx8B$&D!H?U`6*{CX)xmE(Ve2ROlZMT% zA{{4fFA4m%8VmDakh$&p+wONPOV?8SZSPU3)GD@|ld}+q#|r2WpT;IjTdW)547soq zt<}@l)HHaM_SzPR?6VFWN6#!BPWkf=4M>m|6um5E6ft(3|E@iMpcO^<27qHIxXT$lWqFj7G=p6(9(_Gs%V59!P8f~d4co6-AFk72|?^_ zo#5*Akl1!9&sex(y{dTdj_{@J|# zZ~o6jRWTY9e(nuMm`TQ0_w@rVy@3_?gO;N?F4?PaX=11<@OaLSHa-$}0E$wkh}%zmoW!R5N()e(ajAQB=!2dq9%e?r^` zA}<%@`x=&H#Q>iW+RAxbY|4(h)6S_H2$R4NpB9h4tCE`ul4l4Wibq38>;dcYNigwW zd2B=LWW}d5K!o%ZXcv%&e|aRxaMk3ez4XTIX@X3=k`?GIY_&jgQ^OALxe+gj zpDs$yL3@xV^!1iX=2R0h&*x5jX=&Tccbj-tv#ja56|Jaz-B=3ugClJ)(=Uyyw^sm@ z*x@qsZC9Ys6Zh0k^Y>qLpZ8VuO`my=(QWE{xqZ`gbM%B?VXvS_c;VTa|0-UlmKtkY zYCdOGi(BouJoEj~DaW#asg5l<+^MAp%QDx5%t8bYv#%F4M_a$WkDE8hNPAQ$Zb+&9 z((IODSzw%hxApxM^JH}8=Q>5BdA+0icZ*QVx$4@-pGH`YH_A0}2=@ADw9iRU?6K78 zP?tQ-3(2N%R4WbN`CjK#;buIymXc*1y+?Ri>p2~L{O*ZG+zshlB!ml{B$3R53n=Kt zK7+mW?u?^7G(iv4zHHul3fylGgZeQtG?)&%9s{!qgy76f%=gA&)|tjydKrX&5GKl) zioqcHV<`7l>V>711Y>(0lFEl{RQvl9IDoxetaK~8f6x}N)+U|-Mt2^kRg0DWe#F#M znjjJOm>lXE|FvN{7oF*2>X|w+Kq;(GEWnD4m z5wCOU0qmryFm%ful^wM@@jB&n0$=m@DboZV?=I(505>gCvI*2(ZJFW3PduLS?+Y;) z5pGf~0;z{}1JWzk)1y7eabKc=G3+)YT;L|1KctAW8np z3%rwkJp9#jLCN6{$p-Ia1>On%cn~HFzX>lG2vdXKgg+c!2t>k`Tsw`pLakM>CZdB zixmD7@uj<%~CF|fV*^NBpYi({PeN{#LPDmTngO|JzrEOK>73&QZG&3bP2E% zv3sIXGy2fJt%l1>0{^q2E2!;H$j&OTsCZ*6DU+!H-{p&;WxTE17oY@bXMMl#XJbvM zoL<25O{=xGS`aWkUyfuJX1_d8d~ZCiatggQPFr0Oek|*RRgugB=_ul(drvx#W6-RP&#{!6B$V9_(GOzz@*p|*I?lMulGBMS=p_O>W@ zD~p%cvaBs@Xw>I>W=;0obdFmp!!DtCGY{ULRH)S|)4_C~ICM0BAoq$NckEM8O98tV zEU~Igyim;Uw9T~=igKXKlC23vW^F`z{j&2adW0Sjj3L1w(!x)*4|zMA=Mpk>%wwkB zcPz~+c}ley03IH zdfyYMXn#VA+i4TWq@}63i$2=frKm&*mzH^1KKN`K>ds)tQPoamG!n}0&zaF=c?-p9 zc{@fY?ii`TRZGa6c{GLcj`2xXyc8hj#npHqFloL=tNZ> z_jE`felj$aT``Eb^;JN%64>P|4nn;1+FzSTC~maGc)fez7(ljk&o{CUqk+(MM?!I< z+d8o39bYpEy1@G!bAe8ZD93)6T<58qSXhDYW+PO%v~A;@O4&G&DE1_XV?KTvzfScv z>iwv^e6nFuUr3%qL$FapgthHmD&~FNan^@zpM@8Vn|Q|!rD?_2e~ts|zGT|M8z8efMD zBPF2q7I>o)l}y$}x`USb2xbqvg zv7nJ9U{t5Ia#M68XUBTPC^0BA8U0vBEUDF-Vs^r;Y_~qxmW(rm*h z0yR0bw8|aw7m@=PWKNd$xq9nPb;IFlWtmYCX#uuX_t3dGPb4S3FngeZVw6`CrGq+3 zW8G6S`}b15-}2=P<18&)7|TJ$T~x*RFsLoP^48fv@T%J*Z(M)!@hyUqJTW}w>wUbD zbH8eb%IJq#H+}kbS?h*ZKL-gK$#{jgLR?C9IOjvjR*gu8Kiar>7*UnA(-m+S zX5eQH)GN;tJx*OHFj)iqjJ4N2pk5X(QGh}CjeI7mtOMrjDm;hl)GOpL$RR09?4Eh> zp$c|aOohqz@|m?;<*UKoLHTJdBXK`D@-Qw3!5k6cg)J z2~H&P-ZwF+!jNlLaDPZ9USP%N^2I4vvRQ&okjvEDX*?Gp}Fo)NEnE4`Jxj{fJi?IVVVH#3pOeE?fHOv4B#FCf?rz= zfW3RJA-=;+n1;N>mx$Nk&eD2=+QnMw(s}n0Rj;pzxsLsh_-W{v+m*|s+b+5zV^?=8 zQjVs6LXvc3CiJMNH0E8mU9^kA#QEAG)FWLLNlR|GR~f~r4<%@X!dspHQN{DEbMvT} zc|zS`UoIJ;!SRiJds|c{o9u40Ni4HRvI1+9`__ExN9J-Dhm+mTV>qu*N+!m`$2Vl7 zX>&_zscgdo4WjqSafV3FB&701ZM#?U6mk)Ms=7YRQcQnXRr zv=~VbL-}+}OCG+V+61-X71$o1p8JA5w}h>T)HI7Lnj73pG&zB5diz#pN0M21$m*-K zEHYvgd)C(v2MjZ}bFD~RTfY7L)=_jkU zo+7$ekT$n_^%mOKlj~0b`>TYP9A9vfW%()>VI%$gqpw1KI|0dVv@qCtXF#a8d?#ASOH>TuTe23{Q1j zro&N+RV2o=2(ZP|vnaKAh-F%$G@mb+ClEl?LrSAmQC2h1V|+daS5g`>O%L6(k_?) zW#xA4Pl%BtboEaC^Y=?+t5CqkJ|})kR9bX)5CC8;1ju~B955FGe|O{d9`)n><-TT4 z{c(A7!k&Vuk!Hisyp_`u2iznNv;`nB3`K-Z?!pG4ihD9~^TCv(ZObE%t14=q0nnF5 z)ri*ecYMx5omY~*UOEF;^t%OxbvznMxmM-2q+1hD-1}La5Xwo~Md-Bb$?zbUBx zu#oHad1_=k1PnTxh5Jw~9LGh@M3Xbkn8M3Y98K<~D7n1}Fp?QRZG5fA z5q;$Qu|SLjU#^h~b-Cx_7;D=4?U?xOP;bxiO>On*CgK>=oD5~XPYoMl8i^rz=yeG# zM%M?A>SRt-Ym(>8Y6s~D$!Umh)Y#EEo|OFX(&kCQ;vu2^EIWL(CY`w3L!6)$uQf%6 zZkQiYxj-TIJ{4DSfiKJ@l+^NO)3HIP?Y*Unh>^l4dLrL-c^=cHxJL(fpd_>4%@w9l zR0O|ZofpLM3-(*o(cUGhd@HigK`gDM`kar?Y0|X73%z7k6ie&;=~uhW9kv~l2J3DE ze|dHHp>7#_^2H z*`Yrn#$3=m%l_EDEBLDCRFwu04WIKb0mR`mcoKCxsx2Q)!~yAxRXrZCtC}Z|TaYpt zR!+Cdb*>HOLBEjX1CZ3%)fL#VBY!}o6`0bd>jU;PO<&G3mQRSRT-_)zz4FutlbM;T zGK?nurVaX{%S*6yTPN-G^>iqQ*FXoA>WA-_D~Z6wa0{N-(A8DSE^yAhVFBG*a!kX? zAwWra^1rV?^a$LMrS_HzNSXHNyP(nUJK2KnfN=+=_}ukT@dkk5f~x!g*{0}=H0>yc ziI=Rp0%)@FuHOdLOc|K!WM43XT-kN6F8`EZhw{*#^6(U8oj_Q;cGh_Y-HItkl6CSE z9cQIeardp)Lz$j8R#Nnz?d>R5dX3PVrUhS9Q+nXI_ArxG{^2CN84$Q+4`JwggRWJ# zXP!)wq>|`F$pjZ?Ht@vQ_)-0jRGgQstbF|P_>YLWK6OwZWr~1p>?osfEj{~Dou7qQ zX4!S7UguVn!XDa#K`hslc9gAbfA8M|*V>DK2Olp(RGO6rm<%u^xKLE6)qM$kAKHU-z|1;atJVt9j@{<(2SesFmM_qK)-~g!U-q83 z`VDV9ptMC8fYP3h{;jlqW$M7_^oeAlc#9ZA+{l z7{o>tn<+Vlk1Pu#6t78!+>W_ss-u%)VdPgy{`tz}J;f5?Qo$aX`Z*o9Sc+NnlNBN5 z)@u`$Ta62^(i0cZ%P(Z-lkO{oDr#1gaZOLiJJNECyiMSHij&T;b42Z&iaQOPtL2=Z zn9|THFN-HLMeFJ*o72(O4z)++Ty@gxe<4#Ff5Sj7WU9W8tVW4%MBC$y>C4Dc?8s?f zQD)ee-QH3c+iO;A#S!%Jw8zi&j7>i;IrRxU$@+`Po6gfX+r&s7TRya}lOq8mE6iBB zq+nX*=YfgZfl!tNpf$409KezIe}l;QD~@5xyBC@~1)dWMcc-U}c;dF%+@fg`v-)p1 zIECoG?2(Ln}Un|++DiwkKy+>oH~BV1zIqgCVh5HUDH_acR* zD=bV@WmpyWhSX~d-JZlN z=TvBPcGeaT;8jeL>T;_q55Wrb3aAJ-@m^P02!oOa$n1&w7vvkQ#jG*LfAw98dU=t{ z7ZWr{kj=A1=v=YCY7gH2>zDsRg@XJa!qwCylyhxC@>r>6u+Q3*V@!>5Nsqn zINLMO^wnp%t+a;|eO2TFs47m6zs*DmkmCZ=)vUF3d^FvWOk}o@-H`TA$SW|KQ+xkc zTl&ZN{@=sgEZQ!k-uK_Y^|=y}zR@A=oIa zy$pqRK7{!pg4H+_CIEPAnE<0R{zb#$!2VB2q`E1Bc=rV~Qnvd_>6fB`vY+w*dk$h7 zu|!XpvS=RIT7*rsK}aAMimCF&mjuqmJOtO$n5f*L`T|GUR|GzQ!CBN&AQv%z3kbCm zIL<{xfV>Wsl%;%?g`mnrW{>Sx+dspM1>Vx*ETGVa&DYc&WzZnfr)sB~-vE&lDxe8A z3c3adndbSHgg!7dq zlZNvBNb8Ul-cd&hLSEhY1(nJQJyLNlF8vSWAElgPE_1UiPnjV5`@%V==4JsU2~prmv?L2sPkzB zB=Ug7HOz6TaNEIAo~d?!=MgQ;{U@Z<*6CiMR)sC`sjR*JM8l`215bx|rUE=@U50Lt z6E7u9o@vP+_DE*cSlHju&8P~Q7a$R!hoE@5pu8URWwX9rLG*m>lS;1Sv$2~B4B@7a z0<0F%rUt7rMsP}nmUhxJs?yAG|_@j^5G?mGW^Ex)s(o&c?lyL+RmM1zD(nqf45mc;0 zT@};(MC%4(0GmgeT|hutitv;ppmXVUSQ0x@{mATxsj3ZwFGqdJOS7UqRjnjKyxy`d z9I%;(T-!(TFk}uokM9X=nWl*Ew7;!WfzR29J;V20mYGXdA$#H5iMyf(JDogL^g(Gc z34{cN8gG8lr1F7v%$S#P9Y><8C__4WLr;eo0NPgX<81G# znyz2FVURg2+hx88ZybP4tv<=De|Sq1f<%Jh>qWY>MKAuipOx&np=^;7&ib+2x5dbi zYl0Y*N7I+d_;v$%GB;@1Cn-22C>W6|j_Xs74GfB(7udUQkl%zj((+4)Rf<|+72RCr zbK^32v|hfebj#L#vRw0|kFdSl%Aj%jX1tQ}avNGDj9>RX^|5W@!PYor3!>suG0u6)~{Z``}ifpkW#S@l8K4$_^ zG0`y8Yw}CJ+8aSWyhOnOg~aWh=JjNS%rf~dPMvB+44(io{U_LS0S1Q!-Nxpyy#kL! zn9hwD4HbqvtarWXsbXC${{7>3ccSIrq?d-&c`i~Lt1Ef2zfYMl{;F0lzQeu@ zm^F6^3r33k2Zg^CnnZ8g(PO!V3R^3D&D@onltdkqw6wIh$}ngmr(U2z=MF(r{FQ1t zW!m_wdU*xKcz>AML&Ozu($zM+PrVNY{wnZ{5rK$#z&|e$8u{7b)&7-9@Y>}PcpGtG zb^F&Oh&XnKS7%qwAgiJkU@&03V5%89-#8tp6R&Sbf(ZXkQ?w$3hz9lo0eerzA#Rev z=Po||bR$14-U(EU3Vh~#Vh?0Huv(Ty#;QQWPT-mE>PF>T9ddJ%#W*aWl45)2e93gca) z)3PyG&ayAI%?xk#d6v|9>l|cGH%uG%W{r{Bvd}c|rGvdT{{H9>?^m9H5T!NHwF5R11%BDFgY>V6`o)rF zfH%qBP)??{@&{}aJu;DmZ*{{WG+wlEhr`$Rd zzj$8_C3Ybd+x-2(+BQNfgpAV1QW>ufeA5W-6U>SII2Nn}9rZKS@8TwX|U=naH zMy5zU8vT`1#?)=N4ExSL8`=(SzGPRhW!~DR<;)24P%yxp{k+{vz=4W>xTR`Qjk6tH z1a`~yzXOwv{mNHJuiKoMR_-A4b-|(6n`OXF7On(H0?)foVUWe_fx;K^PCRE{+089U zMYLT{(WOnBl0qdeaMBZ>dyS3-ER86YjBngPaS|#A8|LCEPu%#qS}BFKc@@?B9r_1HW=YOH25t)aLw4qgnqKp%(%} zdI*5Uo%q3VaXXlh(+04#Fzj#RhJFed%>KRx>-=Q$3plY9ZE`hmYDLCn1VpU!a2Egq z#f(NzfQiZ*(B%^~$g~Aasqrd=5_}*n8hn&khyumDl6r##^@GwVcM?2}hw$SzVuBra z(!ppFQ=2D%Ko!+Jo!|nKgAplJqZPho%D+uj&?r{*F+7QU@u8Un?1%7`2Y}62RzdD+ zXF)+o6^+7LB~)jSjpuu&dwL~=J6r7)9#yod%4`jZ`9X?meI?x&gDv&bjJMyys>HuM zmtn~PTa8e28aIz(#=p>h%SuyR%`Z=W-`^xt+3BbNtHUgQVAg+Ik>4>?h#t*%XAJh) z^4bxBE~a+8OagA4X||GGMcB8{$;%J-Z_G>|_NXQ_=ZwENE?(#V)akl8=G3P1;*@yY zeHN(^g}7j#U4iivu^S~7Ofi^Cu5h^`@SzmzY!DOXxoZI35w&YJga4*>OCw$7xqNKj zxkpq&DF696n2z%Y=kI<5C}Rs?&c3$LrZE z{K4yqD4iItNF z<$LEIKHkfqH!zu&m&~!$Hc4f8m@i}ubCId78*~wcOjzI03;f3VxJr*`StLxVsu7zQ zB6);CM+(`vsQd|8iNBHxMdTk=NF!qAW$u`(Z+%8xTwOZ!ba+A64mF=nK1}lyIR`bK zviq9O3(=zWlNb#vp|)uIY+YG9hMUdq3<7O(G_tC|HJRTXdgW2H$hZ$M=gHQmmTROW?$hpzv&C|4C1xdZx$vpe z@Ee27c5LY)cD;}-2@1DZs*3~T4z;CrKx#C$UvxWVKBkN$ep}%19jm%lxuOZ%{g~-4 zvze0ZFpO?r_mfu7?La5`vKi3njk$ig~ODwYpG4e$aW_Yf%M5*$Xd zlnwIk3S|`spFtDYo9Yptm|5i4hxxA|lOPS7_%{2}uPI=%A@u)q2s3{&LA-#`13miQ z76!E9F-ov^;{L+b{FNqgI{f%fgqFgKINmk2b!W1l94s{8>bWxGrPsx2T6xM3#3%_r~Yn6r@ zHHm(E#aAUa5oMAtNKuvjnBSEl%}3Ki{&M6Zx3K#rPlUPED|r-wBH^J6Z;!i)Cu8RF zuv_kLQw^7;{l58OzowIeU|%;nKTwq^h`lP-KS9B#({Mo=-Z7 z=7FIp+4q{md0$Z#8z}f}!S60xFLH}9_WL^sp6TYp%PJ$juyTcTX@QLJ8H7BkF`*2aJm0la2vIo-ht@*FQ!#Ad;<_Z$ zflvdQ>&q9`P#0_a?D26!@1U9K%+gqQj866IbmqbBWRjvi96}V~aD{c@M+TrEaQR+5 zTuPAN;~{bm~5FD~TnMciP(G-<0QmxC+5J(e%Se zeofbp;qf~!8|cATAE`=vV)tyr{*TU6<@%;ZJ(i+9{qb(PQ7t(NG$?ct-$)KWDmJ|J z?8$z+JUBk25G2+HBPo3A9{U`fix8EGCJ^En^!O+hPxD&}JhR|;2>n9XWaYy(y!Arw zZW4jMW67)S-lo)M1i?+UFD^Wbf{gr|Q6U>9wC~nzNix3oScU zHS_`wn=_`GtA^;ipif+q2ml1I{GrgUNO#~YK~`WE=KwK0UIPH~(~sy4MS(7?lzz6 zYjJ_Y4KM06qEz0*^}8*n-9|T>*R;=!$Jv6UK+-J)fZ{au}Ec8th1PA z7EyyCFDA5-waNng-QV|AmNEHZ0X!gw1ow3JoSb=Ix+#Thu<0ew;+iJ%O6Zr@^5ape zoF;%SS8hri(+29c_o0u<55lPSvRi$NvOo!AiF}yLP(1O|ef$V@{u>5Xd1sLlM0}yg zIgKY5>$Th+L-O__Mw*?y{Sz-SK42W=t|GaH?(nb3e}Z~62<7pD&Eup5CmkEtdc`xv zyw2UzWtS?lW*Lzmy*}?b9Y911W}A<|h@v@_Q8>Dra9n2i>^i^cy$p!9?>!r;Y5U&T z$5#hF&dmaoUB%p63ri#`m1#^dn$Nyz<`YtfND@FUkR}Ix?gme-aqq}oduAM7|LiY+ z_@6-&;X%uc<9`@p$>A4C6R49AQ0)(09_U@s)zyFM-(lzJ;XG3Orok~z>&I0rfWnZv z?ftUgn)p)oIoK+dQU&*2?uHNf=v<0oLqLm;kJ8uJijMGh?v}Yhg7J16Q&fnzydd4k z?%8Eqo!7#(3tqQd-_~r+t+SO^KTme;vlP#U5!gZ~FBURARnwPxARZ~N=WDA1^b(AB z+p?YVjmIOMPt-CT<$Q0H_g}PM_ZR3hjPM+uCK+U#A2V+N>#qW4n)#%f=G@sjt~)wb z5>Iq%3tU4ntL)u$3K1oRLH3|GerUABJpZmpr;oESZJlD_c8*m%IrqcqbrebaEQ^;x z*(mFwFa_VKjcrSHq6YVx_GWPtwrtO{$Fg}yKgKc!AA71|^zJu*?8%!nY9(CURm@@S zvZhsZE*mFv43Bl)43TJLR>rknr4U*tn_#p9Yb9yoP|_F3UQ#%CYW{?ho=iyY!jT?76Vs{%|*RHWMGJoG&ermmnEui$;&O zTMs#yd^oYqX0|M!G7`E^0Vx2z#9%+F12k7Zb~NrrixtzdFP?((VQCZ+0Ro;n!&WdZ4#g7QjH-aNKd zKj>zjEx&6CHOh)4r3U3}q`nUYps2l{PCRlW1urcu6>r{HVKlU9nmh=fGV&%mUDC&9 zx}%+*M}vnr42qT330T)=!(TF>^83o&3zw zfn*}ED$w(sNZ+6*>$|acs5(s1OV#x4_ooE<$vRkc$rsXf>*D^Tkx(D;(6Hmc$!p9c>-rZXD?N zp#>>oco|vo(rBsVn+8DGCaR+Hu?Xo+WCB71zhG@Wg2&ttE{Uy3jR7zLlqSGLV66aX z=6^1Kd0O1gqe77LS!P`;+W$VvFurcQxqpF;z2A_bl5-fgUNh%pIn2}V=qy=cz9J`W z7C9ZDaZ}FCFRdii08@P5Wyw`35+7F9{D|){-M%57W;hK&{9>jrJ!n25G^#f z={O66-_{&D`^{|lgCdU9h|ETrKQ~2N)0CpH9j4$!dzl+wEa#(%RwtAI*i$|H02f;7 zE3LQF*t$t~~+9y13)7;TXP7Zknu7Nnf=rVRBBBzc@X zMqA$DN553(xV{lGvbZ2qIZ|Y!G?kq1m_Kd#VXh`F>k$BuaK?3o5fF?MJ9r3?6uE#6 zBD`Al**+9l=t~UN3Zpvc>rH32)~C}#(>h%7V)KQOKk5`OBwJJIlvGzLyU<9l;c!49hgQQ_@PwG~$p=k(r`=GB-9`}x6 zOFeyDS>NX-JD)Dphg-~>!c_T-$%1X@+>e-M!Di@!eRE2pP)s9$z+mwFfJRW6P5?ns z>~YfZp{{*(c0A%dip)g&AjSk=lS7>QxjbqJhY+URg{*6gdZ$?8EBB}V3@mJm!flEU zT>m*S;b%s~&p3LBdfQ zx>9V0fh5u9F>9V;h3+hvwP)BHId8EXvUJ2~{#m#7yVjz_hjg8ZmwnS1kn{L#v-$ig zHrwX!GP~VmKD%FwsiU62wx45keMAzS5XitVQ_d+!%B%>{kqBBRvv8R8p?&>BvlIeF zW6HR~)Bsq27_%=z4}84zIrbKosj01axe`M+nUgMh7QSXUH3M4?Rpo54MMX*{j&z;9 zMpkmz199~oT&`mEYU#1eQF@Y<;pT>azoxraFN0ypI8{}5wD4tqc@b$>c_+a$`bZWh z@(lB0OnItcYCdimOTc2xlQ=l2ili<(ot~ZT`B?C3?M$6A3Xb>#{x=d}-9mSL3!PU2 z5PqXQU?jU!cuQ}b1Ym?j11`OQ0U#Atk1I`kZSyVr4(mtO_~ms;a_gDlGdvB$u?zaF z`N1=5MJcq;EKJe$#|9L_J}w&b1QDVmQ5cu1!6}s40$0(Y83~-`Ir@`^fu>G3^Msk6 zgf$+2Tw>wwEeedXs-*q2JM-?&$Wy)&8R{yO=Y^a3p{5&Kk8nlvdz|yV1bDspUhDeH zqo!NkW4nIb%QEPu#o&~WrS25CGD&9^j8%gsz5dk!P3QKg*Zs>v$eUTtu*dOsAC`GD z0&mh*VJwBsIl>lFlcAdh!I;v^_O1@8rL(e$5uQH8SyRExBMz=|o6<*dA@W4a3?VeJ zW)4!9mSn0Ws(yucuxP&AT)@$F1U&n^a&Zse*)+B|AQ7v2wDFrV6qxi8-TOXfRo=Fw z<`_9cDAIPbMPPizVJxNtwr?ntvsGr`Dk&D1>oa6Up;l;uJqwVF)NUVf?7kI7TMD&f zHrrD^eH-+=5f`$vqi2aFx7SF-aBuyF4FubnO2t^;jm`a?l4waxP=Gjm$?`2?<2>j!N-gSHXPaH;WzeI;YT6U|X#J_&c(6Fjw9lAysYP z^x>sNrDhRhUFdy4@R9U?*n7*cI+k@?c;bW*2n2$=d+-EL2r_Z^5Zv7*Xo7^`4#C|C z!7V^=cLBnq5T!n<7;{d36feAo&Wx>OQsQX)-x)@u=Z#8 z!taYAp=fqgQ@#uo4Oeby%f!ru)jTANx*T@jbxw*eaFX&?6_BY<&saWnK4`C5H$d$| zb|4xTnq)pb#Ls%Ee8@KBLh9CoPkh2%2=uo_aFqzurpy*ehS&T*s+Y@zUQUpiB>3zt z&vHm5tf0IdF14`D7CMF_DKN%f#TBx|N`IsB7>rTx9sGMVCJOB+#4g2}I7Tq}n`BNU z1lqscdHH{L=Pmx_&O`G7L=3+nycu`1So~pTkErtz<=f;Z*XHME*@qdZ*A+mt`SnqD z|LcpN-&3I>GGKuKgvkk#9cA~C9gY40;qwCEtZ3nl>{};ys#_1?4W%OqE;zEd+d1!8 z_t(3%n@ZkX-XI~?XCo&)l@xrQ)1tkd0ip{Jq=3)%cNKRDaM{g?vZBbti{HBvf1Xn1 z<#GgxuaBqX2Nc(Q#x;f6K`t!!*T=9M3zT1)fuLxp7(`-?>;iZXewQ9`F_AJl2-5z5 z)O=6O?*9W~-NJr{8Uuy5tkWMxT%Y(1sI-9aS%?@YRQv{(bZmbfl5miHkb&9+DkVsK zOZoP(`2Bj)q(b@(IB^^_WeE>VDF{E{<-n-Q^QAiME+mr@sJv^N{3D5Fpo0_v?vxjC z5hBp?yIL2c7bA$?>Ni|IcXgMz?u7nMdJU3>BA9NIh1VJ7hhnG}eF;D{B(8rRwrpnK z8suF6y#5IXCjhG;_4O8zrUw-PRgVFEA$&9@Ka>ODdUscXJAooW z-k9WPpbyJ0&FMBIKdUJ;pAdm56omdshO)!pM>x-9h_)@8bNK>8`8( z_nAa*J$Tm{rRd>>*ADxyZ$(t`(3BP}Qg)Bjx zR=Ep&exu^zEY*QuW(XF93f!c?FXJa8P8VDfn8nOOI514mAQ3mj)Ab?R@p~FFl97fP zFSPJ}T(!PgEyc?RPcSR(^;SM9c(CcH>zU!eby+4O?M{`ubAL%JzAubH;^TfhO(!s! zGq(S_UE!Nx@!SC2#T1ht#t%Mzo_pP&)*DJE#s?9eTBaf>@_n{=XdVecV{YH2lt_v_ zpw+PZQg;#Nn)9YR<#Hv@=WAkY+I7c+ z;_C6soVJv&=p%8JkFLAnIK)x3{D@Om3{H2#3%TsLL?yrI*)JcJePAVyoAP_FWH+{>CJ?I-r*K$drv z?iL(D`O0aI^G;>yw$&`;62keLc=kz;Jr%n62@*WxC^gr7p6tFBaa&Jf4YOy~CG&+t zn+1Y1UAU1JfSLI|8nti7Ap%sO=-Ob~T`wgT2n2`0e=F7UonPgAxj{TtKe@a~ygkQ*E9n>#qAg@DF=Cl>op;kpxv9w^deJ-avK+jue2cX~RC;$_m3L z{NpGT4PrBTD@!DC!PRs*I$R2Y?ctKGj@O@Lr)xn%UiKM$3H+7O9mMsg4-WTeKHYA5 zBn=0+>m2`b*NISRD2il74p@tJcvuH!lcw8mTpT`pNX^{#9`VORLAZjQH_ADSHw9nE zi?p%@K1(BH73elaP0!Xx5NPNOE_c*bOcPEmaWw|I^(qvllXuB4^gdgdBUZD0JJz*$ zsohhm#&2)Rk3T^&)*+)!^dPL&+S}`9GuMtU@s|8te<@JWonBK8W7`#3IKf35P!lTI zVwqRuppW$+{*0#E`-WXk*_GoDN5)yM>t2pl9q!I$lGy3YV~j<;hA|#DTZHM@O04d< zEQ*uBCw5%3zT|Xu-!-wRi-5``6@F#%y=dE69#!-B8_|3VUM8f3Sz0ypHwQM$Yza4? zA=gFPVXjiO&9#-$r(}F9F0C{=Z?fqGuBJa;3)e||84wvy?BxY6&aA2MqWAK11Ri2x z_BRNm_zhEi$Em(yVSOW!H!K{aJNBb0a2BUlLSqD8-EMfOGhD+3A3o0aRMLhbje?mY zCs+=F30|08wzfWS#WjWlS{28y{gnJiNdK=N9A;7NLXljxuXA_VCe78(QF;9{W9zTh!L^j}nkUKADk2fjjSl~Ts zvpL$URWnc8T~HHn?#J@=Do!{kOSiXQ-iGd+|>X zTvI*PD|LMg)kJ!JIaC7fWyxrsbXVS)vK+Ozj7Nz*I*m)>j&oGGu4!}_w1>olJN(78;N5#LXBnQuiY=HrpxBsP=jb9FD;1#nKVs}>z+o9hw?OmnqGm7CD>+X zc)Ru`i?5`~#WYDzmVodmR1VfZDf}*vQLVzo9l+ zlQP7}HFPPKRLszgb*=1JEGn-dvN*1D{yB{Zdw`W)HzM)bpzzCI3*k`(ufEU4bG}OH zZOn7<@Nh14l^4}ud{f%HJ+c7MflA{XkGM?!4H*bx82$q-`2Q;HpGcL-@mo5=AmPIk zI@{AQ)o(t{m#Tw05xZWZ5f>gYEmz`2s~*Z&q+KUB^xqa+azPviB&c7gC6`q5HUZoD z+y1}OM@6uTQosfN`B&d_CHck(AtvJy&6mm{>HFTXIR~>TvJi>tmgY-E*apT6U~LOd z+-)v@S0r2ioL|+=GD}pGyCFw-Z0@`e#l_Z zR9MhddV~l2zL;->{t38i{>oyg=cwMN!L;SDwB>XlCH5Ge1?;EqD*pm{(yhFvb=jtM zg)nXDRtW&{_XrERngE_GW&|Ff!}aNM{AX7Cr@{QGu>1+-18K1!0}h5hh7JW3oDTAz z3^RG*{_(z~>8=M{`Gq<1jj#_$j)AK7_LVlSs3XOz4YZ9Xxfbr)=r{P8)G4&^KMMR0tE5+ zkD6NBa@KYUEk;34@5sDd%^;)rW-lkwpC~wWN~us|-&C6uoQ#WF;?L zzgv}s9*DtKkq5R2%0ZdM&{K->Y88zOZ@q#MjzzB0{t`U*KUcvqO`GQ3OA6xVoS{^tY+z3bP}C|KUpk zybPw6FyWm=_Spy6$1wZ%YRi|p6TbkMa+Y*ACwI1-(Oq8P!eqX)3-^lzDgoa+sAqIY zM87cLdcVy6{R3pVU>BPIb>Z)ufD3_*2?Y)hm}!0YfZ)PAGw}CK$L?;Ll?}7uV8$Y_ zTfu|D%4)f@Ct+qAH852{LBJG)nVfeG#dmlAKdnh0m^BI5&;Gp-lv3p6Aj)(A&VE4h zlM7Bwn+a+h4Yf9KjS{E@eL|Nh*eSeIbFR25aaB#9tw;2H1z|)v(1<18xQ$$IgbE)h zM&7lqwe=Jnd>dg0pE!=5H;#^WyZwDp0VtQ%GYJAj<)-vYYqpyc=o-ey)yv1*A`f^* zo`m8*vNFO^LdH4m&J}gcHLq1FseQ~d&QUfpj8BazpR0jodj9?PGWXC=c>AuM;I`ny zii)yF?4{3@1xkGSH9-r@Cf+`SHFSJ*(x1=#`mO~=5P($6Z)-O9x8>^uxusvuhFS4< zD;=+47Am+fCgdrsN5JyM*(A&e_kndUK*XorEswyOAFNftx<&y3_=ipJ)=ogqzs~-@ zDD2U*lVg-EizR5GLppG4tT7hv@&>a-kLj)u;ldW?}ZOTjiG z0XR#N3}O)6)FO)qrp|_FbN%FXvBex^B1F(GF9#IdY>iurn)vmt&gGiAx+{LJ043S? zXOf5!I#K5)z6|DCHw&kkw&*n#T{KFP3NvyXoN0_%=UF?w_JnN+RdTrYO*Tk1F~>M& zm&PaYg~YXL%6Y9J=yC-2?)!J#c&fe}<8}$Y$Gk73kt3$!+_k*w>s60jfRQiS^iiQY z-aJbqiPo%DXUX^H{N+@bshb1Q2QLr#;!h;a$Ln)jyl26t3fR{x$mDt6We<1 z+j@qV{MqFirYQYS^zJpHIW|@j>)FpOX2i%eMU`kN+my-C%whaURP=Y8&J|X!h)Z33 zIuL#u8s1meZ)CXns;R0u2AzEI(ODz-2lqs|yuZcEcyo(>nC+55Fi z8p$EBiOsC~Rq1iw$ld?k!3zMJVSvmsoV>L!mr1uw7G#B%?5r9k+cWy}+$`f2<{{7E zXT5Wd=Z=CQT&e27O7;te4i6;-3i=$bSafF`|1>0cq7Zff9g88IKsSatMB(G(080jm z`LP1LYtFvhzU;opgY0S?mkgBdGoMFtd*)F$!yRL3c{wPm(*2q_jkQhH^^eaUA}b7i zoG3OV4-NmJ`l&zU#ij@W`{n~HmfY&5!#zE01gh>jXw#mR-*}oh<6gS$DZed~PvXEX zRXzUvfP);rN^V>3d=;t-W7Fv%!>d~L$7^zq2)lIK%6sx!vYR4(JX#u6whI<`jRNfy z;;|1qPKI$Vs`^biIHiU6X^%qgVYGhhvR6CHB^vW(_>yX3m0S6ezLFeDB0tpXw4T@? z_&%kdvR_Dp^Ii^HG#q7G@=QvYFk## zY}$KpZQ-gHQQdWVm`fsryjrONxvP&|c@@P3zw&p4e`76xGZ6Hp64r{4nXI}|`v!y% zBXvbcB6|@6U$qwu7nI?(9`-ITyx8v~jLVPD>gB4gi9>}88gy(UeoKod*x_v0S~>Ic zQmBP{RGMG1dM*~lW>Tl8C-p4CDj&Jbig}VVTri6zsLce--#$99IOwIq$TkFvS5g%0#p4*LgW^6~e(v;oszVzbdIS(hG>9sBpe395j zw)ivyQ-<(1(YcVzr;2WNrA9mcRqLB~>C*^w-`*;2rcsSMldzo7lXNg(CB=;dVI7KE zACqGXTJ|)VBd2}gY_9uKje(RrH2skML1%@c%}$G5J!0qy0|0@ZRkl^eTBdDtubPNAg<5nLiY*JF?zr{(G_kvgJuB!X!kNTx7n?kn7TysW? zI3z(4>$I)=cFE+Z$#}UxIGAUfriwr}P$gpH`ZL_60?O;ZqWGV4J%ZkG)!&iF|Nf>M zZ`Vv8cH<5uIIg{#!|s2Sz95IG;v;h^7zVLW)NEj>zP4nU%D4Zrwd$`gn)m}DtGrxS z-cd^h((iL3t%MeW3EM(2wO-KQGTLRJmR<^9t!LFI=(iS<17FR4aY6OCm|A8r$#e7o8OZC0L72;^OeXUE}jx#=<%rBEjBh5i(=si$I5tm+}pa`3Z zi84nC62&3K&*Kxv)CW8ftf{Bxd!pj1iR5I$_v=DEems(EoOasgr*gM<2+kto@29q9 z3jNg#Eq^vHG)fz~wG-WKjF@KBIBvnD{Yf!uV)eGVqWEX6t#N_Hz5LButp5C$g)F@{ ztDc6>y8FB&>|eNYhqIPs4)zx=n9s(AU7JnSvP)_r?5HF8Qq>h#cbrR0G}TU6oe(gW zu;{%K2GjMI2GTKA{xb-lgi~E6C3mx*$?-;4f>^0M8xTMRxDmu4WFQ<7z%>9h0XVpe zin&_@#ZiEW(MY(00G`~p18z;7Oka4~+3JL$uua6ca{@=hEFpS9m^zWabm3CFK171Mhyd{5w zI)V1I@I?6!$fJKv8%6R%3~Csth-wjWY!cJ7xfN1Gc@# zfT8&~F$bj_$Mo9>Ib~{yxquVb4!8?_&TusWpzzE;$Po@hOc#}XmVtC_mUI1p*n<$F zYX*Q|0C8uW7Bn;ba@G+%9n&c-|gUOIQ7b zZS#Y(VZqOy+B42hpGWtoQs2T=!x6bHH;mPl$#tpLPdEu5d;Zkz)zpwSTY}Q#?K>fa zS5G{qO?+q}`?+h$Pce8d9?`GsS-0Sodh8w^eiN@+K$?cHcP4 z)0#L(+V;I^uq`{?GjeAG354-dk+ajC2CD`EJhOw_M0__0sr`F-Cwf3)L43%4Hdm-u~#z_u+8HP>|Op4Y#%{C2lb10LLkI+q&1it!y;WH&3AC`L1^ZiEKr}2gfd_(T}^wa|* z62?IeDNi8-=?wttM7G2Rpib$RhaZ489u5X=C*Vo>Oz+Wu`v>G0=P1NvT1m?KqLGl3 zH^-+GB5YfqGk3z3lyDO<74h98e_I3-VcuKCa53p>f1d{bhsAu(EGJn@HJ)S)8?j{b;)jNT=e2BzG`AU7KnCAiRo5? zKI3NFcX;;NL;kzzx!2_g0?V%TZkx6nHVw1JF2wkKudtF?O;v48*w`%UvV_A9uE>1H zYRs1Tw~qvUUe9tnzVCQf!O`K^D}t7~O}ee}{`~_-2i)O`w^w&bcUw3bT(bR^&Gc=F zyR8WJsG|4ZPXmG@v8(+{)63Dj?FeF(%Ihy+LCB|+?Dr1@EEaX=f;^`_TEbqp!`rYT zZm;h8z^CGJ1P-;sQfb@}Uo4*xBI5{@OW@F{f;P_IXGw}iwxW;4Ia_5Sl4)V4&Kbi~ zJ`r}oFQf`(LqvQ1J^f<)aw3j&DT`T-|Nz$FfDjMnF>QMRHZqObF61a~+Zo`x8M}V~Cas)BOW3;{L~#l*#EU`REEW0m=z_uY#R#_IBf_T|rhNMX*hVKubhqy;>NaJbi&5m5 zQ)sGDaoocvx2W*hpD6d@>k^UdL~v1JNlEB*HS2^ z-=d@A;{l_j`PK2tbS~Y&Ee@ngX%wiH-Htc~`-yp2nYp3QrTRQMaaTKH3dUlS0&*O#R^QhBo>^^o-lBT(3#p|b zh3D%fBOv(4B-S-2Lqz^4-*j7sYi9(0RS^nhDp3?DbIE!z;DrR?3n@FRskpg_MIh`! zeKw!-18ZAJ;8)iYO{93|{m?%kPR`sqpLYl`vx>DWcwFC=R&7%d^dR7OYUYe&%OGWs~&aNw7(#=?VdH(clU~@H8mo61|<<$`9 zi0aid7rG=@=26vMTb8nU*Blv zo#nU8TYyi76oXk2ZqO)HS_Fv&IK-<0+AMm;IwD+$nDgg<(Y_%XDy zlfYDgkG}OOouF6IuY4OW0;nSszFfs~qM8VMcbch}fecZ>QO_>|HPEq{ZoI4I&~+k_iVTslJuiiC|o#t2`@JSNi6k3%yk`=CI`Gitqu8R zXE(XFd*UC5o9M5>Z`a*Y`C7DmV;fv~63WQ_lvB_W4nQ<4?T)nYq`q50IY}L zpE$}^5+IQ1TaMvm3O)jHG+deh+@Qkq0A}Z4uJWDj#-t6|9zFJ`P;4lYAH=W_zZaod zVeE}_QjpXpYxP@O+@>+}X(-N!Kq(c{+&$HW8ktA6R04-jx5s?BpD=u_Pi5;O1hY<( z(&FNZDqhx?Z<#`kMYh}oJyfvV7Kc>cb>> ziAR4MDZfwM9sV=dzjFL{FCvf?=vt-x$2L6xm^df^cLkQN0p%)Su=vldA_%mh!Knf` zc7kj!h$2k>4F~u>?}9^2XG5<*eF;vpg(m*4;^wWAav1_~CcaX3Kgqo|gZ#r)Yd`sS zQb9JUihsQK)WO2G)<${ay?tmjOR*!yPh#)O7pNDJr(^-PXSo*+_(ky~_~BwQ*#jlk z;^mdD)dMVjrHBGVqlJ(4uidCuZ+KQ_v!myB@_UunyYXK}{ve;#iBqF07l_$>g-5VZ zWpmQa^^Rdkh4|#GKxKDgdYf-I{~DW!VRn7SM31vRHtv~PTbD#d;U+Sq6`#~UfxGBg z&bW1{?@7)rqw-YDc>y&n0$Z!&fSRbh>b=^1pJ1|HBBnscF&a)t98J!~vP_X43_ba{>{6xbYqX6$_@Fe*~@2AXUQ7WVQ zUUJ7Hh)=G?q$j_&rQ|HXw%d;v4yrhCYMdgd^t)mt4I>l}o&o|I1^7uXk-Cc1mAGZ6 z7j2P0eld0;GQpQ*#)dwW&@!Kymsd56X=c~y*LR3t)N|J1opF!yB|yOM0l*4F#Sg|S z^MO$>X7R*O5Fc7*xviLl%{Ul5lRve1rWdH3MD-w!X*hF>sd$^PoC+6e>6rFwtRPFLHrYHMm*Oy7?b2B#j+|1>~=xaRxBpx@9%vIEeRuHE^2${ z2Wi`a-lj~&Q3dE9G1lfSrKr5n`K-h!ousieFjg$-XW~XM^R<%LVFul8z04xNcf#hi zBmQc@s)Ig0iVZUU<+S(|j|@JMM)>l*3y&yeF8liatctl>YSRd*y?9n$S*lb;kGugk z^v^d%y8?p)6%xLv&#pyw#~KI?x&gB)BfSgcF+wzo{I-0V)+8?Uh{{bOPJ^KZd=vO$ z&LfsI9#h@4%kInj)J=>}6~GWy_{x5Hqas10fAjs!1Bs*w1q3^@wtNuU3Gx80FJ~_2 zd<8g}{Be4?_FNY7O=%HGmY*1e%OzukUja<8?~fm3iVQM3<how z6pO=-_XznON}jVwfooQ+%zaI79vKRt}VI}oEie&sfPq|N0LP~_+vaV zgP-a+&NlE+`l0e@VRml!N(S(o{~#cOj3CZE8jI08#+4h^7_$+c+ zjL3pBFw=mVJK5A+yCQzoTmZ!dk~Z*y52SObRg)%?8%FwM}L! zoQUkWs6Tix2ii$IllS^XLHO{a}JjF`QFAEe^<7-1Zoqt z`yJJVRb*rD-H?59&$s}uJMn2`f4*3*1hJ(v@0`zAq*Z990q0?~wqF`g%wn@Gd(cAc zb*Jz^KueepyIAXn5K(5(*T=!_2yZ&=@#1r3N}k{U_Ruk83-CLUb+n|C8ofRm@pkf! zIm|xHbFTpP06aF|fEN<57772~rh#znyK9HVu74gGg23_*cZ5cS!h}H8fGPnG9 zi3NEVcLNZ_Y}}2&-)#gNqrWeOF>|$`F{C-Y22a5Enn!2|jAFjX<85fOy!h*(1i&rp z1j)VxEI$RgWMh9>S?T8=w}^fE?<7`Q55`I?GJGtz?xeVYm4$%QfzOr5wS#7Z1Yz|m z7_P=AjRh!O_#-Hol0oFT-d#@^B#?0f0$e~F<+m-4Qc8gwgX|Z+G8Q5mYyDd*10|W^ zi6DLJFS8APbk)L6B=P5j0SoAHPjRB z>2Wp?h=Ng0f&#j$AvozBK4@C<%eG&AQLe&d>5|p20zWPm{Iz6^+%ND1iE|(dS+D4o zFXE-61}`bC+3lz!Y4X@+{P+dO;=Ndi1002CXj;{R3z7L7Xm07X!or+R_FQfwSrYNl zx?x>wm#3=vK5h8et)*kWsA0a;2nlyKQLciW`^mE(3MkS@yCsJLcJer-_(=XwsZH~| z7^khKInB+jF+cnW>B47zO!S()Tf7eQ8|JBGSuUU{V3^as%0Dc~V_2(;3=5Fksd>6T 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

VKAeP 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 literal 0 HcmV?d00001 diff --git a/web/src/assets/styles/views/budget-center-dialog.css b/web/src/assets/styles/views/budget-center-dialog.css new file mode 100644 index 0000000..bb11be7 --- /dev/null +++ b/web/src/assets/styles/views/budget-center-dialog.css @@ -0,0 +1,349 @@ +.budget-dialog-backdrop, +.budget-dialog-backdrop * { + box-sizing: border-box; +} + +.budget-dialog-backdrop { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + padding: 28px; + background: rgba(15, 23, 42, .52); + backdrop-filter: blur(1px); +} + +.budget-edit-dialog { + width: min(1024px, calc(100vw - 48px)); + max-height: calc(100vh - 56px); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + border-radius: 8px; + background: #fff; + box-shadow: 0 24px 72px rgba(15, 23, 42, .28); + overflow: hidden; +} + +.budget-edit-head { + min-height: 56px; + padding: 0 24px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid #edf1f6; +} + +.budget-edit-head strong { + color: #111827; + font-size: 18px; + font-weight: 800; +} + +.budget-dialog-close { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border: 0; + border-radius: 8px; + background: transparent; + color: #64748b; + font-size: 20px; + cursor: pointer; + transition: background 160ms ease, color 160ms ease; +} + +.budget-dialog-close:hover { + background: #f1f5f9; + color: #0f172a; +} + +.budget-edit-body { + min-height: 0; + padding: 18px 24px 16px; + overflow: auto; +} + +.budget-edit-section + .budget-edit-section { + margin-top: 18px; +} + +.budget-edit-section h3 { + margin: 0 0 12px; + color: #111827; + font-size: 15px; + line-height: 1.35; + font-weight: 800; +} + +.budget-edit-form-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px 28px; +} + +.budget-edit-form-grid label, +.budget-edit-textarea { + min-width: 0; + display: grid; + gap: 7px; + color: #334155; + font-size: 13px; + font-weight: 750; +} + +.budget-edit-form-grid label.required > span::after { + content: "*"; + margin-left: 3px; + color: #ef4444; +} + +.budget-edit-form-grid select, +.budget-edit-textarea textarea, +.budget-edit-table input, +.budget-edit-table select { + width: 100%; + border: 1px solid #dbe4ee; + border-radius: 6px; + background: #fff; + color: #111827; + font-size: 14px; + outline: none; + transition: border-color 160ms ease, box-shadow 160ms ease; +} + +.budget-edit-form-grid select { + height: 38px; + padding: 0 34px 0 12px; +} + +.budget-edit-form-grid select:focus, +.budget-edit-textarea textarea:focus, +.budget-edit-table input:focus, +.budget-edit-table select:focus { + border-color: #10b981; + box-shadow: 0 0 0 3px rgba(16, 185, 129, .12); +} + +.budget-edit-textarea { + position: relative; + margin-top: 14px; +} + +.budget-edit-textarea textarea { + min-height: 86px; + resize: none; + padding: 12px 14px 24px; + line-height: 1.6; +} + +.budget-edit-textarea em { + position: absolute; + right: 12px; + bottom: 9px; + color: #94a3b8; + font-size: 12px; + font-style: normal; + font-weight: 500; +} + +.budget-edit-table-wrap { + border: 1px solid #edf1f6; + border-radius: 8px; + overflow-x: auto; +} + +.budget-edit-table { + width: 100%; + min-width: 760px; + border-collapse: collapse; + table-layout: fixed; +} + +.budget-edit-table th, +.budget-edit-table td { + height: 48px; + padding: 8px 10px; + border-right: 1px solid #edf1f6; + border-bottom: 1px solid #edf1f6; + text-align: center; + vertical-align: middle; +} + +.budget-edit-table th:last-child, +.budget-edit-table td:last-child { + border-right: 0; +} + +.budget-edit-table tbody tr:last-child td { + border-bottom: 0; +} + +.budget-edit-table th { + background: #fbfcfe; + color: #334155; + font-size: 13px; + font-weight: 800; + white-space: nowrap; +} + +.budget-edit-table th i { + color: #ef4444; + font-style: normal; +} + +.budget-edit-table th:nth-child(1) { width: 180px; } +.budget-edit-table th:nth-child(2) { width: 168px; } +.budget-edit-table th:nth-child(3) { width: 120px; } +.budget-edit-table th:nth-child(4) { width: 120px; } +.budget-edit-table th:nth-child(6) { width: 68px; } + +.budget-edit-table input, +.budget-edit-table select { + height: 34px; + padding: 0 10px; + text-align: center; +} + +.budget-edit-table td:nth-child(5) input { + text-align: left; +} + +.budget-row-delete { + width: 32px; + height: 32px; + display: inline-grid; + place-items: center; + border: 0; + border-radius: 8px; + background: transparent; + color: #64748b; + font-size: 18px; + cursor: pointer; +} + +.budget-row-delete:hover { + background: #fef2f2; + color: #dc2626; +} + +.budget-add-row-btn { + height: 28px; + margin-top: 8px; + padding: 0 10px; + display: inline-flex; + align-items: center; + gap: 5px; + border: 1px solid rgba(16, 185, 129, .42); + border-radius: 6px; + background: #fff; + color: #059669; + font-size: 13px; + font-weight: 800; + cursor: pointer; +} + +.budget-edit-total { + height: 42px; + margin-top: 8px; + padding: 0 14px; + display: grid; + grid-template-columns: 120px 1fr; + align-items: center; + border: 1px solid #edf1f6; + border-radius: 8px; + background: #fbfcfe; +} + +.budget-edit-total span, +.budget-edit-total strong { + color: #111827; + font-size: 14px; + font-weight: 800; +} + +.budget-edit-foot { + padding: 18px 24px 20px; + display: flex; + align-items: center; + justify-content: center; + gap: 18px; + border-top: 1px solid #edf1f6; + background: #fff; +} + +.budget-edit-foot button { + height: 40px; + min-width: 156px; + border-radius: 7px; + font-size: 14px; + font-weight: 800; + cursor: pointer; +} + +.budget-edit-cancel { + border: 1px solid #dbe4ee; + background: #fff; + color: #334155; +} + +.budget-edit-draft { + border: 1px solid #10b981; + background: #fff; + color: #059669; +} + +.budget-edit-publish { + border: 0; + background: linear-gradient(135deg, #10b981, #059669); + color: #fff; + box-shadow: 0 10px 24px rgba(5, 150, 105, .20); +} + +.budget-dialog-fade-enter-active, +.budget-dialog-fade-leave-active { + transition: opacity 180ms ease; +} + +.budget-dialog-fade-enter-active .budget-edit-dialog, +.budget-dialog-fade-leave-active .budget-edit-dialog { + transition: transform 200ms ease, opacity 180ms ease; +} + +.budget-dialog-fade-enter-from, +.budget-dialog-fade-leave-to { + opacity: 0; +} + +.budget-dialog-fade-enter-from .budget-edit-dialog, +.budget-dialog-fade-leave-to .budget-edit-dialog { + opacity: 0; + transform: translateY(12px); +} + +@media (max-width: 860px) { + .budget-dialog-backdrop { + align-items: flex-start; + padding: 18px; + } + + .budget-edit-dialog { + width: 100%; + max-height: calc(100vh - 36px); + } + + .budget-edit-form-grid { + grid-template-columns: 1fr; + } + + .budget-edit-foot { + flex-direction: column; + gap: 10px; + } + + .budget-edit-foot button { + width: 100%; + } +} diff --git a/web/src/assets/styles/views/budget-center-view.css b/web/src/assets/styles/views/budget-center-view.css index e1efd90..5445707 100644 --- a/web/src/assets/styles/views/budget-center-view.css +++ b/web/src/assets/styles/views/budget-center-view.css @@ -5,138 +5,245 @@ color: #1f2937; } -.budget-local-head { - min-height: 34px; - display: flex; - align-items: center; -} -.budget-local-head h2 { - margin: 0; - color: #111827; - font-size: 24px; - line-height: 1.2; - font-weight: 800; -} .budget-summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); - border: 1px solid #e5eaf1; - border-radius: 8px; - background: #fff; - overflow: hidden; + gap: 12px; } .budget-summary-card { - min-height: 118px; - padding: 22px 28px; - display: grid; - grid-template-columns: 64px minmax(0, 1fr); - align-items: center; - gap: 18px; - border-right: 1px solid #edf1f6; + --accent: #10b981; + position: relative; + min-height: 112px; + padding: 12px 14px 10px; + display: flex; + flex-direction: column; + border: 1px solid #dbe4ee; + border-left: 3px solid var(--accent); + border-radius: 8px; + background: #fff; + box-shadow: 0 1px 2px rgba(15, 23, 42, .04); + animation: dashboardItemIn 520ms var(--ease) both; + animation-delay: var(--delay, 0ms); + transition: box-shadow 200ms ease, transform 200ms ease; } -.budget-summary-card:last-child { - border-right: 0; +.budget-summary-card:hover { + box-shadow: 0 4px 20px rgba(0, 0, 0, .06); + transform: translateY(-1px); +} + +.budget-summary-card.green { + --accent: #10b981; +} + +.budget-summary-card.blue { + --accent: #3b82f6; +} + +.budget-summary-card.orange { + --accent: #f59e0b; +} + +.budget-summary-head { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; + min-width: 0; } .summary-icon { - width: 54px; - height: 54px; - border-radius: 50%; + width: 26px; + height: 26px; + border-radius: 7px; display: grid; place-items: center; - font-size: 30px; -} - -.summary-icon.green { - background: #e8f7ef; - color: #07965f; -} - -.summary-icon.blue { - background: #edf4ff; - color: #2f7fd7; -} - -.summary-icon.orange { - background: #fff4e5; - color: #df9300; -} - -.budget-summary-card span:not(.summary-icon) { - display: block; - color: #1f2937; + background: color-mix(in srgb, var(--accent) 10%, white); + color: var(--accent); font-size: 14px; - font-weight: 700; + flex: 0 0 auto; + animation: iconPop 560ms var(--ease) both; + animation-delay: calc(var(--delay, 0ms) + 100ms); } -.budget-summary-card strong { +.budget-summary-card .summary-label { display: block; - margin-top: 8px; - color: #111827; - font-size: 24px; - line-height: 1; + min-width: 0; + color: #64748b; + font-size: 11px; font-weight: 500; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.summary-value { + display: block; + min-height: 22px; + margin-bottom: 6px; + color: #0f172a; + font-size: clamp(16px, 1.2vw, 20px); + line-height: 1; + font-weight: 800; font-variant-numeric: tabular-nums; white-space: nowrap; + letter-spacing: 0; +} + +.summary-comparison-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + padding-top: 6px; + border-top: 1px solid #f1f5f9; + min-width: 0; + flex-wrap: wrap; +} + +.comparison-pill { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 2px; + padding: 1px 6px; + border-radius: 4px; + font-size: 11px; + line-height: 1.45; + font-weight: 700; + white-space: nowrap; } -.budget-summary-card em { - display: block; - margin-top: 10px; - color: #8a94a6; - font-size: 13px; +.comparison-pill b { + color: inherit; + font-size: 11px; + font-weight: 600; +} + +.comparison-pill em { font-style: normal; + font-variant-numeric: tabular-nums; +} + +.comparison-pill i { + font-size: 11px; +} + +.comparison-pill.up { + background: rgba(22, 163, 74, .08); + color: #16a34a; +} + +.comparison-pill.down { + background: rgba(239, 68, 68, .08); + color: #dc2626; } .budget-filter-bar { - min-height: 62px; - border: 1px solid #e5eaf1; + border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; - padding: 12px 18px; + padding: 14px 16px; display: flex; align-items: center; - gap: 22px; + justify-content: space-between; + gap: 16px; +} + +.budget-filter-set, +.budget-action-set { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + min-width: 0; } .budget-filter-bar label { display: inline-flex; align-items: center; - gap: 10px; - color: #1f2937; - font-size: 14px; - font-weight: 700; + gap: 8px; + color: #64748b; + font-size: 13px; + font-weight: 750; + white-space: nowrap; } -.budget-filter-bar select, -.budget-table-foot select { - height: 34px; - min-width: 150px; - border: 1px solid #dbe2ec; - border-radius: 5px; +.budget-filter-bar select { + min-height: 38px; + min-width: 128px; + border: 1px solid #d7e0ea; + border-radius: 8px; background: #fff; - color: #1f2937; - padding: 0 34px 0 12px; + color: #334155; + padding: 0 34px 0 14px; font-size: 14px; + font-weight: 750; + transition: border-color 160ms ease, box-shadow 160ms ease, color 160ms ease; +} + +.budget-filter-bar select:hover { + border-color: rgba(16, 185, 129, .32); + color: #0f9f78; +} + +.budget-filter-bar select:focus { + border-color: #10b981; + box-shadow: 0 0 0 3px rgba(16, 185, 129, .14); + outline: none; } .budget-primary-btn { - margin-left: auto; - height: 36px; + min-height: 40px; border: 0; - border-radius: 5px; - background: #0aa66f; + border-radius: 10px; + background: linear-gradient(135deg, #10b981, #059669); color: #fff; padding: 0 18px; display: inline-flex; align-items: center; + justify-content: center; gap: 6px; font-size: 14px; font-weight: 800; + white-space: nowrap; + cursor: pointer; + box-shadow: 0 10px 24px rgba(5, 150, 105, .2); + transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease; +} + +.budget-primary-btn:hover { + transform: translateY(-1px); + box-shadow: 0 14px 28px rgba(5, 150, 105, .24); + filter: saturate(1.02); +} + +.budget-ghost-btn { + min-height: 38px; + border: 1px solid #d7e0ea; + border-radius: 8px; + background: #fff; + color: #334155; + padding: 0 14px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + font-size: 14px; + font-weight: 750; + white-space: nowrap; + cursor: pointer; + transition: border-color 160ms ease, color 160ms ease, box-shadow 160ms ease; +} + +.budget-ghost-btn:hover { + border-color: rgba(16, 185, 129, .32); + color: #0f9f78; + box-shadow: 0 1px 4px rgba(15, 23, 42, .08); } .budget-work-grid { @@ -240,7 +347,7 @@ border-right: 1px solid #edf1f6; color: #273142; font-size: 14px; - text-align: left; + text-align: center; white-space: nowrap; } @@ -259,6 +366,7 @@ width: 96px; display: grid; gap: 6px; + margin: 0 auto; } .budget-rate span { @@ -304,6 +412,7 @@ .budget-row-actions { display: flex; align-items: center; + justify-content: center; gap: 14px; } @@ -324,24 +433,84 @@ gap: 10px; } -.budget-table-foot button { +.budget-page-summary { + color: #64748b; + font-size: 14px; + font-weight: 650; +} + +.budget-pager { + display: inline-flex; + justify-content: center; + gap: 6px; + padding: 4px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #f8fafc; +} + +.budget-pager button { width: 32px; height: 32px; - border: 1px solid #dbe2ec; - border-radius: 5px; - background: #fff; - color: #64748b; -} - -.budget-table-foot button.active { - border-color: #10a873; - color: #10a873; - font-weight: 800; -} - -.budget-table-foot span { - color: #4b5563; + border: 0; + border-radius: 9px; + background: transparent; + color: #334155; font-size: 14px; + font-weight: 800; + transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease; +} + +.budget-pager button:hover:not(.active):not(:disabled) { + background: #fff; + color: #059669; + box-shadow: 0 1px 4px rgba(15, 23, 42, .08); +} + +.budget-pager button.active { + background: #059669; + color: #fff; + box-shadow: 0 8px 16px rgba(5, 150, 105, .20); +} + +.budget-pager button:disabled { + color: #94a3b8; + cursor: not-allowed; +} + +.budget-page-size { + min-height: 38px; + min-width: 112px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + padding: 0 14px; + border: 1px solid #d7e0ea; + border-radius: 10px; + background: #fff; + color: #334155; + font-size: 14px; + font-weight: 750; + white-space: nowrap; + box-shadow: 0 1px 2px rgba(15, 23, 42, .04); + cursor: pointer; + transition: border-color 160ms ease, color 160ms ease; +} + +.budget-page-size:hover { + border-color: rgba(16, 185, 129, .32); + color: #0f9f78; +} + +.budget-page-size select { + appearance: none; + border: 0; + background: transparent; + color: inherit; + font: inherit; + outline: none; + cursor: pointer; } .budget-bottom-grid { @@ -448,6 +617,32 @@ text-align: right; } +@keyframes dashboardItemIn { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes iconPop { + 0% { + opacity: 0; + transform: scale(.82); + } + 70% { + opacity: 1; + transform: scale(1.04); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + @media (max-width: 1280px) { .budget-summary-grid, .budget-bottom-grid { @@ -480,7 +675,24 @@ .budget-filter-bar label, .budget-filter-bar select, - .budget-primary-btn { + .budget-filter-set, + .budget-action-set, + .budget-primary-btn, + .budget-ghost-btn { + width: 100%; + } + + .budget-filter-bar label { + justify-content: space-between; + } + + .budget-table-foot { + justify-content: flex-start; + flex-wrap: wrap; + } + + .budget-pager, + .budget-page-size { width: 100%; } diff --git a/web/src/composables/useNavigation.js b/web/src/composables/useNavigation.js index f62a870..fef72d0 100644 --- a/web/src/composables/useNavigation.js +++ b/web/src/composables/useNavigation.js @@ -3,7 +3,7 @@ import { useRoute, useRouter } from 'vue-router' import { icons } from '../data/icons.js' -export const appViews = ['overview', 'workbench', 'documents', 'budget', 'policies', 'audit', 'employees', 'logs', 'settings'] +export const appViews = ['overview', 'workbench', 'documents', 'budget', 'audit', 'employees', 'policies', 'logs', 'settings'] export const navItems = [ { @@ -38,21 +38,13 @@ export const navItems = [ title: '预算中心', desc: '配置部门、项目及费用类型预算,跟踪申请占用、报销核销与超预算预警。' }, - { - id: 'policies', - label: '制度知识', - navHint: '查看制度与知识库', - icon: icons.file, - title: '制度与知识库', - desc: '统一管理制度文档、检索入口与知识资产。' - }, { id: 'audit', label: '任务规则中心', - navHint: '查看和管理任务规则配置', + navHint: '查看和管理规则配置', icon: icons.skill, title: '任务规则中心', - desc: '集中管理规则文件、外部 MCP 服务与定时任务调度。' + desc: '集中管理财务规则、风险规则、技能与外部 MCP 服务。' }, { id: 'employees', @@ -62,6 +54,14 @@ export const navItems = [ title: '员工与组织管理', desc: '维护员工账号、组织结构与角色权限。' }, + { + id: 'policies', + label: '制度知识', + navHint: '查看制度与知识库', + icon: icons.file, + title: '制度与知识库', + desc: '统一管理制度文档、检索入口与知识资产。' + }, { id: 'logs', label: '日志管理', diff --git a/web/src/composables/useRequests.js b/web/src/composables/useRequests.js index f60417f..fa873ad 100644 --- a/web/src/composables/useRequests.js +++ b/web/src/composables/useRequests.js @@ -17,9 +17,11 @@ const EXPENSE_TYPE_LABELS = { ride_ticket: '乘车', travel_allowance: '出差补贴', entertainment: '业务招待费', + marketing: '市场推广费', office: '办公用品费', meeting: '会务费', training: '培训费', + software: '软件服务费', hotel: '住宿费', transport: '交通费', meal: '业务招待费', diff --git a/web/src/data/icons.js b/web/src/data/icons.js index c469100..d17f57a 100644 --- a/web/src/data/icons.js +++ b/web/src/data/icons.js @@ -5,7 +5,7 @@ export const icons = { workspace: iconPath(''), list: iconPath(''), approval: iconPath(''), - budget: iconPath(''), + budget: iconPath(''), archive: iconPath(''), file: iconPath(''), skill: iconPath(''), diff --git a/web/src/utils/accessControl.js b/web/src/utils/accessControl.js index 2d7f7f3..4f56239 100644 --- a/web/src/utils/accessControl.js +++ b/web/src/utils/accessControl.js @@ -33,13 +33,17 @@ function normalizedRoleCodes(user) { : [] } -export function isManagerUser(user) { - return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager') -} - -export function isFinanceUser(user) { - return normalizedRoleCodes(user).includes('finance') -} +export function isManagerUser(user) { + return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager') +} + +export function isPlatformAdminUser(user) { + return Boolean(user?.isAdmin) +} + +export function isFinanceUser(user) { + return normalizedRoleCodes(user).includes('finance') +} export function isExecutiveUser(user) { return normalizedRoleCodes(user).includes('executive') diff --git a/web/src/utils/budgetOntology.js b/web/src/utils/budgetOntology.js new file mode 100644 index 0000000..eaac9f1 --- /dev/null +++ b/web/src/utils/budgetOntology.js @@ -0,0 +1,199 @@ +export const BUDGET_ONTOLOGY_FIELDS = [ + { + key: 'budget_period', + label: '预算周期', + scope: 'budget_header', + required: true, + aliases: ['预算周期', '预算期间', '年度', '季度', '月份'] + }, + { + key: 'department', + label: '所属部门', + scope: 'budget_header', + required: true, + aliases: ['所属部门', '预算部门', '部门'] + }, + { + key: 'cost_center', + label: '成本中心', + scope: 'budget_header', + required: true, + aliases: ['成本中心', '成本中心编码'] + }, + { + key: 'budget_owner', + label: '预算负责人', + scope: 'budget_header', + required: true, + aliases: ['预算负责人', '负责人', '编制人'] + }, + { + key: 'budget_version', + label: '预算版本', + scope: 'budget_header', + required: true, + aliases: ['预算版本', '版本'] + }, + { + key: 'budget_status', + label: '预算状态', + scope: 'budget_header', + required: true, + aliases: ['预算状态', '状态'] + }, + { + key: 'budget_description', + label: '预算说明', + scope: 'budget_header', + required: false, + aliases: ['预算说明', '编制说明', '说明'] + }, + { + key: 'budget_subject', + label: '预算科目', + scope: 'budget_detail', + required: true, + aliases: ['预算科目', '费用类型', '费用科目'] + }, + { + key: 'budget_amount', + label: '预算金额', + scope: 'budget_detail', + required: true, + aliases: ['预算金额', '预算额度', '预算总额'] + }, + { + key: 'reserved_amount', + label: '已占用', + scope: 'budget_execution', + required: false, + aliases: ['已占用', '已预占', '占用金额'] + }, + { + key: 'consumed_amount', + label: '已发生', + scope: 'budget_execution', + required: false, + aliases: ['已发生', '已核销', '已消耗', '已使用'] + }, + { + key: 'available_amount', + label: '剩余可用', + scope: 'budget_execution', + required: false, + aliases: ['剩余可用', '可用余额', '剩余预算', '可用预算'] + }, + { + key: 'warning_threshold', + label: '预警线', + scope: 'budget_control', + required: true, + aliases: ['预警线', '预警阈值', '预算预警'] + }, + { + key: 'control_action', + label: '控制动作', + scope: 'budget_control', + required: true, + aliases: ['控制动作', '管控动作', '超预算控制'] + }, + { + key: 'budget_remark', + label: '备注', + scope: 'budget_detail', + required: false, + aliases: ['备注', '说明'] + } +] + +export const BUDGET_FIELD_KEYS = Object.freeze( + BUDGET_ONTOLOGY_FIELDS.reduce((result, field) => { + result[field.key] = field.key + return result + }, {}) +) + +export const BUDGET_STATUS_OPTIONS = ['编制中', '已发布', '已冻结'] +export const BUDGET_WARNING_OPTIONS = ['60%', '70%', '80%', '90%'] +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: 'hotel', label: '住宿费' }, + { value: 'transport', label: '交通费' }, + { value: 'meal', label: '业务招待费' }, + { value: 'meeting', label: '会务费' }, + { value: 'marketing', label: '市场推广费' }, + { value: 'office', label: '办公用品费' }, + { value: 'training', label: '培训费' }, + { value: 'software', label: '软件服务费' }, + { value: 'communication', label: '通讯费' }, + { value: 'welfare', label: '福利费' } +]) + +const BUDGET_EXPENSE_TYPE_BY_CODE = Object.freeze( + BUDGET_EXPENSE_TYPE_OPTIONS.reduce((result, item) => { + result[item.value] = item + return result + }, {}) +) + +export function resolveBudgetExpenseTypeLabel(code, fallback = '') { + return BUDGET_EXPENSE_TYPE_BY_CODE[String(code || '').trim()]?.label || fallback +} + +export function formatBudgetPeriod(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}` +} + +export function buildBudgetOntologyContext({ form = {}, rows = [], departments = [] } = {}) { + const department = departments.find((item) => item.code === form.departmentCode) || {} + const budgetYear = + String(form.budgetYear || '').replace(/[^\d]/g, '') || + String(form.budgetPeriod || '').replace(/[^\d]/g, '').slice(0, 4) || + '2026' + const budgetQuarter = BUDGET_QUARTER_OPTIONS.includes(String(form.budgetQuarter || '').trim()) + ? String(form.budgetQuarter || '').trim() + : BUDGET_QUARTER_OPTIONS[0] + const budgetPeriod = form.budgetYear || form.budgetQuarter + ? formatBudgetPeriod(budgetYear, budgetQuarter) + : form.budgetPeriod || formatBudgetPeriod(budgetYear, budgetQuarter) + return { + document_type: 'budget_plan', + entry_source: 'budget_center', + conversation_scenario: 'budget', + budget_fields: BUDGET_ONTOLOGY_FIELDS, + budget_header: { + budget_period: budgetPeriod, + budget_year: budgetYear, + budget_quarter: budgetQuarter, + department: department.name || '', + department_code: form.departmentCode || '', + cost_center: form.costCenter || department.costCenter || '', + budget_owner: form.budgetOwner || '', + budget_version: form.budgetVersion || '', + budget_status: form.budgetStatus || '', + budget_description: form.budgetDescription || '' + }, + budget_details: rows.map((row) => { + const code = String(row.budgetSubjectCode || '').trim() + const option = BUDGET_EXPENSE_TYPE_BY_CODE[code] + const label = option?.label || row.budgetSubject || '' + return { + budget_subject: label, + budget_subject_code: option?.value || code, + expense_type: option?.value || code, + expense_type_label: label, + budget_amount: row.budgetAmount || '', + warning_threshold: row.warningThreshold || '', + control_action: row.controlAction || '', + budget_remark: row.budgetRemark || '' + } + }) + } +} diff --git a/web/src/utils/expenseApplicationOntology.js b/web/src/utils/expenseApplicationOntology.js index b75312e..79b3275 100644 --- a/web/src/utils/expenseApplicationOntology.js +++ b/web/src/utils/expenseApplicationOntology.js @@ -5,8 +5,10 @@ const EXPENSE_TYPE_LABELS = { meal: '业务招待费', entertainment: '业务招待费', meeting: '会务费', + marketing: '市场推广费', office: '办公用品费', training: '培训费', + software: '软件服务费', communication: '通讯费', welfare: '福利费', other: '其他费用' diff --git a/web/src/utils/reimbursementTextInference.js b/web/src/utils/reimbursementTextInference.js index 4058d80..c8aafeb 100644 --- a/web/src/utils/reimbursementTextInference.js +++ b/web/src/utils/reimbursementTextInference.js @@ -27,8 +27,10 @@ const DEFAULT_EXPENSE_TYPE_LABELS = { meal: '业务招待费', meeting: '会务费', entertainment: '业务招待费', + marketing: '市场推广费', office: '办公用品费', training: '培训费', + software: '软件服务费', communication: '通讯费', welfare: '福利费', other: '其他费用' diff --git a/web/src/views/AuditView.vue b/web/src/views/AuditView.vue index f05099d..462316e 100644 --- a/web/src/views/AuditView.vue +++ b/web/src/views/AuditView.vue @@ -261,7 +261,7 @@

基本信息

-

这条规则的业务域、风险等级、创建时间、上线状态和审核历史。

+

这条规则的业务域、风险等级、创建时间、上线状态和最近操作。

@@ -273,6 +273,10 @@ 适用场景 {{ selectedSkill.riskCategory || selectedSkill.scope || '-' }}
+
+ 业务环节 + {{ selectedSkill.businessStageLabel || '-' }} +
风险等级 @@ -288,17 +292,17 @@
是否上线 - + - {{ selectedSkill.isOnlineLabel || '否' }} + {{ selectedSkill.isOnlineLabel || '待上线' }}
- 是否启用 + 规则状态 - - {{ selectedSkill.isEnabledLabel || '-' }} + + {{ selectedSkill.status || '-' }}
@@ -329,6 +333,10 @@ 上线时间 {{ selectedSkill.publishedAt || '-' }}
+
+ 最后操作 + {{ selectedSkill.lastOperationLabel || '-' }} +
使用字段 {{ selectedSkill.riskRuleFieldSummary || '-' }} @@ -623,17 +631,6 @@
+ + + diff --git a/web/src/views/scripts/AuditView.js b/web/src/views/scripts/AuditView.js index 6f6357d..777b421 100644 --- a/web/src/views/scripts/AuditView.js +++ b/web/src/views/scripts/AuditView.js @@ -31,7 +31,7 @@ import { updateAgentAsset } from '../../services/agentAssets.js' import { loadOnlyOfficeApi } from '../../services/onlyoffice.js' -import { isFinanceUser, isManagerUser } from '../../utils/accessControl.js' +import { isFinanceUser, isManagerUser, isPlatformAdminUser } from '../../utils/accessControl.js' import { buildOnlyOfficeEditorConfig } from './onlyOfficePreviewConfig.js' import { buildReviewNote, @@ -69,6 +69,7 @@ import { } from './auditViewModel.js' import { createDefaultRiskRuleForm, + RISK_RULE_BUSINESS_STAGE_OPTIONS, RISK_RULE_EXPENSE_CATEGORY_OPTIONS } from './auditViewRiskRuleModel.js' @@ -144,11 +145,11 @@ export default { financialRules: [], riskRules: [], skills: [], - mcp: [], - tasks: [] + mcp: [] }) - const isAdmin = computed(() => isManagerUser(currentUser.value)) + const isAdmin = computed(() => isPlatformAdminUser(currentUser.value)) + const isRuleManager = computed(() => isManagerUser(currentUser.value)) const isFinance = computed(() => isFinanceUser(currentUser.value)) const activeMeta = computed(() => TAB_META[activeType.value]) const activeTabLabel = computed(() => activeMeta.value.label) @@ -162,7 +163,7 @@ export default { const showVersionColumn = computed(() => activeMeta.value.showVersionColumn !== false) const showStatusColumn = computed(() => activeMeta.value.showStatusColumn !== false) const showOnlineColumn = computed(() => false) - const showEnabledColumn = computed(() => activeType.value === 'riskRules') + const showEnabledColumn = computed(() => false) const selectedSkillIsRule = computed(() => selectedSkill.value?.type === 'rules') const selectedSkillUsesSpreadsheet = computed( () => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesSpreadsheetRule) @@ -171,6 +172,9 @@ export default { () => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesJsonRiskRule) ) const canManageSelected = computed( + () => isRuleManager.value && Boolean(selectedSkill.value) && !selectedSkill.value?.isPreviewMock + ) + const canAdminOperateSelected = computed( () => isAdmin.value && Boolean(selectedSkill.value) && !selectedSkill.value?.isPreviewMock ) const canEditSelected = computed( @@ -180,7 +184,7 @@ export default { (isAdmin.value || isFinance.value) ) const canCreateRiskRule = computed( - () => activeType.value === 'riskRules' && (isAdmin.value || isFinance.value) && !detailBusy.value + () => activeType.value === 'riskRules' && isRuleManager.value && !detailBusy.value ) const latestRiskRuleTestSummary = computed(() => selectedSkill.value?.latestTestSummary || null) const riskRuleTestPassed = computed(() => Boolean(latestRiskRuleTestSummary.value?.test_passed)) @@ -196,27 +200,20 @@ export default { const canOpenRiskRuleTest = computed( () => selectedSkillUsesJsonRisk.value && - canEditSelected.value && + canAdminOperateSelected.value && Boolean(selectedSkill.value?.id) && !riskRuleGenerationBusy.value && - !riskRuleGenerationFailed.value && - !detailBusy.value + !riskRuleGenerationFailed.value ) const canDeleteRiskRule = computed( () => selectedSkillUsesJsonRisk.value && - canEditSelected.value && + canAdminOperateSelected.value && Boolean(selectedSkill.value?.id) && - !normalizeText(selectedSkill.value?.publishedVersion).replace('-', '') && - !detailBusy.value + !normalizeText(selectedSkill.value?.publishedVersion).replace('-', '') ) const canOpenRiskRuleReviewSubmit = computed( - () => - selectedSkillUsesJsonRisk.value && - canSubmitReview.value && - !riskRuleInReview.value && - !riskRuleGenerationBusy.value && - !riskRuleGenerationFailed.value + () => false ) const canSubmitRiskRuleReview = computed( () => @@ -224,17 +221,14 @@ export default { riskRuleTestPassed.value ) const canReturnRiskRule = computed( - () => selectedSkillUsesJsonRisk.value && canManageSelected.value && riskRuleInReview.value + () => false ) const canPublishRiskRule = computed( () => - selectedSkillUsesJsonRisk.value && - canManageSelected.value && - riskRuleInReview.value && - riskRuleTestPassed.value + false ) const canToggleRiskRuleEnabled = computed( - () => selectedSkillUsesJsonRisk.value && canManageSelected.value && !detailBusy.value + () => selectedSkillUsesJsonRisk.value && canManageSelected.value ) const riskRuleCreateBusy = computed(() => actionState.value === 'generate-risk-rule') const canEditMarkdown = computed(() => canEditSelected.value && selectedSkillIsRule.value) @@ -242,7 +236,11 @@ export default { () => selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion ) const canSubmitReview = computed( - () => canEditSelected.value && selectedSkillIsRule.value && isDisplayingWorkingVersion.value + () => + !selectedSkillUsesJsonRisk.value && + canEditSelected.value && + selectedSkillIsRule.value && + isDisplayingWorkingVersion.value ) const hasReviewSubmitReviewers = computed(() => reviewSubmitReviewerOptions.value.length > 0) const canReviewSelected = computed( @@ -370,7 +368,7 @@ export default { ) const showStatusFilter = computed(() => true) const showOnlineFilter = computed(() => false) - const showEnabledFilter = computed(() => activeType.value === 'riskRules') + const showEnabledFilter = computed(() => false) const selectedRiskScenarioLabel = computed( () => RISK_SCENARIO_OPTIONS.find((item) => item.value === selectedRiskScenario.value)?.label || @@ -646,6 +644,7 @@ export default { const detail = await generateRiskRuleAsset( { business_domain: 'expense', + business_stage: riskRuleCreateForm.value.business_stage, expense_category: riskRuleCreateForm.value.expense_category, rule_title: ruleTitle, requires_attachment: Boolean(riskRuleCreateForm.value.requires_attachment), @@ -1007,8 +1006,13 @@ export default { } async function loadAssets(options = {}) { - loading.value = true - errorMessage.value = '' + const shouldShowLoading = !options.silent && !options.background + if (shouldShowLoading) { + loading.value = true + } + if (!options.silent) { + errorMessage.value = '' + } try { const payload = await fetchAgentAssets({ assetType: activeMeta.value.assetType }) @@ -1037,6 +1041,9 @@ export default { } } } catch (error) { + if (options.silent || options.background) { + return + } if (activeMeta.value.assetType === 'rule') { assetBuckets.value = { ...assetBuckets.value, @@ -1056,12 +1063,14 @@ export default { toast(errorMessage.value) } } finally { - loading.value = false + if (shouldShowLoading) { + loading.value = false + } } } async function refreshCurrentAssets() { - await loadAssets({ force: true, silent: true }) + await loadAssets({ force: true, silent: true, background: true }) } async function loadSelectedAssetDetail(assetId) { @@ -1110,6 +1119,39 @@ export default { } } + function mergeSelectedRuleLifecycle(detail) { + if (!selectedSkill.value || !detail) { + return + } + const next = buildDetailViewModel(detail, runs.value) + selectedSkill.value = { + ...selectedSkill.value, + status: next.status, + statusValue: next.statusValue, + statusTone: next.statusTone, + publishedVersion: next.publishedVersion, + workingVersion: next.workingVersion, + currentVersion: next.currentVersion, + displayVersion: next.displayVersion, + reviewer: next.reviewer, + publisher: next.publisher, + publishedAt: next.publishedAt, + isOnlineValue: next.isOnlineValue, + isOnlineLabel: next.isOnlineLabel, + isOnlineTone: next.isOnlineTone, + isEnabledValue: next.isEnabledValue, + isEnabledLabel: next.isEnabledLabel, + isEnabledTone: next.isEnabledTone, + latestTestSummary: next.latestTestSummary, + lastOperationLabel: next.lastOperationLabel, + lastOperationTone: next.lastOperationTone, + publishMeta: next.publishMeta, + publishState: next.publishState, + updatedAt: next.updatedAt, + configJson: next.configJson + } + } + async function loadRiskRuleJson(assetId) { if (!assetId || !selectedSkill.value?.usesJsonRiskRule) { return @@ -1525,6 +1567,9 @@ export default { } function openRiskRuleTestDialog() { + if (detailBusy.value) { + return + } if (!canOpenRiskRuleTest.value) { if (!selectedSkill.value?.id) { toast('规则详情还没有加载完成,请稍后再测试。') @@ -1544,7 +1589,8 @@ export default { } await refreshCurrentAssets() if (selectedSkill.value?.id) { - await loadSelectedAssetDetail(selectedSkill.value.id) + const detail = await fetchAgentAssetDetail(selectedSkill.value.id) + mergeSelectedRuleLifecycle(detail) } } @@ -1659,15 +1705,15 @@ export default { return } const assetId = selectedSkill.value.id - const nextEnabled = !selectedSkill.value.isEnabledValue + const nextEnabled = !selectedSkill.value.isOnlineValue actionState.value = 'toggle-risk-rule-enabled' try { - await setRiskRuleAssetEnabled(assetId, nextEnabled, { actor: resolveActor() }) + const detail = await setRiskRuleAssetEnabled(assetId, nextEnabled, { actor: resolveActor() }) + mergeSelectedRuleLifecycle(detail) await refreshCurrentAssets() - await loadSelectedAssetDetail(assetId) - toast(nextEnabled ? '风险规则已启用。' : '风险规则已停用,不会进入业务扫描。') + toast(nextEnabled ? '风险规则已上线。' : '风险规则已下线,不会进入业务扫描。') } catch (error) { - toast(error?.message || '风险规则启用状态更新失败,请稍后重试。') + toast(error?.message || '风险规则上线状态更新失败,请稍后重试。') } finally { actionState.value = '' } @@ -1851,6 +1897,7 @@ export default { riskRuleReturnOpen, riskRulePublishOpen, riskRuleReturnNote, + riskRuleBusinessStageOptions: RISK_RULE_BUSINESS_STAGE_OPTIONS, riskRuleExpenseCategoryOptions: RISK_RULE_EXPENSE_CATEGORY_OPTIONS, showReviewNote, spreadsheetUploadInput, diff --git a/web/src/views/scripts/BudgetCenterView.js b/web/src/views/scripts/BudgetCenterView.js index 26feb31..17f222f 100644 --- a/web/src/views/scripts/BudgetCenterView.js +++ b/web/src/views/scripts/BudgetCenterView.js @@ -1,7 +1,18 @@ -import { computed, onMounted, ref } from 'vue' +import { computed, onMounted, ref, watch } from 'vue' import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue' import { fetchEmployeeMeta } from '../../services/employees.js' +import { + BUDGET_CONTROL_ACTION_OPTIONS, + BUDGET_EXPENSE_TYPE_OPTIONS, + BUDGET_QUARTER_OPTIONS, + BUDGET_STATUS_OPTIONS, + BUDGET_WARNING_OPTIONS, + BUDGET_YEAR_OPTIONS, + buildBudgetOntologyContext, + formatBudgetPeriod, + resolveBudgetExpenseTypeLabel +} from '../../utils/budgetOntology.js' const FALLBACK_DEPARTMENTS = [ { code: 'MARKET-DEPT', name: '市场部', costCenter: 'CC-4100' }, @@ -12,13 +23,34 @@ const FALLBACK_DEPARTMENTS = [ { code: 'PRESIDENT-OFFICE', name: '总裁办', costCenter: 'CC-1000' } ] -const EXPENSE_BLUEPRINTS = [ - { expenseType: '市场推广费', total: 500000, used: 186400, occupied: 120000, warning: 80, action: '提醒' }, - { expenseType: '差旅费', total: 600000, used: 242300, occupied: 150000, warning: 80, action: '提醒' }, - { expenseType: '办公费', total: 300000, used: 68500, occupied: 60000, warning: 70, action: '正常' }, - { expenseType: '培训费', total: 200000, used: 42300, occupied: 20000, warning: 70, action: '正常' }, - { expenseType: '软件服务费', total: 600000, used: 249500, occupied: 240800, warning: 80, action: '管控' } -] +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: '提醒' } +} + +const DEFAULT_EXPENSE_BUDGET = { + total: 100000, + used: 0, + occupied: 0, + warning: 70, + action: '正常' +} + +const EXPENSE_BLUEPRINTS = BUDGET_EXPENSE_TYPE_OPTIONS.map((option) => ({ + ...DEFAULT_EXPENSE_BUDGET, + ...EXPENSE_BUDGET_SEED[option.value], + budgetSubjectCode: option.value, + expenseType: option.label +})) const currency = (value) => Number(value || 0).toLocaleString('zh-CN', { @@ -26,14 +58,29 @@ const currency = (value) => maximumFractionDigits: 2 }) +const comparison = (value, direction) => ({ + value, + tone: direction === 'down' ? 'down' : 'up', + icon: direction === 'down' ? 'mdi mdi-arrow-down' : 'mdi mdi-arrow-up' +}) + +const parseBudgetAmount = (value) => Number(String(value || '').replace(/[^\d.-]/g, '')) || 0 +const makeBudgetRowId = () => `budget-row-${Date.now()}-${Math.random().toString(16).slice(2)}` +const BUDGET_PAGE_SIZE_OPTIONS = [5, 10] + function buildDepartmentRows(departmentCode) { - const seed = Array.from(String(departmentCode || '')).reduce((sum, char) => sum + char.charCodeAt(0), 0) + const seed = Array.from(String(departmentCode || '')).reduce( + (sum, char) => sum + char.charCodeAt(0), + 0 + ) const factor = 0.88 + (seed % 18) / 100 return EXPENSE_BLUEPRINTS.map((item, index) => { const totalAmount = Math.round(item.total * factor) const usedAmount = Math.round(item.used * (0.9 + ((seed + index) % 12) / 100)) - const occupiedAmount = Math.round(item.occupied * (0.92 + ((seed + index * 3) % 10) / 100)) + const occupiedAmount = Math.round( + item.occupied * (0.92 + ((seed + index * 3) % 10) / 100) + ) const leftAmount = Math.max(totalAmount - usedAmount - occupiedAmount, 0) const rate = Number((((usedAmount + occupiedAmount) / totalAmount) * 100).toFixed(2)) @@ -80,18 +127,36 @@ export default { const activeDepartmentCode = ref(FALLBACK_DEPARTMENTS[0].code) const departmentKeyword = ref('') const filters = ref({ - period: '2026年度', + year: '2026', + quarter: 'Q1', expenseType: '全部', status: '全部' }) + const budgetPage = ref(1) + const budgetPageSize = ref(5) + const budgetEditOpen = ref(false) + const budgetEditForm = ref({ + budgetYear: '2026', + budgetQuarter: 'Q1', + budgetPeriod: '2026年Q1', + departmentCode: FALLBACK_DEPARTMENTS[0].code, + costCenter: FALLBACK_DEPARTMENTS[0].costCenter, + budgetOwner: '张晓明', + budgetVersion: 'V1.0(初始版本)', + budgetStatus: '编制中', + budgetDescription: '' + }) + const budgetEditRows = ref([]) 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 visibleBudgetRows = computed(() => + const departmentRows = computed(() => + buildDepartmentRows(activeDepartment.value?.code || activeDepartmentCode.value) + ) + const filteredBudgetRows = computed(() => departmentRows.value .filter((row) => filters.value.expenseType === '全部' || row.expenseType === filters.value.expenseType) .filter((row) => { @@ -101,6 +166,21 @@ export default { return row.rateTone === 'ok' }) ) + const totalBudgetRows = computed(() => filteredBudgetRows.value.length) + const totalBudgetPages = computed(() => + Math.max(1, Math.ceil(totalBudgetRows.value / Number(budgetPageSize.value || 5))) + ) + const currentBudgetPage = computed(() => + Math.min(Math.max(1, budgetPage.value), totalBudgetPages.value) + ) + const budgetPageNumbers = computed(() => + Array.from({ length: totalBudgetPages.value }, (_, index) => index + 1) + ) + const visibleBudgetRows = computed(() => { + const pageSize = Number(budgetPageSize.value || 5) + const start = (currentBudgetPage.value - 1) * pageSize + return filteredBudgetRows.value.slice(start, start + pageSize) + }) const totals = computed(() => { const rows = departmentRows.value @@ -119,30 +199,34 @@ export default { { label: '预算总额', value: `¥${currency(totals.value.total)}`, - note: '本年累计', + yoy: comparison('+8.42%', 'up'), + mom: comparison('+2.16%', 'up'), tone: 'green', icon: 'mdi mdi-wallet-outline' }, { label: '已发生', value: `¥${currency(totals.value.used)}`, - note: `占比 ${((totals.value.used / totals.value.total) * 100).toFixed(2)}%`, + yoy: comparison('+12.68%', 'up'), + mom: comparison('+4.35%', 'up'), tone: 'blue', icon: 'mdi mdi-chart-line' }, { label: '已占用', value: `¥${currency(totals.value.occupied)}`, - note: `占比 ${((totals.value.occupied / totals.value.total) * 100).toFixed(2)}%`, + yoy: comparison('+6.37%', 'up'), + mom: comparison('-1.84%', 'down'), tone: 'orange', icon: 'mdi mdi-briefcase-check-outline' }, { label: '剩余可用', value: `¥${currency(totals.value.left)}`, - note: `占比 ${((totals.value.left / totals.value.total) * 100).toFixed(2)}%`, + yoy: comparison('-3.26%', 'down'), + mom: comparison('-2.08%', 'down'), tone: 'green', - icon: 'mdi mdi-currency-cny' + icon: 'mdi mdi-cash' } ]) @@ -170,6 +254,103 @@ export default { ) const trendData = computed(() => buildTrendData(departmentRows.value)) + const budgetEditTotal = computed(() => + currency( + budgetEditRows.value.reduce( + (sum, row) => sum + parseBudgetAmount(row.budgetAmount), + 0 + ) + ) + ) + const budgetOntologyContext = computed(() => + buildBudgetOntologyContext({ + form: budgetEditForm.value, + rows: budgetEditRows.value, + departments: departments.value + }) + ) + + function buildEditableRows() { + return departmentRows.value.map((row) => ({ + id: makeBudgetRowId(), + budgetSubject: row.expenseType, + budgetSubjectCode: row.budgetSubjectCode || '', + budgetAmount: currency(row.totalAmount), + warningThreshold: `${row.warning}%`, + controlAction: row.action, + budgetRemark: `${row.expenseType}相关费用` + })) + } + + 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] + ) + } + + function syncBudgetRowSubject(row) { + row.budgetSubject = resolveBudgetExpenseTypeLabel(row.budgetSubjectCode, row.budgetSubject) + } + + function openBudgetEditDialog() { + const department = activeDepartment.value + const budgetPeriod = formatBudgetPeriod(filters.value.year, filters.value.quarter) + budgetEditForm.value = { + budgetYear: filters.value.year, + budgetQuarter: filters.value.quarter, + budgetPeriod, + departmentCode: department?.code || activeDepartmentCode.value, + costCenter: department?.costCenter || '', + budgetOwner: '张晓明', + budgetVersion: 'V1.0(初始版本)', + budgetStatus: '编制中', + budgetDescription: `${department?.name || '当前部门'}2026年度预算编制,用于指导费用支出及控制成本,确保资源合理使用。` + } + budgetEditRows.value = buildEditableRows() + budgetEditOpen.value = true + } + + function closeBudgetEditDialog() { + budgetEditOpen.value = false + } + + function addBudgetDetailRow() { + const option = resolveNextExpenseTypeOption() + budgetEditRows.value.push({ + id: makeBudgetRowId(), + budgetSubject: option.label, + budgetSubjectCode: option.value, + budgetAmount: '0.00', + warningThreshold: '70%', + controlAction: '正常', + budgetRemark: '' + }) + } + + function removeBudgetDetailRow(rowId) { + if (budgetEditRows.value.length <= 1) return + budgetEditRows.value = budgetEditRows.value.filter((row) => row.id !== rowId) + } + + function goToBudgetPage(page) { + budgetPage.value = Math.min(Math.max(Number(page) || 1, 1), totalBudgetPages.value) + } + + function changeBudgetPage(direction) { + goToBudgetPage(currentBudgetPage.value + direction) + } + + function saveBudgetDraft() { + budgetEditForm.value.budgetStatus = '编制中' + closeBudgetEditDialog() + } + + function publishBudget() { + budgetEditForm.value.budgetStatus = '已发布' + closeBudgetEditDialog() + } async function loadDepartments() { try { @@ -198,19 +379,65 @@ export default { void loadDepartments() }) + watch( + [ + activeDepartmentCode, + budgetPageSize, + () => filters.value.year, + () => filters.value.quarter, + () => filters.value.expenseType, + () => filters.value.status + ], + () => { + budgetPage.value = 1 + } + ) + + watch(totalBudgetPages, (pages) => { + if (budgetPage.value > pages) { + budgetPage.value = pages + } + }) + return { activeDepartmentCode, activeDepartmentName, + addBudgetDetailRow, + budgetEditForm, + budgetEditOpen, + budgetEditRows, + budgetEditTotal, budgetMetrics, + budgetOntologyContext, + budgetPage: currentBudgetPage, + budgetPageNumbers, + budgetPageSize, + budgetPageSizeOptions: BUDGET_PAGE_SIZE_OPTIONS, + closeBudgetEditDialog, + controlActionOptions: BUDGET_CONTROL_ACTION_OPTIONS, + changeBudgetPage, departmentKeyword, + departments, + expenseTypeOptions: BUDGET_EXPENSE_TYPE_OPTIONS, expenseTypes: ['全部', ...EXPENSE_BLUEPRINTS.map((item) => item.expenseType)], filters, - periods: ['2026年度', '2026年Q2', '2026年5月'], + openBudgetEditDialog, + quarters: BUDGET_QUARTER_OPTIONS, + publishBudget, + removeBudgetDetailRow, + saveBudgetDraft, + statusOptions: BUDGET_STATUS_OPTIONS, statuses: ['全部', '正常', '预警', '管控'], + syncBudgetRowSubject, + goToBudgetPage, + totalBudgetPages, + totalBudgetRows, trendData, visibleBudgetRows, visibleDepartments, - warnings + warningOptions: BUDGET_WARNING_OPTIONS, + warnings, + years: BUDGET_YEAR_OPTIONS } } } diff --git a/web/src/views/scripts/auditViewMetadata.js b/web/src/views/scripts/auditViewMetadata.js index e73becb..5065786 100644 --- a/web/src/views/scripts/auditViewMetadata.js +++ b/web/src/views/scripts/auditViewMetadata.js @@ -56,23 +56,6 @@ export const TYPE_META = { version: '当前版本', metric: '超时配置' } - }, - tasks: { - assetType: 'task', - label: '任务', - typeLabel: '任务', - createButtonLabel: '任务已接入', - hintText: '任务页签已接到真实资产 API,可查看调度周期、执行 Agent 和最近执行结果。', - searchPlaceholder: '搜索任务名称、编码或负责人', - tableColumns: { - name: '任务名称', - category: '业务域', - owner: '负责人', - scope: '适用场景', - runtime: '调度周期', - version: '当前版本', - metric: '执行 Agent' - } } } @@ -113,20 +96,15 @@ export const TAB_META = { ...TYPE_META.mcp, typeKey: 'mcp', badgeTone: 'amber' - }, - tasks: { - ...TYPE_META.tasks, - typeKey: 'tasks', - badgeTone: 'violet' } } export const STATUS_META = { generating: { label: '生成中', tone: 'info' }, - draft: { label: '草稿中', tone: 'draft' }, + draft: { label: '待上线', tone: 'draft' }, review: { label: '待审核', tone: 'warning' }, active: { label: '已上线', tone: 'success' }, - disabled: { label: '已停用', tone: 'disabled' }, + disabled: { label: '已下线', tone: 'disabled' }, failed: { label: '生成失败', tone: 'danger' } } @@ -230,34 +208,16 @@ export const DETAIL_TITLES = { historyDesc: '最近版本记录', publishTitle: '服务状态', publishDesc: 'MCP 资产已接入规则中心,但真实外部调用仍以后续链路集成为准。' - }, - tasks: { - configTitle: '任务配置', - configDesc: '展示调度周期、执行 Agent 和任务编码。', - detailTitle: '任务结构', - detailDesc: '按调度计划、目标场景和运行结果组织任务信息。', - outputTitle: '运行要求', - outputDesc: '任务详情重点展示调度 Agent、最近运行结果和运行日志入口。', - ruleListTitle: '运行要求', - checkListTitle: '最近执行', - triggerTitle: '适用场景', - triggerDesc: '当前任务覆盖的业务场景', - toolTitle: '最近调用', - toolDesc: '根据 AgentRun 中的最近执行记录回显任务运行情况', - historyTitle: '版本历史', - historyDesc: '最近版本记录', - publishTitle: '调度状态', - publishDesc: '任务资产已接入规则中心,后续 Day 4 运行时会继续消费这些配置。' } } export const STATUS_OPTIONS = [ { value: '', label: '全部状态' }, { value: 'generating', label: '生成中' }, - { value: 'draft', label: '草稿中' }, + { value: 'draft', label: '待上线' }, { value: 'review', label: '待审核' }, { value: 'active', label: '已上线' }, - { value: 'disabled', label: '已停用' }, + { value: 'disabled', label: '已下线' }, { value: 'failed', label: '生成失败' } ] diff --git a/web/src/views/scripts/auditViewModel.js b/web/src/views/scripts/auditViewModel.js index 6cbc8c7..40c78c1 100644 --- a/web/src/views/scripts/auditViewModel.js +++ b/web/src/views/scripts/auditViewModel.js @@ -207,6 +207,65 @@ export function resolveRiskRuleEnabled(source, rulePayload = null) { return true } +const LAST_OPERATION_LABELS = { + generate: '开始生成', + create: '创建', + test: '测试', + online: '上线', + offline: '下线', + delete: '删除', + update: '更新' +} + +const RISK_RULE_BUSINESS_STAGE_LABELS = { + expense_application: '费用申请', + reimbursement: '费用报销' +} + +function resolveRiskRuleBusinessStage(source, rulePayload = null) { + const configJson = readConfigJson(source) + const metadata = rulePayload && typeof rulePayload === 'object' ? rulePayload.metadata || {} : {} + const stage = + normalizeText(configJson.business_stage) || + normalizeText(metadata.business_stage) || + normalizeText(rulePayload?.business_stage) + const label = + normalizeText(configJson.business_stage_label) || + normalizeText(metadata.business_stage_label) || + RISK_RULE_BUSINESS_STAGE_LABELS[stage] + return { + value: stage || 'reimbursement', + label: label || '费用报销' + } +} + +function resolveRiskRuleOnlineMeta(statusValue) { + if (statusValue === 'active') { + return { label: '已上线', tone: 'success', online: true } + } + if (statusValue === 'disabled') { + return { label: '已下线', tone: 'disabled', online: false } + } + if (statusValue === 'generating') { + return { label: '生成中', tone: 'info', online: false } + } + if (statusValue === 'failed') { + return { label: '生成失败', tone: 'danger', online: false } + } + return { label: '待上线', tone: 'draft', online: false } +} + +function resolveLastOperationLabel(source, fallback = {}) { + const configJson = readConfigJson(source) + const operation = isPlainObject(configJson.last_operation) ? configJson.last_operation : {} + const action = normalizeText(operation.action) || normalizeText(fallback.action) || 'create' + const actor = normalizeText(operation.actor) || normalizeText(fallback.actor) || '系统' + const at = normalizeText(operation.at) || normalizeText(fallback.at) + const actionLabel = LAST_OPERATION_LABELS[action] || action + const timeLabel = formatDateTime(at) + return timeLabel && timeLabel !== '-' ? `${actionLabel}:${actor} · ${timeLabel}` : `${actionLabel}:${actor}` +} + export function readRuleDocumentMeta(value) { const configJson = readConfigJson(value) return isPlainObject(configJson.rule_document) ? configJson.rule_document : null @@ -458,12 +517,13 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) { normalizeText(apiConfig.expense_category_label) || normalizeText(rulePayload.risk_category) || resolveRiskRuleCategory({ ...target, risk_category: rulePayload.risk_category, config_json: rulePayload }) + const businessStage = resolveRiskRuleBusinessStage(target, rulePayload) const riskRuleFields = resolveRiskRuleFields(rulePayload) const riskRuleCreatedAt = resolveRiskRuleCreatedAt(rulePayload, target.createdAt || target.updatedAt) const riskRuleScoreLevel = resolveRiskRuleScoreLevel(rulePayload, apiConfig) const statusValue = apiPayload?.status || target.statusValue || 'draft' - const isOnlineLabel = statusValue === 'active' ? '是' : '否' + const onlineMeta = resolveRiskRuleOnlineMeta(statusValue) const isEnabledValue = resolveRiskRuleEnabled(target, rulePayload) const publisher = @@ -488,6 +548,8 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) { riskRuleBusinessDescription: resolveRiskRuleBusinessDescription(rulePayload, fullDescription), riskRuleSubtitle: buildRiskListSubtitle(fullDescription, 48), riskCategory, + businessStageValue: businessStage.value, + businessStageLabel: businessStage.label, scope: riskCategory, riskRuleSourceRef: resolveRiskRuleSourceRef(rulePayload), riskRuleSeverity: riskRuleScoreLevel || resolveRiskRuleSeverity(rulePayload), @@ -521,10 +583,16 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) { outcomes: apiPayload?.outcomes || rulePayload.outcomes || {} }, riskRuleJsonText: JSON.stringify(rulePayload, null, 2), - isOnlineLabel, + isOnlineValue: onlineMeta.online, + isOnlineLabel: onlineMeta.label, + isOnlineTone: onlineMeta.tone, isEnabledValue, isEnabledLabel: isEnabledValue ? '是' : '否', isEnabledTone: isEnabledValue ? 'success' : 'disabled', + lastOperationLabel: resolveLastOperationLabel(target, { + actor: publisher, + at: riskRuleCreatedAt + }), publisher, publishedAt } @@ -747,7 +815,7 @@ export function resolveTypeKey(assetType) { if (assetType === 'mcp') { return 'mcp' } - return 'tasks' + return '' } export function formatSeverity(value) { @@ -778,23 +846,6 @@ export function formatOutputSummary(items) { return `${items.length} 项输出` } -export function formatTaskRisk(scenarios) { - if (Array.isArray(scenarios) && scenarios.includes('risk_check')) { - return '高风险' - } - if ( - Array.isArray(scenarios) && - (scenarios.includes('accounts_receivable') || scenarios.includes('accounts_payable')) - ) { - return '中风险' - } - return '常规' -} - -export function findLatestTaskRun(runs, assetId) { - return runs.find((item) => item.task_id === assetId) || null -} - export function findLatestMcpCall(runs, assetCode) { const expectedToolName = normalizeText(assetCode).replace(/^mcp\./, '') @@ -827,7 +878,7 @@ export function buildRowRuntime(asset, typeKey) { if (typeKey === 'mcp') { return normalizeText(asset.config_json?.endpoint) || '未配置地址' } - return normalizeText(asset.config_json?.cron) || '未配置调度' + return '' } export function buildRowMetric(asset, typeKey) { @@ -840,7 +891,7 @@ export function buildRowMetric(asset, typeKey) { if (typeKey === 'mcp') { return asset.config_json?.timeout_ms ? `${asset.config_json.timeout_ms} ms` : '未配置超时' } - return normalizeText(asset.config_json?.agent) || '未配置 Agent' + return '' } export function formatSpreadsheetChangeSummary(summary) { @@ -885,7 +936,8 @@ export function buildListItem(asset) { const listSubtitle = isRiskRule ? buildRiskListSubtitle(asset.description) : normalizeText(asset.description) - const isOnlineValue = asset.status === 'active' + const onlineMeta = resolveRiskRuleOnlineMeta(asset.status) + const isOnlineValue = onlineMeta.online const isEnabledValue = usesJsonRiskRule ? resolveRiskRuleEnabled(asset) : true const reviewer = normalizeText(asset.reviewer) || '待分配' const creator = @@ -895,6 +947,9 @@ export function buildListItem(asset) { '未知' const publisher = isRiskRule ? creator : '' const riskRuleCreatedAt = formatDateTime(asset.created_at || asset.updated_at) + const businessStage = usesJsonRiskRule + ? resolveRiskRuleBusinessStage(asset) + : { value: '', label: '' } return { id: asset.id, @@ -915,6 +970,8 @@ export function buildListItem(asset) { reviewer, scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(asset.scenario_json), riskCategory: ruleScenarioCategory, + businessStageValue: businessStage.value, + businessStageLabel: businessStage.label, model: buildRowRuntime(asset, typeKey), version: workingVersion, versionDisplay: typeKey === 'rules' ? `${changeCount} 次` : workingVersion, @@ -928,8 +985,8 @@ export function buildListItem(asset) { publisher, publishedAt: isOnlineValue ? formatDateTime(asset.published_at || asset.updated_at) : '-', isOnlineValue, - isOnlineLabel: isOnlineValue ? '是' : '否', - isOnlineTone: isOnlineValue ? 'success' : 'disabled', + isOnlineLabel: onlineMeta.label, + isOnlineTone: onlineMeta.tone, isEnabledValue, isEnabledLabel: isEnabledValue ? '是' : '否', isEnabledTone: isEnabledValue ? 'success' : 'disabled', @@ -1002,21 +1059,7 @@ export function buildMcpFields(detail, latestCall) { ] } -export function buildTaskFields(detail, latestRun) { - const content = detail.current_version_content || {} - return [ - { label: '任务编码', value: detail.code }, - { label: 'Cron', value: normalizeText(detail.config_json?.cron) || normalizeText(content.schedule) || '未配置' }, - { label: '执行 Agent', value: normalizeText(detail.config_json?.agent) || normalizeText(content.target_agent) || '未配置' }, - { label: '风险等级', value: formatTaskRisk(detail.scenario_json) }, - { - label: '最近执行', - value: latestRun ? formatDateTime(latestRun.started_at) : '暂无执行记录' - } - ] -} - -export function buildFields(detail, typeKey, latestRun, latestCall) { +export function buildFields(detail, typeKey, latestCall) { if (typeKey === 'rules') { return buildRuleFields(detail) } @@ -1026,10 +1069,10 @@ export function buildFields(detail, typeKey, latestRun, latestCall) { if (typeKey === 'mcp') { return buildMcpFields(detail, latestCall) } - return buildTaskFields(detail, latestRun) + return [] } -export function buildPromptSections(detail, typeKey, latestRun, latestCall) { +export function buildPromptSections(detail, typeKey) { const content = detail.current_version_content || {} if (typeKey === 'skills') { @@ -1075,26 +1118,10 @@ export function buildPromptSections(detail, typeKey, latestRun, latestCall) { ] } - return [ - { - title: '任务场景', - intent: '调度目标', - content: formatScenarioList(detail.scenario_json) - }, - { - title: '执行 Agent', - intent: '运行主体', - content: normalizeText(content.target_agent) || normalizeText(detail.config_json?.agent) || '未配置执行 Agent。' - }, - { - title: '最近执行结果', - intent: '运行反馈', - content: latestRun?.result_summary || latestRun?.error_message || '暂无执行记录。' - } - ] + return [] } -export function buildOutputRules(detail, typeKey, latestRun, latestCall) { +export function buildOutputRules(detail, typeKey) { const content = detail.current_version_content || {} if (typeKey === 'rules') { @@ -1130,15 +1157,10 @@ export function buildOutputRules(detail, typeKey, latestRun, latestCall) { ] } - return [ - `调度周期:${normalizeText(detail.config_json?.cron) || normalizeText(content.schedule) || '未配置'}`, - `执行 Agent:${normalizeText(detail.config_json?.agent) || normalizeText(content.target_agent) || '未配置'}`, - `风险等级:${formatTaskRisk(detail.scenario_json)}`, - `最近执行结果:${latestRun?.status || '暂无执行记录'}` - ] + return [] } -export function buildTests(detail, typeKey, latestRun, latestCall) { +export function buildTests(detail, typeKey, latestCall) { if (typeKey === 'rules') { const reviewMeta = resolveReviewMeta(detail.latest_review?.review_status) return [ @@ -1195,23 +1217,10 @@ export function buildTests(detail, typeKey, latestRun, latestCall) { ] } - return [ - { - name: '最近运行状态', - input: latestRun?.run_id || '暂无运行', - result: latestRun?.status || '未记录', - tone: latestRun?.status === 'failed' || latestRun?.status === 'blocked' ? 'danger' : 'success' - }, - { - name: '结果摘要', - input: latestRun?.agent || normalizeText(detail.config_json?.agent) || '未配置', - result: latestRun?.result_summary || '暂无摘要', - tone: 'success' - } - ] + return [] } -export function buildTools(detail, typeKey, latestRun, latestCall) { +export function buildTools(detail, typeKey, latestCall) { const content = detail.current_version_content || {} if (typeKey === 'skills') { @@ -1246,26 +1255,7 @@ export function buildTools(detail, typeKey, latestRun, latestCall) { ] } - return [ - { - name: normalizeText(detail.config_json?.agent) || normalizeText(content.target_agent) || '未配置 Agent', - scope: '执行 Agent', - mode: '调度', - tone: 'active' - }, - { - name: latestRun?.run_id || '暂无执行记录', - scope: '最近 Run', - mode: latestRun?.status || '未执行', - tone: latestRun?.status === 'failed' || latestRun?.status === 'blocked' ? 'danger' : 'active' - }, - { - name: latestRun?.permission_level || '未记录', - scope: '权限级别', - mode: 'Trace', - tone: 'safe' - } - ] + return [] } export function buildPublishDescription(detail, typeKey) { @@ -1279,14 +1269,16 @@ export function buildPublishDescription(detail, typeKey) { return '当前规则需要先完成审核,再调用上线接口正式激活。' } - return DETAIL_TITLES[typeKey].publishDesc + return DETAIL_TITLES[typeKey]?.publishDesc || '' } export function buildDetailViewModel(detail, runs) { const typeKey = resolveTypeKey(detail.asset_type) const tabId = resolveTabId(detail, typeKey) || typeKey + if (!typeKey || !tabId) { + return null + } const tabMeta = resolveTabMeta(tabId, typeKey) - const latestRun = typeKey === 'tasks' ? findLatestTaskRun(runs, detail.id) : null const latestCall = typeKey === 'mcp' ? findLatestMcpCall(runs, detail.code) : null const configJson = readConfigJson(detail) const statusMeta = resolveStatusMeta(detail.status) @@ -1320,6 +1312,14 @@ export function buildDetailViewModel(detail, runs) { normalizeText(detail.owner) || normalizeText(detail.recent_versions?.[0]?.created_by) || '未知' + const onlineMeta = resolveRiskRuleOnlineMeta(detail.status) + const businessStage = usesJsonRiskRule + ? resolveRiskRuleBusinessStage(detail) + : { value: '', label: '' } + + const initialRiskRuleScore = resolveRiskRuleScore(configJson, configJson) + const initialRiskRuleScoreLevel = resolveRiskRuleScoreLevel(configJson, configJson) + const initialRiskRuleSeverity = initialRiskRuleScoreLevel || resolveRiskRuleSeverity(configJson) return { id: detail.id, @@ -1335,6 +1335,8 @@ export function buildDetailViewModel(detail, runs) { reviewer: detail.reviewer || detail.latest_review?.reviewer || '待分配', category: resolveDomainLabel(detail.domain), scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(detail.scenario_json), + businessStageValue: businessStage.value, + businessStageLabel: businessStage.label, version: detail.working_version || detail.current_version || '-', currentVersion: detail.current_version || '-', publishedVersion: detail.published_version || '-', @@ -1356,15 +1358,19 @@ export function buildDetailViewModel(detail, runs) { riskRuleBusinessDescription: '', riskRuleSubtitle: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : '', riskRuleSourceRef: '', - riskRuleSeverity: 'medium', - riskRuleSeverityLabel: '中风险', - riskRuleScore: null, - riskRuleScoreLabel: '待计算', - riskRuleScoreLevel: 'medium', - riskRuleScoreDetail: null, + riskRuleSeverity: initialRiskRuleSeverity, + riskRuleScore: initialRiskRuleScore, + riskRuleScoreLevel: initialRiskRuleScoreLevel || initialRiskRuleSeverity, + riskRuleScoreDetail: resolveRiskRuleScoreDetail(configJson, configJson), + riskRuleSeverityLabel: initialRiskRuleScoreLevel + ? resolveRiskRuleScoreLabel(configJson, configJson) + : resolveRiskRuleSeverityLabel(configJson), + riskRuleScoreLabel: resolveRiskRuleScoreLabel(configJson, configJson), riskRuleCreatedAt: formatDateTime(detail.created_at), riskRuleAgeLabel: formatRiskRuleAge(detail.created_at), - isOnlineLabel: detail.status === 'active' ? '是' : '否', + isOnlineValue: onlineMeta.online, + isOnlineLabel: onlineMeta.label, + isOnlineTone: onlineMeta.tone, isEnabledValue, isEnabledLabel: isEnabledValue ? '是' : '否', isEnabledTone: isEnabledValue ? 'success' : 'disabled', @@ -1381,6 +1387,11 @@ export function buildDetailViewModel(detail, runs) { history.find((item) => item.isPublished || item.lifecycleState === 'published')?.time || (detail.published_at ? formatDateTime(detail.published_at) : '') || (detail.latest_review?.reviewed_at ? formatDateTime(detail.latest_review.reviewed_at) : '-'), + lastOperationLabel: resolveLastOperationLabel(detail, { + actor: riskRuleCreator, + at: detail.created_at + }), + lastOperationTone: onlineMeta.tone, riskRuleFields: [], riskRuleFieldSummary: '未识别字段', riskRuleFlow: resolveRiskRuleFlow({}, []), @@ -1411,13 +1422,12 @@ export function buildDetailViewModel(detail, runs) { reviewStatusValue: detail.latest_review?.review_status || '', reviewTimeLabel: formatDateTime(detail.latest_review?.reviewed_at), reviewNote: detail.latest_review?.review_note || '', - latestRun, latestCall, - fields: buildFields(detail, typeKey, latestRun, latestCall), + fields: buildFields(detail, typeKey, latestCall), promptSections: - typeKey === 'rules' ? [] : buildPromptSections(detail, typeKey, latestRun, latestCall), - outputRules: buildOutputRules(detail, typeKey, latestRun, latestCall), - tests: buildTests(detail, typeKey, latestRun, latestCall), + typeKey === 'rules' ? [] : buildPromptSections(detail, typeKey), + outputRules: buildOutputRules(detail, typeKey), + tests: buildTests(detail, typeKey, latestCall), triggers: typeKey === 'rules' ? [ruleScenarioCategory || '通用'] @@ -1446,7 +1456,7 @@ export function buildDetailViewModel(detail, runs) { tone: 'safe' } ] - : buildTools(detail, typeKey, latestRun, latestCall), + : buildTools(detail, typeKey, latestCall), history, configTitle: titles.configTitle, configDesc: titles.configDesc, @@ -1467,9 +1477,7 @@ export function buildDetailViewModel(detail, runs) { publishMeta: typeKey === 'rules' ? `最近保存:${formatDateTime(detail.updated_at)}` - : latestRun - ? `最近运行:${formatDateTime(latestRun.started_at)}` - : `最近更新:${formatDateTime(detail.updated_at)}`, + : `最近更新:${formatDateTime(detail.updated_at)}`, publishState: statusMeta.label, latestReviewVersion: detail.latest_review?.version || detail.current_version || '-', loading: false diff --git a/web/src/views/scripts/auditViewRiskRuleModel.js b/web/src/views/scripts/auditViewRiskRuleModel.js index 389a7c1..191ce35 100644 --- a/web/src/views/scripts/auditViewRiskRuleModel.js +++ b/web/src/views/scripts/auditViewRiskRuleModel.js @@ -16,6 +16,11 @@ export const RISK_RULE_EXPENSE_CATEGORY_OPTIONS = [ { value: 'welfare', label: '福利费' } ] +export const RISK_RULE_BUSINESS_STAGE_OPTIONS = [ + { value: 'expense_application', label: '费用申请' }, + { value: 'reimbursement', label: '费用报销' } +] + export const RISK_RULE_LEVEL_OPTIONS = [ { value: 'low', label: '低风险' }, { value: 'medium', label: '中风险' }, @@ -49,6 +54,7 @@ const CITY_ROUTE_SEMANTIC_TYPES = new Set([ export function createDefaultRiskRuleForm() { return { business_domain: 'expense', + business_stage: 'reimbursement', expense_category: 'travel', rule_title: '', requires_attachment: false, diff --git a/web/src/views/scripts/travelReimbursementReviewConstants.js b/web/src/views/scripts/travelReimbursementReviewConstants.js index 876bbbb..025169f 100644 --- a/web/src/views/scripts/travelReimbursementReviewConstants.js +++ b/web/src/views/scripts/travelReimbursementReviewConstants.js @@ -25,8 +25,10 @@ export const EXPENSE_TYPE_LABELS = { meal: '业务招待费', meeting: '会务费', entertainment: '业务招待费', + marketing: '市场推广费', office: '办公用品费', training: '培训费', + software: '软件服务费', communication: '通讯费', welfare: '福利费', other: '其他费用' @@ -96,8 +98,10 @@ export const REVIEW_FALLBACK_GROUP_CODES = [ 'hotel', 'meal', 'meeting', + 'marketing', 'office', 'training', + 'software', 'communication', 'welfare' ] @@ -113,7 +117,9 @@ export const REVIEW_CATEGORY_PRESET_OPTIONS = [ export const REVIEW_OTHER_CATEGORY_OPTIONS = [ { key: 'meeting', label: '会务费' }, + { key: 'marketing', label: '市场推广费' }, { key: 'training', label: '培训费' }, + { key: 'software', label: '软件服务费' }, { key: 'communication', label: '通讯费' }, { key: 'welfare', label: '福利费' }, { key: 'other', label: '其他费用' } @@ -140,9 +146,11 @@ export const CATEGORY_CONFIDENCE_KEYWORDS = { transport: [TRANSPORT_KEYWORD_PATTERN], meal: [/业务招待|招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同|餐费|用餐|午餐|晚餐|早餐|伙食|餐饮/], meeting: [/会务|会议|论坛|展会|参会|会场/], + marketing: [/市场推广|推广费|广告|投放|品牌宣传|营销物料|推广物料/], entertainment: [/招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同/], office: [/办公|工位|耗材|白板|键盘|鼠标|打印|文具|采购/], training: [/培训|授课|讲师|课程|签到|讲义/], + software: [/软件|SaaS|订阅|系统服务|云服务|云资源|平台服务|技术服务/], communication: [/通讯|电话|流量|话费|宽带|网络/], welfare: [/福利|体检|团建|节日|慰问|关怀/] } diff --git a/web/tests/budget-ontology.test.mjs b/web/tests/budget-ontology.test.mjs new file mode 100644 index 0000000..39889d4 --- /dev/null +++ b/web/tests/budget-ontology.test.mjs @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + BUDGET_EXPENSE_TYPE_OPTIONS, + BUDGET_ONTOLOGY_FIELDS, + BUDGET_QUARTER_OPTIONS, + BUDGET_YEAR_OPTIONS, + buildBudgetOntologyContext +} from '../src/utils/budgetOntology.js' + +test('budget ontology fields expose required budget center keys', () => { + const requiredKeys = BUDGET_ONTOLOGY_FIELDS + .filter((field) => field.required) + .map((field) => field.key) + + assert.deepEqual(requiredKeys, [ + 'budget_period', + 'department', + 'cost_center', + 'budget_owner', + 'budget_version', + 'budget_status', + 'budget_subject', + 'budget_amount', + 'warning_threshold', + 'control_action' + ]) +}) + +test('budget ontology context maps dialog fields to ontology payload', () => { + const context = buildBudgetOntologyContext({ + form: { + budgetYear: '2026', + budgetQuarter: 'Q2', + departmentCode: 'MARKET-DEPT', + budgetOwner: '张晓明', + budgetVersion: 'V1.0(初始版本)', + budgetStatus: '编制中', + budgetDescription: '市场部预算编制' + }, + departments: [ + { code: 'MARKET-DEPT', name: '市场部', costCenter: 'CC-4100' } + ], + rows: [ + { + budgetSubject: '差旅费', + budgetSubjectCode: 'travel', + budgetAmount: '600,000.00', + warningThreshold: '80%', + controlAction: '提醒', + budgetRemark: '差旅相关费用' + } + ] + }) + + assert.equal(context.document_type, 'budget_plan') + assert.equal(context.conversation_scenario, 'budget') + assert.equal(context.budget_header.budget_period, '2026年Q2') + assert.equal(context.budget_header.budget_year, '2026') + assert.equal(context.budget_header.budget_quarter, 'Q2') + assert.equal(context.budget_header.department, '市场部') + 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].warning_threshold, '80%') +}) + +test('budget expense type options expose real expense type codes', () => { + const optionCodes = BUDGET_EXPENSE_TYPE_OPTIONS.map((item) => item.value) + + assert.deepEqual(optionCodes, [ + 'travel', + 'hotel', + 'transport', + 'meal', + 'meeting', + 'marketing', + 'office', + 'training', + 'software', + 'communication', + 'welfare' + ]) +}) + +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']) +})