From 34457f9c3e7a3f9336b0fccbb2f2534dd327801e Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Wed, 3 Jun 2026 15:46:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9C=AC=E4=BD=93=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E6=B2=BB=E7=90=86=E4=B8=8E=E9=A3=8E=E9=99=A9=E8=A7=84=E5=88=99?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E6=89=A7=E8=A1=8C=E5=99=A8=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增本体字段注册表与字段治理审计脚本 - 重构风险规则模板执行器、DSL 验证与清单分类器 - 完善票据夹服务与差旅请求详情页交互 - 优化趋势图表与总览页数据展示 - 增强报销平台风险分级与模拟公司筛选 - 补充本体字段、风险规则生成与票据夹服务测试覆盖 --- .../ontology-field-governance/CONCEPT.md | 78 +++ .../ontology-field-governance/FIELD_AUDIT.md | 40 ++ .../ontology-field-governance/TODO.md | 15 + .../development/receipt-folder/CONCEPT.md | 28 + document/development/receipt-folder/TODO.md | 13 + .../finance-rules/公司差旅费报销规则.xlsx | Bin 19827 -> 19831 bytes ...sk.budget.project_department_mismatch.json | 89 ++- .../risk.travel.high.city_mismatch.json | 13 +- .../risk.travel.low.vague_ticket_content.json | 6 +- .../scripts/audit_ontology_context_fields.py | 104 +++ server/src/app/models/financial_record.py | 1 + server/src/app/schemas/receipt_folder.py | 14 + server/src/app/schemas/reimbursement.py | 4 + .../agent_asset_risk_rule_simulation.py | 3 +- .../services/agent_asset_risk_rule_testing.py | 2 +- server/src/app/services/agent_assets.py | 43 +- server/src/app/services/agent_foundation.py | 6 + .../services/agent_foundation_risk_rules.py | 5 + .../demo_company_simulation_filters.py | 27 +- .../demo_company_simulation_rebalance.py | 373 +++++++++++ .../expense_claim_attachment_operations.py | 1 + .../expense_claim_document_item_builder.py | 19 +- .../expense_claim_ontology_resolvers.py | 37 +- .../services/expense_claim_platform_risk.py | 146 +++- server/src/app/services/expense_claims.py | 26 +- server/src/app/services/finance_dashboard.py | 83 ++- server/src/app/services/ontology.py | 4 +- .../src/app/services/ontology_extraction.py | 39 +- .../app/services/ontology_field_registry.py | 185 ++++++ server/src/app/services/receipt_folder.py | 167 +++++ .../app/services/risk_rule_dsl_validator.py | 2 +- .../app/services/risk_rule_execution_trace.py | 3 +- .../src/app/services/risk_rule_generation.py | 1 - .../services/risk_rule_generation_ontology.py | 2 +- .../services/risk_rule_generation_prompt.py | 8 +- .../risk_rule_generation_semantics.py | 18 +- .../services/risk_rule_manifest_classifier.py | 120 ++++ .../services/risk_rule_manifest_normalizer.py | 11 +- .../services/risk_rule_template_catalog.py | 7 +- .../services/risk_rule_template_executor.py | 117 +++- .../app/services/user_agent_review_core.py | 5 +- .../app/services/user_agent_review_slots.py | 98 +-- .../user_agent_review_travel_receipts.py | 8 +- server/tests/test_agent_asset_service.py | 51 +- .../test_demo_company_simulation_seed.py | 116 +++- .../test_expense_claim_platform_risk_stage.py | 185 ++++++ server/tests/test_expense_claim_service.py | 191 +++++- .../tests/test_finance_dashboard_service.py | 2 + server/tests/test_ocr_endpoints.py | 18 + server/tests/test_ontology_service.py | 59 ++ server/tests/test_receipt_folder_service.py | 40 ++ server/tests/test_risk_rule_generation.py | 326 ++++++++- .../styles/views/receipt-folder-view.css | 552 ++++++---------- .../views/travel-request-detail-view.css | 210 +++++- web/src/components/charts/TrendChart.vue | 373 +++++++++-- .../shared/EnterpriseDetailPage.vue | 4 + web/src/composables/useOverviewView.js | 22 +- web/src/composables/useRequests.js | 69 +- web/src/views/DocumentsCenterView.vue | 123 +++- web/src/views/OverviewView.vue | 13 +- web/src/views/ReceiptFolderView.vue | 258 ++++---- web/src/views/TravelRequestDetailView.vue | 107 ++- .../scripts/TravelReimbursementCreateView.js | 18 + .../views/scripts/TravelRequestDetailView.js | 622 ++++++++++++++++-- .../scripts/receiptFolderDetailDashboard.js | 65 +- .../travelReimbursementGuidedFlowModel.js | 6 +- .../scripts/travelReimbursementReviewModel.js | 68 +- .../travelRequestDetailExpenseModel.js | 2 + .../scripts/travelRequestDetailInsights.js | 6 + .../scripts/travelRequestDetailSubmitModel.js | 2 +- .../useTravelReimbursementGuidedFlow.js | 4 - .../useTravelReimbursementSubmitComposer.js | 87 +++ ...p-shell-financial-assistant-entry.test.mjs | 15 + .../documents-center-status-filter.test.mjs | 24 +- web/tests/finance-dashboard-ranking.test.mjs | 34 + web/tests/receipt-folder-view.test.mjs | 115 ++-- web/tests/requestProgressSteps.test.mjs | 52 ++ .../travel-reimbursement-guided-flow.test.mjs | 6 +- ...eimbursement-review-drawer-switch.test.mjs | 35 + ...travel-request-detail-risk-advice.test.mjs | 71 +- ...vel-request-detail-submit-confirm.test.mjs | 9 +- 81 files changed, 4858 insertions(+), 1073 deletions(-) create mode 100644 document/development/ontology-field-governance/CONCEPT.md create mode 100644 document/development/ontology-field-governance/FIELD_AUDIT.md create mode 100644 document/development/ontology-field-governance/TODO.md create mode 100644 server/scripts/audit_ontology_context_fields.py create mode 100644 server/src/app/services/demo_company_simulation_rebalance.py create mode 100644 server/src/app/services/ontology_field_registry.py create mode 100644 server/src/app/services/risk_rule_manifest_classifier.py diff --git a/document/development/ontology-field-governance/CONCEPT.md b/document/development/ontology-field-governance/CONCEPT.md new file mode 100644 index 0000000..65e4db5 --- /dev/null +++ b/document/development/ontology-field-governance/CONCEPT.md @@ -0,0 +1,78 @@ +# 本体字段治理 + +## 背景 + +当前费用申请、报销助手、单据详情、风险规则和预算控制中存在字段口径不一致的问题。例如同一语义在不同环节被命名为 `transport_type`、`transport_mode`、`application_transport_mode`,或 `occurred_date`、`business_time`、`time_range`。这些字段如果不先进入本体层,会导致语义识别、规则判断、草稿保存和前端展示各自解释同一业务事实。 + +## 原则 + +所有业务字段必须先设计为本体字段,再下放到业务模块使用。 + +- 本体字段注册表是唯一字段源。 +- 业务层只允许消费本体 canonical 字段。 +- 非本体字段只能作为输入别名,必须在语义入口归一。 +- 页面控件字段、兼容字段、后端历史字段不能直接进入业务判断。 +- 新增业务字段时,必须先更新本体字段设计,再更新表单、助手上下文、持久化、风险规则和测试。 + +## 当前第一阶段范围 + +第一阶段先治理费用申请和报销链路: + +- 个人工作台意图识别。 +- 费用申请预览和提交。 +- 报销助手快速发起报销。 +- 关联申请单生成报销草稿。 +- 报销详情智能录入和附件归集。 +- AI 预审、风险规则、审批流和预算流。 + +## 字段分层 + +本体 canonical 字段: + +- `expense_type` +- `time_range` +- `location` +- `reason` +- `amount` +- `transport_mode` +- `attachments` +- `customer_name` +- `merchant_name` +- `participants` +- `application_claim_id` +- `application_claim_no` +- `application_days` +- `application_date` +- `application_lodging_daily_cap` +- `application_subsidy_daily_cap` +- `application_transport_policy` +- `application_policy_estimate` + +输入兼容别名: + +- `transport_type`、`transportMode`、`application_transport_mode` -> `transport_mode` +- `occurred_date`、`business_time`、`application_business_time` -> `time_range` +- `business_location`、`application_location` -> `location` +- `reason_value`、`business_reason`、`application_reason` -> `reason` +- `attachment_names` -> `attachments` +- `reimbursement_type`、`scene_label` -> `expense_type` + +## 非合规判断 + +以下情况视为字段不合规: + +- 新业务流程直接新增 `context_json` 字段但没有进入本体注册表。 +- 风险规则读取未注册字段。 +- 前端 `review_form_values` 输出页面控件字段。 +- 后端服务用别名字段做业务判断,而不是先归一成本体字段。 +- 同一业务事实在申请、报销、审批、预算中使用不同字段名。 + +## 验收口径 + +完成后应满足: + +- 语义层能从上下文中生成统一本体实体。 +- 报销助手关联申请单后不再因为字段别名丢失追问出行方式。 +- `review_form_values` 对外输出本体字段,不输出页面别名字段。 +- 后端测试覆盖别名归一到本体字段。 +- 前端测试覆盖快速报销和核对抽屉只输出本体字段。 diff --git a/document/development/ontology-field-governance/FIELD_AUDIT.md b/document/development/ontology-field-governance/FIELD_AUDIT.md new file mode 100644 index 0000000..755fd5d --- /dev/null +++ b/document/development/ontology-field-governance/FIELD_AUDIT.md @@ -0,0 +1,40 @@ +# 本体字段纠察记录 + +## 纠察口径 + +所有会参与意图识别、申请/报销草稿、费用明细、风险规则、审批或预算判断的字段,必须先进入本体字段注册表。 + +字段分为三类: + +- 本体业务字段:可被业务逻辑、规则、页面表单直接消费。 +- 输入兼容别名:只允许在语义入口归一,不允许在业务判断中继续直接读取。 +- 上下文元数据:只表达会话、上传、编辑态、权限和执行链路,不作为业务事实。 + +## 已注册的业务字段 + +- 费用事实:`expense_type`、`time_range`、`location`、`reason`、`amount`、`transport_mode`、`attachments` +- 对象事实:`customer_name`、`merchant_name`、`participants` +- 员工事实:`employee_name`、`employee_no`、`department_name`、`employee_position`、`employee_grade`、`manager_name` +- 预算事实:`budget_period`、`budget_subject`、`budget_amount`、`cost_center`、`warning_threshold`、`control_action` +- 申请关联事实:`application_claim_id`、`application_claim_no`、`application_days`、`application_date`、`application_policy_estimate` + +## 已登记为元数据的字段 + +- 会话与流程:`conversation_id`、`conversation_history`、`conversation_scenario`、`conversation_intent`、`session_type`、`entry_source` +- 编辑与动作:`review_action`、`draft_claim_id`、`application_edit_mode`、`application_edit_claim_id` +- 上传与 OCR:`attachment_count`、`attachment_names`、`ocr_documents`、`ocr_summary`、`review_document_form_values` +- 客户端运行态:`client_now_iso`、`client_timezone_offset_minutes` +- 权限与调试:`role_codes`、`is_admin`、`simulate_tool_failure`、`simulate_orchestrator_exception` + +## 当前审计结论 + +- 未注册字段:已清零。 +- 历史别名直接读取:主要集中在员工上下文顶层字段,例如 `name`、`grade`、`department`、`position`。 +- 第一轮已把申请/报销关键链路的表单字段统一到 `expense_type`、`time_range`、`location`、`reason`、`amount`、`transport_mode`。 + +## 后续清理策略 + +1. 新增业务字段前,先更新 `ontology_field_registry.py`。 +2. 旧字段只作为输入别名保留,入口归一到 canonical 字段。 +3. 业务模块逐步停止直接读取旧别名。 +4. 使用 `server/scripts/audit_ontology_context_fields.py --strict` 作为收口质量闸。 diff --git a/document/development/ontology-field-governance/TODO.md b/document/development/ontology-field-governance/TODO.md new file mode 100644 index 0000000..a78221f --- /dev/null +++ b/document/development/ontology-field-governance/TODO.md @@ -0,0 +1,15 @@ +# 本体字段治理 TODO + +- [x] 建立本体字段注册表,集中维护 canonical 字段和输入别名。[CONCEPT: 字段分层] +- [x] 在语义解析入口归一 `context_json.review_form_values`。[CONCEPT: 原则] +- [x] 在本体实体抽取中把上下文字段桥接为 `transport_mode`、`reason`、`location` 等实体。[CONCEPT: 当前第一阶段范围] +- [x] 报销助手 review 入口复用本体字段注册表,不再自己维护字段别名。[CONCEPT: 原则] +- [x] 快速报销关联申请单上下文去除 `business_time`、`business_location`、`reason_value`、`reimbursement_type` 等非本体输出字段。[CONCEPT: 非合规判断] +- [x] 核对抽屉提交上下文归一为本体字段。[CONCEPT: 验收口径] +- [x] 补充本体层和前端字段归一回归测试。[CONCEPT: 验收口径] +- [ ] 清查申请助手字段:`application_preview`、`application_fields`、`business_time_context` 是否都已归一本体。 +- [ ] 清查报销详情字段:智能录入、附件归集、费用明细、异常说明是否仍有非本体字段直传。 +- [ ] 清查风险规则字段:规则中心、Hermes 归一字段、OCR pipeline 字段是否有未注册业务字段。 +- [ ] 清查预算字段:预算控制、预算复核、预算操作上下文是否全部使用本体字段。 +- [ ] 清查审批字段:审批意见、退回原因、流程节点字段是否需要纳入本体或定义为流程元数据。 +- [ ] 增加字段合规扫描脚本,对新增 `review_form_values` / `context_json` 字段进行检查。 diff --git a/document/development/receipt-folder/CONCEPT.md b/document/development/receipt-folder/CONCEPT.md index 63c142f..6e2054d 100644 --- a/document/development/receipt-folder/CONCEPT.md +++ b/document/development/receipt-folder/CONCEPT.md @@ -236,3 +236,31 @@ $$ - 本轮采用文件元数据而非数据库,适合先完成闭环;后续若需要审计、权限、跨用户协作和全文检索,应升级到资产表。 - 已关联状态如何自动回写,需要在后续把票据夹 ID 与报销明细 `invoice_id` 建立更强绑定。 - 多票据关联时,如果用户中途取消对话,本轮仍保留为未关联,避免误标。 + +## 2026-06-03 详情页与上传治理补充 + +本轮根据新的验收要求收敛为三块核心内容: + +- 左侧为票据预览,使用共享详情页主区域承载,图片和 PDF 都以完整票据可见为优先目标,不再提供“打开源文件”按钮。 +- 右侧为识别票据详情,展示当前票据所有 OCR 字段和基础字段;用户点击“编辑”后可直接修改识别内容,保存后写回票据夹元数据。 +- 底部为关联信息;左侧预览卡底部同时展示用户编辑操作记录,用于后续财务判断人工修改痕迹。 + +编辑记录治理: + +- `PATCH /receipt-folder/{receipt_id}` 在保存前后对可编辑票据信息做字段级 diff。 +- 每条编辑日志记录操作者、操作时间、字段名称、修改前值、修改后值。 +- 前端详情页只展示真实 `edit_logs`,不再用模拟操作日志替代。 + +重复上传治理: + +- OCR 持久化票据时计算源文件 `sha256`。 +- 同一用户再次上传相同源文件时,不新建票据目录,返回已有 `receipt_id`,并在 OCR 文档 warnings 中提示“已上传过同样的单据,请不要重复上传。” + +报销助手联动: + +- 用户在报销助手上传新附件前,如果票据夹中存在未关联票据,先提示用户是否进入票据夹关联。 +- 用户可以选择“去票据夹关联”,也可以选择“继续上传新附件”;继续上传时只跳过本次未关联提醒,不影响后续重复附件校验。 + +删除级联: + +- 已关联票据对应的报销单被删除时,票据夹中关联该报销单的票据源文件、预览文件和元数据一并删除。 diff --git a/document/development/receipt-folder/TODO.md b/document/development/receipt-folder/TODO.md index a6dc7c9..fbb2a38 100644 --- a/document/development/receipt-folder/TODO.md +++ b/document/development/receipt-folder/TODO.md @@ -99,3 +99,16 @@ - [x] 更新本 TODO 的完成状态和验证记录。[CONCEPT: 测试方案] 证据:本文件已补充完成勾选和验证命令记录。 + +## 阶段八:2026-06-03 详情页与上传治理收口 + +- [x] 将票据夹详情页收敛为共享详情布局下的三块内容:左侧完整预览、右侧识别票据详情、底部关联信息。[CONCEPT: 2026-06-03 详情页与上传治理补充] + 证据:`node web/tests/receipt-folder-view.test.mjs`、`npm.cmd run build`、容器内 `cd /app/web && npm run build` 均通过。 +- [x] 支持识别票据详情编辑,并在后端保存字段级编辑日志。[CONCEPT: 编辑记录治理] + 证据:容器内 `pytest -q server/tests/test_ocr_endpoints.py server/tests/test_receipt_folder_service.py` 通过,3 passed。 +- [x] OCR 持久化时识别同一用户重复上传的相同源文件,返回已有票据并提示不要重复上传。[CONCEPT: 重复上传治理] + 证据:`test_ocr_endpoints.py` 已覆盖重复上传返回原 `receipt_id` 和 warnings。 +- [x] 报销助手上传附件前提示票据夹中存在未关联票据,并提供进入票据夹关联或继续上传的建议动作。[CONCEPT: 报销助手联动] + 证据:`receipt-folder-view.test.mjs` 覆盖 `fetchReceiptFolderItems('unlinked')`、`open_receipt_folder` 和 `continue_upload_with_unlinked_receipts`。 +- [x] 删除已关联报销单时,同步删除票据夹中关联该报销单的票据文件。[CONCEPT: 删除级联] + 证据:`test_receipt_folder_service.py` 已覆盖 `delete_receipts_for_claim` 删除后不可再读取票据。 diff --git a/server/rules/finance-rules/公司差旅费报销规则.xlsx b/server/rules/finance-rules/公司差旅费报销规则.xlsx index 22ca3d855043fd4a858f8bcf738c134524d5b0bb..874fd5c4d8f28d9f705ec3ac73d1507ace445c63 100644 GIT binary patch delta 5526 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 L*(mz;PJjJBr?ex< diff --git a/server/rules/risk-rules/risk.budget.project_department_mismatch.json b/server/rules/risk-rules/risk.budget.project_department_mismatch.json index c360979..f34e868 100644 --- a/server/rules/risk-rules/risk.budget.project_department_mismatch.json +++ b/server/rules/risk-rules/risk.budget.project_department_mismatch.json @@ -155,9 +155,9 @@ "action": "continue" }, "fail": { - "severity": "high", + "severity": "medium", "action": "manual_review", - "risk_score": 84 + "risk_score": 60 } }, "metadata": { @@ -166,8 +166,8 @@ "source_ref": "费用管控 Demo 风险规则库", "created_at": "2026-05-31T00:10:41.785760+00:00", "created_by": "system", - "risk_score": 84, - "risk_level": "high", + "risk_score": 60, + "risk_level": "medium", "rule_title": "项目预算与部门不匹配", "finance_rule_code": "budget.execution.policy", "finance_rule_sheet": "预算执行规则", @@ -179,9 +179,82 @@ "expense_types": [ "all" ], - "budget_required": true + "budget_required": true, + "risk_level_label": "中风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 60, + "level": "medium", + "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": 58, + "evidence": 62, + "exception": 35, + "action": 35, + "sensitivity": 45 + }, + "calibration": { + "raw_score": 60, + "rules": [] + }, + "ai_evidence": {}, + "basis": { + "template_key": "keyword_match_v1", + "field_count": 12, + "condition_count": 0, + "expense_category": null, + "expense_category_label": "预算管控", + "requires_attachment": false + } + } }, - "severity": "high", - "risk_score": 84, - "risk_level": "high" + "severity": "medium", + "risk_score": 60, + "risk_level": "medium", + "risk_level_label": "中风险", + "risk_score_detail": { + "score": 60, + "level": "medium", + "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": 58, + "evidence": 62, + "exception": 35, + "action": 35, + "sensitivity": 45 + }, + "calibration": { + "raw_score": 60, + "rules": [] + }, + "ai_evidence": {}, + "basis": { + "template_key": "keyword_match_v1", + "field_count": 12, + "condition_count": 0, + "expense_category": null, + "expense_category_label": "预算管控", + "requires_attachment": false + } + } } diff --git a/server/rules/risk-rules/risk.travel.high.city_mismatch.json b/server/rules/risk-rules/risk.travel.high.city_mismatch.json index c00f055..e323164 100644 --- a/server/rules/risk-rules/risk.travel.high.city_mismatch.json +++ b/server/rules/risk-rules/risk.travel.high.city_mismatch.json @@ -45,12 +45,6 @@ "type": "text", "source": "item" }, - { - "key": "employee.location", - "label": "员工常驻地", - "type": "text", - "source": "employee" - }, { "key": "attachment.route_cities", "label": "交通票行程城市", @@ -83,7 +77,6 @@ "field_keys": [ "claim.location", "item.item_location", - "employee.location", "attachment.route_cities", "attachment.hotel_city", "claim.reason", @@ -97,9 +90,7 @@ "attachment.route_cities", "attachment.hotel_city" ], - "home_city_fields": [ - "employee.location" - ], + "home_city_fields": [], "exception_fields": [ "claim.reason", "item.item_reason" @@ -113,7 +104,7 @@ "客户拜访", "项目现场" ], - "condition_summary": "票据城市未覆盖申报目的地,或路线出现常驻地/目的地以外城市且无合理说明。", + "condition_summary": "票据城市未覆盖申报目的地,或路线出现无法由本次票据起终点和申报目的地解释的额外城市且无合理说明。", "message_template": "差旅票据城市与申报目的地不一致,请补充多地出差、改签或异地住宿说明。" }, "outcomes": { diff --git a/server/rules/risk-rules/risk.travel.low.vague_ticket_content.json b/server/rules/risk-rules/risk.travel.low.vague_ticket_content.json index 3bf5027..9618ab0 100644 --- a/server/rules/risk-rules/risk.travel.low.vague_ticket_content.json +++ b/server/rules/risk-rules/risk.travel.low.vague_ticket_content.json @@ -2,7 +2,7 @@ "schema_version": "2.0", "rule_code": "risk.travel.low.vague_ticket_content", "name": "差旅票据服务内容笼统低风险", - "description": "票据商品或服务名称过于笼统,例如仅写服务费、其他、详见清单等,提醒补充明细。", + "description": "票据商品或服务名称过于笼统,例如仅写服务费、其他、详见清单等,提醒补充明细;已明确识别为火车、机票、酒店、出租车等差旅票据时不按 OCR 全文关键词误判。", "enabled": true, "requires_attachment": true, "risk_dimension": "travel_reimbursement_control", @@ -41,14 +41,14 @@ }, { "key": "attachment.ocr_text", - "label": "票据 OCR 全文", + "label": "未识别明确票据类型时的 OCR 兜底文本", "type": "text", "source": "attachment" } ] }, "params": { - "condition_summary": "票据商品或服务名称过于笼统,无法直接对应差旅事项。", + "condition_summary": "票据未识别为明确的酒店、交通等差旅票据,且商品或服务名称过于笼统,无法直接对应差旅事项。", "message_template": "差旅票据服务内容较笼统,请补充明细清单或业务说明。" }, "outcomes": { diff --git a/server/scripts/audit_ontology_context_fields.py b/server/scripts/audit_ontology_context_fields.py new file mode 100644 index 0000000..fab4030 --- /dev/null +++ b/server/scripts/audit_ontology_context_fields.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +APP_SRC = ROOT / "src" +if str(APP_SRC) not in sys.path: + sys.path.insert(0, str(APP_SRC)) + +from app.services.ontology_field_registry import ( # noqa: E402 + CANONICAL_ONTOLOGY_FIELDS, + ONTOLOGY_CONTEXT_METADATA_FIELDS, + ONTOLOGY_FIELD_ALIASES, + REGISTERED_ONTOLOGY_CONTEXT_FIELDS, +) + + +SCAN_ROOTS = (ROOT / "src" / "app", ROOT.parent / "web" / "src") +SKIP_PARTS = {"__pycache__", ".pytest_cache", ".ruff_cache", "node_modules", "dist"} +FIELD_PATTERNS = ( + re.compile(r"""context_json\.get\(["']([^"']+)["']"""), + re.compile(r"""review_form_values\.get\(["']([^"']+)["']"""), + re.compile(r"""form_values\.get\(["']([^"']+)["']"""), + re.compile(r"""review_values\.get\(["']([^"']+)["']"""), +) + + +@dataclass(frozen=True) +class Finding: + file: Path + line_no: int + field: str + kind: str + source: str + + +def iter_source_files() -> list[Path]: + files: list[Path] = [] + for root in SCAN_ROOTS: + if not root.exists(): + continue + for path in root.rglob("*"): + if any(part in SKIP_PARTS for part in path.parts): + continue + if path.suffix not in {".py", ".js", ".vue", ".mjs", ".ts"}: + continue + files.append(path) + return sorted(files) + + +def collect_findings() -> tuple[list[Finding], list[Finding]]: + alias_fields = {alias for aliases in ONTOLOGY_FIELD_ALIASES.values() for alias in aliases} + unknown: list[Finding] = [] + alias_reads: list[Finding] = [] + + for path in iter_source_files(): + if path.name == "ontology_field_registry.py": + continue + text = path.read_text(encoding="utf-8", errors="ignore") + for line_no, line in enumerate(text.splitlines(), start=1): + for pattern in FIELD_PATTERNS: + for match in pattern.finditer(line): + field = match.group(1) + source = match.group(0) + if field in alias_fields and field not in ONTOLOGY_CONTEXT_METADATA_FIELDS: + alias_reads.append(Finding(path, line_no, field, "alias_read", source)) + if field not in REGISTERED_ONTOLOGY_CONTEXT_FIELDS: + unknown.append(Finding(path, line_no, field, "unknown", source)) + + return unknown, alias_reads + + +def print_section(title: str, findings: list[Finding]) -> None: + print(f"\n{title}: {len(findings)}") + for item in findings[:200]: + relative = item.file.relative_to(ROOT.parent) + print(f"- {relative}:{item.line_no} field={item.field} source={item.source}") + if len(findings) > 200: + print(f"- ... {len(findings) - 200} more") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Audit ontology context field usage.") + parser.add_argument("--strict", action="store_true", help="Exit non-zero when findings exist.") + args = parser.parse_args() + + unknown, alias_reads = collect_findings() + print(f"canonical_fields: {len(CANONICAL_ONTOLOGY_FIELDS)}") + print(f"context_metadata_fields: {len(ONTOLOGY_CONTEXT_METADATA_FIELDS)}") + print_section("unknown_context_fields", unknown) + print_section("direct_alias_reads", alias_reads) + + if args.strict and (unknown or alias_reads): + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server/src/app/models/financial_record.py b/server/src/app/models/financial_record.py index 7a6b025..6c2a182 100644 --- a/server/src/app/models/financial_record.py +++ b/server/src/app/models/financial_record.py @@ -86,6 +86,7 @@ class ExpenseClaimItem(Base): item_type: Mapped[str] = mapped_column(String(50)) item_reason: Mapped[str] = mapped_column(Text()) item_location: Mapped[str] = mapped_column(String(100)) + item_note: Mapped[str] = mapped_column(Text(), default="") item_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2)) invoice_id: Mapped[str | None] = mapped_column(String(100), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/server/src/app/schemas/receipt_folder.py b/server/src/app/schemas/receipt_folder.py index 032bdd2..1a97c88 100644 --- a/server/src/app/schemas/receipt_folder.py +++ b/server/src/app/schemas/receipt_folder.py @@ -12,6 +12,19 @@ class ReceiptFolderFieldRead(BaseModel): value: str = "" +class ReceiptFolderFieldChangeRead(BaseModel): + key: str = "" + label: str = "" + before: str = "" + after: str = "" + + +class ReceiptFolderEditLogRead(BaseModel): + operated_at: datetime | None = None + operator: str = "" + changes: list[ReceiptFolderFieldChangeRead] = Field(default_factory=list) + + class ReceiptFolderItemRead(BaseModel): id: str file_name: str @@ -48,6 +61,7 @@ class ReceiptFolderDetailRead(ReceiptFolderItemRead): classification_confidence: float = 0.0 classification_evidence: list[str] = Field(default_factory=list) fields: list[ReceiptFolderFieldRead] = Field(default_factory=list) + edit_logs: list[ReceiptFolderEditLogRead] = Field(default_factory=list) raw_meta: dict[str, Any] = Field(default_factory=dict) diff --git a/server/src/app/schemas/reimbursement.py b/server/src/app/schemas/reimbursement.py index 9967568..5189e54 100644 --- a/server/src/app/schemas/reimbursement.py +++ b/server/src/app/schemas/reimbursement.py @@ -39,6 +39,7 @@ class ExpenseClaimItemRead(BaseModel): item_type: str item_reason: str item_location: str + item_note: str = "" item_amount: Decimal invoice_id: str | None is_system_generated: bool = False @@ -101,6 +102,7 @@ class ExpenseClaimItemUpdate(BaseModel): item_type: str | None = None item_reason: str | None = None item_location: str | None = None + item_note: str | None = None item_amount: Decimal | None = None invoice_id: str | None = None @@ -110,6 +112,7 @@ class ExpenseClaimItemCreate(BaseModel): item_type: str | None = None item_reason: str | None = None item_location: str | None = None + item_note: str | None = None item_amount: Decimal | None = None invoice_id: str | None = None @@ -203,6 +206,7 @@ class ExpenseClaimAttachmentActionResponse(BaseModel): item_type: str | None = None item_reason: str | None = None item_location: str | None = None + item_note: str | None = None item_amount: Decimal | None = None claim_amount: Decimal | None = None claim_risk_flags: list[Any] = Field(default_factory=list) diff --git a/server/src/app/services/agent_asset_risk_rule_simulation.py b/server/src/app/services/agent_asset_risk_rule_simulation.py index bbe2799..597b69c 100644 --- a/server/src/app/services/agent_asset_risk_rule_simulation.py +++ b/server/src/app/services/agent_asset_risk_rule_simulation.py @@ -216,7 +216,7 @@ class AgentAssetRiskRuleSimulationMixin: if field_key == "item.item_location": return self._extract_labeled_city(corpus, city_mentions, ("明细地点", "发生地点")) if field_key == "employee.location": - return self._extract_labeled_city(corpus, city_mentions, ("员工常驻地", "常驻地", "办公地", "出发地")) + return self._extract_labeled_city(corpus, city_mentions, ("员工常驻地", "常驻地", "办公地")) if "city" in field_key or "location" in field_key: if any( token in key_text @@ -387,7 +387,6 @@ class AgentAssetRiskRuleSimulationMixin: for group_name in ( "attachment_city_fields", "reference_city_fields", - "home_city_fields", "exception_fields", ): for key in self._read_string_list(params.get(group_name)): 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 5dd58aa..2f16019 100644 --- a/server/src/app/services/agent_asset_risk_rule_testing.py +++ b/server/src/app/services/agent_asset_risk_rule_testing.py @@ -622,7 +622,7 @@ class AgentAssetRiskRuleTestingMixin: params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {} if template_key == "field_compare_v1": if str(params.get("semantic_type") or "").strip() in {"travel_city_consistency", "travel_route_city_consistency"}: - values.update({"attachment.hotel_city": "上海" if hit else "北京", "attachment.route_cities": ["上海"] if hit else ["北京"], "claim.location": "北京", "item.item_location": "北京", "employee.location": "北京"}) + values.update({"attachment.hotel_city": "上海" if hit else "北京", "attachment.route_cities": ["上海"] if hit else ["北京"], "claim.location": "北京", "item.item_location": "北京"}) return values condition = next( (item for item in params.get("conditions", []) if isinstance(item, dict)), diff --git a/server/src/app/services/agent_assets.py b/server/src/app/services/agent_assets.py index 4f8e4e7..0e8ab50 100644 --- a/server/src/app/services/agent_assets.py +++ b/server/src/app/services/agent_assets.py @@ -39,7 +39,8 @@ 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.pagination import PageResult +from app.services.pagination import PageResult, normalize_page_params +from app.services.risk_rule_manifest_classifier import is_budget_risk_manifest from app.services.risk_rule_score_backfill import backfill_missing_risk_rule_score logger = get_logger("app.services.agent_assets") @@ -77,6 +78,7 @@ class AgentAssetService( assets = self.repository.list( asset_type=asset_type, status=status, domain=domain, keyword=keyword ) + assets = self._filter_excluded_risk_assets(assets) version_stats = self._collect_version_stats(assets) return [self._serialize_list_item(asset, version_stats.get(asset.id)) for asset in assets] @@ -93,17 +95,24 @@ class AgentAssetService( self._ensure_ready() if asset_type in {None, "", AgentAssetType.RULE.value}: self.sync_platform_risk_rules_from_library() - result = self.repository.list_page( + assets = self.repository.list( asset_type=asset_type, status=status, domain=domain, keyword=keyword, - page=page, - page_size=page_size, ) - version_stats = self._collect_version_stats(result.items) - return result.map( - lambda asset: self._serialize_list_item(asset, version_stats.get(asset.id)) + assets = self._filter_excluded_risk_assets(assets) + page_params = normalize_page_params(page, page_size) + paged_assets = assets[page_params.offset : page_params.offset + page_params.page_size] + version_stats = self._collect_version_stats(paged_assets) + return PageResult( + items=[ + self._serialize_list_item(asset, version_stats.get(asset.id)) + for asset in paged_assets + ], + total=len(assets), + page=page_params.page, + page_size=page_params.page_size, ) def get_asset(self, asset_id: str) -> AgentAssetRead | None: @@ -151,6 +160,26 @@ class AgentAssetService( else None, ) + @staticmethod + def _filter_excluded_risk_assets(assets: list[AgentAsset]) -> list[AgentAsset]: + return [asset for asset in assets if not AgentAssetService._is_excluded_budget_risk_asset(asset)] + + @staticmethod + def _is_excluded_budget_risk_asset(asset: AgentAsset) -> bool: + if asset.asset_type != AgentAssetType.RULE.value: + return False + config_json = asset.config_json if isinstance(asset.config_json, dict) else {} + if str(config_json.get("detail_mode") or "").strip().lower() != "json_risk": + return False + manifest_like = { + **config_json, + "rule_code": str(asset.code or "").strip(), + "name": str(asset.name or "").strip(), + "description": str(asset.description or "").strip(), + "metadata": config_json, + } + return is_budget_risk_manifest(manifest_like) + def create_asset( self, payload: AgentAssetCreate, diff --git a/server/src/app/services/agent_foundation.py b/server/src/app/services/agent_foundation.py index 7356abf..815ec8a 100644 --- a/server/src/app/services/agent_foundation.py +++ b/server/src/app/services/agent_foundation.py @@ -124,6 +124,12 @@ class AgentFoundationService( "ON expense_claims (hermes_risk_flag)" ) ) + if "expense_claim_items" in inspector.get_table_names(): + item_column_names = {column["name"] for column in inspector.get_columns("expense_claim_items")} + if "item_note" not in item_column_names: + self.db.execute( + text("ALTER TABLE expense_claim_items ADD COLUMN item_note TEXT DEFAULT '' NOT NULL") + ) self.db.flush() def _sync_demo_financial_records(self) -> None: diff --git a/server/src/app/services/agent_foundation_risk_rules.py b/server/src/app/services/agent_foundation_risk_rules.py index 2faf348..27b9c52 100644 --- a/server/src/app/services/agent_foundation_risk_rules.py +++ b/server/src/app/services/agent_foundation_risk_rules.py @@ -20,6 +20,7 @@ from app.services.agent_asset_spreadsheet import ( from app.services.agent_foundation_constants import ( PLATFORM_DESTINATION_LOCATION_RULE_FILENAME, ) +from app.services.risk_rule_manifest_classifier import is_budget_risk_manifest logger = get_logger("app.services.agent_foundation") @@ -63,6 +64,10 @@ class AgentFoundationRiskRuleMixin: continue + if is_budget_risk_manifest(payload): + + continue + manifests.append((file_name, payload)) return manifests diff --git a/server/src/app/services/demo_company_simulation_filters.py b/server/src/app/services/demo_company_simulation_filters.py index ed8bcd7..578eba5 100644 --- a/server/src/app/services/demo_company_simulation_filters.py +++ b/server/src/app/services/demo_company_simulation_filters.py @@ -21,8 +21,8 @@ APPLICATION_EXPENSE_TYPES = { "preapproval", } APPLICATION_CLAIM_PREFIXES = ("AP-", "APP-", "TA-") -RECENT_VISIBLE_CLAIM_START = 501 -RECENT_VISIBLE_CLAIM_END = 817 +RECENT_VISIBLE_CLAIM_START = 401 +RECENT_VISIBLE_CLAIM_END = 424 def is_admin_identity(*values: Any) -> bool: @@ -99,7 +99,8 @@ def simulation_claim_day( ) if visible_day is not None: return visible_day - month = months[(employee_index + local_index * 2) % len(months)] + distribution_months = complete_distribution_months(months, period_end) + month = distribution_months[(employee_index + local_index * 2) % len(distribution_months)] _, max_day = calendar.monthrange(month.year, month.month) if month.year == period_end.year and month.month == period_end.month: max_day = min(max_day, period_end.day) @@ -108,16 +109,26 @@ def simulation_claim_day( def simulation_claim_count(employee: Any, index: int) -> int: - base = 7 + (index % 5) + base = 3 + (1 if index % 3 == 0 else 0) department_code = str(getattr(getattr(employee, "department", None), "unit_code", "") or "") grade = str(getattr(employee, "grade", "") or "") if department_code in {"MARKET-DEPT", "TECH-DEPT"}: - base += 3 + base += 1 elif department_code in {"PRODUCTION-DEPT", "PRESIDENT-OFFICE"}: - base += 2 + base += 1 if grade in {"P7", "P8"}: - base += 2 - return max(6, min(base, 16)) + base += 1 + return max(3, min(base, 6)) + + +def complete_distribution_months(months: list[date], period_end: date) -> list[date]: + complete_months: list[date] = [] + for month in months: + _, max_day = calendar.monthrange(month.year, month.month) + if month.year == period_end.year and month.month == period_end.month and period_end.day < 10: + continue + complete_months.append(month) + return complete_months or months def next_simulation_number(prefix: str, used_numbers: set[str], cursor: int) -> tuple[str, int]: diff --git a/server/src/app/services/demo_company_simulation_rebalance.py b/server/src/app/services/demo_company_simulation_rebalance.py new file mode 100644 index 0000000..2abad71 --- /dev/null +++ b/server/src/app/services/demo_company_simulation_rebalance.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import UTC, date, datetime, time, timedelta +from decimal import Decimal + +from sqlalchemy import func, select, text +from sqlalchemy.orm import Session + +from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction +from app.models.financial_record import ExpenseClaim +from app.models.risk_observation import RiskObservation +from app.services.demo_company_simulation_catalog import ( + BUDGETED_STATUSES, + SIM_BUDGET_PREFIX, + SIM_PROJECT_CODE, + SIM_RESERVATION_PREFIX, + SIM_RISK_PREFIX, + SIM_TRANSACTION_PREFIX, + build_simulation_reimbursement_no, + target_budget_usage, +) + + +@dataclass(frozen=True, slots=True) +class SimulationRebalanceSummary: + mode: str + claims: int + main_period_claims: int + recent_claims: int + period_start: str + period_end: str + max_daily_count: int + budget_transactions: int + budget_reservations: int + risk_observations: int + allocation_missing_count: int + + def to_dict(self) -> dict[str, object]: + return asdict(self) + + +class HalfYearExpenseSimulationRebalancer: + """Rebalance existing simulation rows without deleting business records.""" + + def __init__( + self, + db: Session, + *, + start_date: date = date(2026, 1, 1), + end_date: date = date(2026, 6, 2), + recent_sample_days: int = 2, + ) -> None: + self.db = db + self.start_date = start_date + self.end_date = end_date + self.main_period_end = date(end_date.year, end_date.month, 1) - timedelta(days=1) + self.recent_sample_days = max(1, recent_sample_days) + + def preview(self) -> SimulationRebalanceSummary: + return self._run(apply=False) + + def apply(self) -> SimulationRebalanceSummary: + return self._run(apply=True) + + def _run(self, *, apply: bool) -> SimulationRebalanceSummary: + claims = self._simulation_claims() + plans = self._claim_plans(claims) + allocation_map = self._allocation_map() + allocation_missing_count = self._count_missing_allocations(plans, allocation_map) + day_counts: dict[date, int] = {} + for _claim, plan in plans: + day_counts[plan["day"]] = day_counts.get(plan["day"], 0) + 1 + + if apply and plans: + self._apply_claim_plans(plans, allocation_map) + self._rebalance_allocation_amounts() + self.db.flush() + + recent_count = sum(1 for _claim, plan in plans if plan["day"] >= date(2026, 6, 1)) + return SimulationRebalanceSummary( + mode="apply" if apply else "dry-run", + claims=len(claims), + main_period_claims=len(claims) - recent_count, + recent_claims=recent_count, + period_start=self.start_date.isoformat(), + period_end=self.end_date.isoformat(), + max_daily_count=max(day_counts.values()) if day_counts else 0, + budget_transactions=self._sim_transaction_count(), + budget_reservations=self._sim_reservation_count(), + risk_observations=self._sim_risk_count(), + allocation_missing_count=allocation_missing_count, + ) + + def _simulation_claims(self) -> list[ExpenseClaim]: + return list( + self.db.scalars( + select(ExpenseClaim) + .where(ExpenseClaim.project_code == SIM_PROJECT_CODE) + .order_by(ExpenseClaim.claim_no.asc(), ExpenseClaim.id.asc()) + ).all() + ) + + def _claim_plans(self, claims: list[ExpenseClaim]) -> list[tuple[ExpenseClaim, dict[str, object]]]: + recent_count = self._recent_count(len(claims)) + main_count = max(len(claims) - recent_count, 0) + main_days = self._date_range(self.start_date, self.main_period_end) + recent_days = self._date_range(date(2026, 6, 1), self.end_date) + plans: list[tuple[ExpenseClaim, dict[str, object]]] = [] + for index, claim in enumerate(claims): + if index < main_count: + day = self._spread_day(index, main_count, main_days) + else: + recent_index = index - main_count + day = recent_days[recent_index % len(recent_days)] + occurred_at = datetime.combine(day, time(hour=8 + (index % 9)), tzinfo=UTC) + submitted_at = None + if self._status(claim) != "draft": + submitted_at = datetime.combine(day, time(hour=9 + (index % 7)), tzinfo=UTC) + updated_at = self._updated_at(claim, occurred_at, submitted_at, index) + final_claim_no = build_simulation_reimbursement_no(occurred_at, index + 1) + period_key = f"{occurred_at.year}Q{((occurred_at.month - 1) // 3) + 1}" + subject_code = "meal" if str(claim.expense_type or "") == "entertainment" else str(claim.expense_type or "") + plans.append( + ( + claim, + { + "sequence": index + 1, + "day": day, + "occurred_at": occurred_at, + "submitted_at": submitted_at, + "updated_at": updated_at, + "claim_no": final_claim_no, + "period_key": period_key, + "subject_code": subject_code, + }, + ) + ) + return plans + + def _apply_claim_plans( + self, + plans: list[tuple[ExpenseClaim, dict[str, object]]], + allocation_map: dict[tuple[str | None, str, str], str], + ) -> None: + claim_ids = [claim.id for claim, _plan in plans] + transactions_by_claim = self._transactions_by_claim_id(claim_ids) + reservations_by_claim = self._reservations_by_claim_id(claim_ids) + observations_by_claim = self._observations_by_claim_id(claim_ids) + + for claim, plan in plans: + claim.claim_no = f"SIM-TEMP-{claim.id}" + self.db.flush() + + for claim, plan in plans: + claim_no = str(plan["claim_no"]) + occurred_at = plan["occurred_at"] + submitted_at = plan["submitted_at"] + updated_at = plan["updated_at"] + allocation_id = allocation_map.get( + ( + claim.department_id, + str(plan["period_key"]), + str(plan["subject_code"]), + ) + ) + claim.claim_no = claim_no + claim.occurred_at = occurred_at + claim.submitted_at = submitted_at + claim.created_at = occurred_at + claim.updated_at = updated_at + claim.reason = self._normalized_reason(claim.reason, occurred_at.date()) + + self.db.execute( + text( + """ + update expense_claim_items + set item_date = :item_date, updated_at = :updated_at + where claim_id = :claim_id + """ + ), + { + "item_date": occurred_at.date(), + "updated_at": updated_at, + "claim_id": claim.id, + }, + ) + + for transaction in transactions_by_claim.get(claim.id, []): + transaction.source_no = claim_no + transaction.created_at = submitted_at or occurred_at + if allocation_id: + transaction.allocation_id = allocation_id + + for reservation in reservations_by_claim.get(claim.id, []): + reservation.source_no = claim_no + reservation.created_at = submitted_at or occurred_at + reservation.updated_at = updated_at + if allocation_id: + reservation.allocation_id = allocation_id + + for observation in observations_by_claim.get(claim.id, []): + observation.subject_key = claim_no + observation.subject_label = claim_no + observation.claim_no = claim_no + observation.created_at = submitted_at or occurred_at + observation.updated_at = updated_at + + def _allocation_map(self) -> dict[tuple[str | None, str, str], str]: + rows = self.db.scalars( + select(BudgetAllocation).where(BudgetAllocation.project_code == SIM_PROJECT_CODE) + ).all() + return { + (row.department_id, row.period_key, row.subject_code): row.id + for row in rows + } + + def _count_missing_allocations( + self, + plans: list[tuple[ExpenseClaim, dict[str, object]]], + allocation_map: dict[tuple[str | None, str, str], str], + ) -> int: + missing = { + (claim.department_id, str(plan["period_key"]), str(plan["subject_code"])) + for claim, plan in plans + if self._status(claim) in BUDGETED_STATUSES + and (claim.department_id, str(plan["period_key"]), str(plan["subject_code"])) not in allocation_map + } + return len(missing) + + def _rebalance_allocation_amounts(self) -> None: + allocations = list( + self.db.scalars( + select(BudgetAllocation) + .where(BudgetAllocation.budget_no.like(f"{SIM_BUDGET_PREFIX}%")) + .order_by(BudgetAllocation.period_key.asc(), BudgetAllocation.subject_code.asc()) + ).all() + ) + transactions = list( + self.db.scalars( + select(BudgetTransaction).where( + BudgetTransaction.transaction_no.like(f"{SIM_TRANSACTION_PREFIX}%") + ) + ).all() + ) + used_by_allocation: dict[str, Decimal] = {} + for transaction in transactions: + used_by_allocation[transaction.allocation_id] = ( + used_by_allocation.get(transaction.allocation_id, Decimal("0.00")) + + Decimal(transaction.amount or 0) + ) + for index, allocation in enumerate(allocations): + used = used_by_allocation.get(allocation.id, Decimal("0.00")) + usage = target_budget_usage(allocation.period_key, allocation.subject_code, index) + allocation.original_amount = max( + (used / usage).quantize(Decimal("0.01")) if usage > 0 else used, + Decimal("3000.00"), + ) + allocation.updated_by = "simulation_rebalance" + allocation.updated_at = datetime.now(UTC) + + def _transactions_by_claim_id(self, claim_ids: list[str]) -> dict[str, list[BudgetTransaction]]: + rows = self.db.scalars( + select(BudgetTransaction) + .where(BudgetTransaction.transaction_no.like(f"{SIM_TRANSACTION_PREFIX}%")) + .where(BudgetTransaction.source_id.in_(claim_ids)) + ).all() + return self._group_by_source_id(rows) + + def _reservations_by_claim_id(self, claim_ids: list[str]) -> dict[str, list[BudgetReservation]]: + rows = self.db.scalars( + select(BudgetReservation) + .where(BudgetReservation.reservation_no.like(f"{SIM_RESERVATION_PREFIX}%")) + .where(BudgetReservation.source_id.in_(claim_ids)) + ).all() + return self._group_by_source_id(rows) + + def _observations_by_claim_id(self, claim_ids: list[str]) -> dict[str, list[RiskObservation]]: + rows = self.db.scalars( + select(RiskObservation) + .where(RiskObservation.observation_key.like(f"{SIM_RISK_PREFIX}%")) + .where(RiskObservation.claim_id.in_(claim_ids)) + ).all() + grouped: dict[str, list[RiskObservation]] = {} + for row in rows: + if row.claim_id: + grouped.setdefault(row.claim_id, []).append(row) + return grouped + + @staticmethod + def _group_by_source_id(rows: object) -> dict[str, list[object]]: + grouped: dict[str, list[object]] = {} + for row in rows: + grouped.setdefault(row.source_id, []).append(row) + return grouped + + def _recent_count(self, total: int) -> int: + if total <= 0: + return 0 + return min(24, max(12, total // 50)) + + @staticmethod + def _date_range(start: date, end: date) -> list[date]: + days = max((end - start).days, 0) + return [start + timedelta(days=index) for index in range(days + 1)] + + @staticmethod + def _spread_day(index: int, count: int, days: list[date]) -> date: + if not days: + raise ValueError("days cannot be empty") + if count <= 1: + return days[0] + day_index = round(index * (len(days) - 1) / (count - 1)) + jitter = ((index * 17) % 5) - 2 + return days[max(0, min(len(days) - 1, day_index + jitter))] + + @staticmethod + def _updated_at( + claim: ExpenseClaim, + occurred_at: datetime, + submitted_at: datetime | None, + index: int, + ) -> datetime: + base = submitted_at or occurred_at + status = HalfYearExpenseSimulationRebalancer._status(claim) + if status == "paid": + return base + timedelta(days=2 + (index % 3), hours=index % 5) + if status in {"approved", "pending_payment"}: + return base + timedelta(days=1 + (index % 2), hours=index % 4) + if status in {"returned", "rejected"}: + return base + timedelta(hours=6 + (index % 8)) + return base + timedelta(hours=2 + (index % 4)) + + @staticmethod + def _normalized_reason(reason: str, day: date) -> str: + text = str(reason or "").strip() + for month in range(1, 7): + text = text.replace(f"{month}月", f"{day.month}月") + return text + + @staticmethod + def _status(claim: ExpenseClaim) -> str: + return str(claim.status or "").strip().lower() + + def _sim_transaction_count(self) -> int: + return int( + self.db.scalar( + select(func.count()).select_from(BudgetTransaction).where( + BudgetTransaction.transaction_no.like(f"{SIM_TRANSACTION_PREFIX}%") + ) + ) + or 0 + ) + + def _sim_reservation_count(self) -> int: + return int( + self.db.scalar( + select(func.count()).select_from(BudgetReservation).where( + BudgetReservation.reservation_no.like(f"{SIM_RESERVATION_PREFIX}%") + ) + ) + or 0 + ) + + def _sim_risk_count(self) -> int: + return int( + self.db.scalar( + select(func.count()).select_from(RiskObservation).where( + RiskObservation.observation_key.like(f"{SIM_RISK_PREFIX}%") + ) + ) + or 0 + ) diff --git a/server/src/app/services/expense_claim_attachment_operations.py b/server/src/app/services/expense_claim_attachment_operations.py index d51f41b..6658f83 100644 --- a/server/src/app/services/expense_claim_attachment_operations.py +++ b/server/src/app/services/expense_claim_attachment_operations.py @@ -275,6 +275,7 @@ class ExpenseClaimAttachmentOperationsMixin: "item_type": item.item_type, "item_reason": item.item_reason, "item_location": item.item_location, + "item_note": item.item_note, "item_amount": item.item_amount, "claim_amount": claim.amount, "claim_risk_flags": list(claim.risk_flags_json or []), diff --git a/server/src/app/services/expense_claim_document_item_builder.py b/server/src/app/services/expense_claim_document_item_builder.py index 70e5bee..076eec8 100644 --- a/server/src/app/services/expense_claim_document_item_builder.py +++ b/server/src/app/services/expense_claim_document_item_builder.py @@ -107,6 +107,7 @@ from app.services.expense_rule_runtime import ( build_default_expense_rule_catalog, resolve_document_type_label, ) +from app.services.ontology_field_registry import normalize_ontology_form_values from app.services.ocr import OcrService @@ -344,10 +345,10 @@ class ExpenseClaimDocumentItemBuilderMixin: review_form_values = context_json.get("review_form_values") if isinstance(review_form_values, dict): + review_form_values = normalize_ontology_form_values(review_form_values) review_type = str( review_form_values.get("expense_type") - or review_form_values.get("scene_label") - or review_form_values.get("reason_value") + or review_form_values.get("reason") or "" ) if any(keyword in review_type for keyword in ("差旅", "出差")): @@ -377,12 +378,8 @@ class ExpenseClaimDocumentItemBuilderMixin: else: review_form_values = context_json.get("review_form_values") if isinstance(review_form_values, dict): - time_text = str( - review_form_values.get("time_range") - or review_form_values.get("business_time") - or review_form_values.get("occurred_date") - or "" - ).strip() + review_form_values = normalize_ontology_form_values(review_form_values) + time_text = str(review_form_values.get("time_range") or "").strip() matched_dates = re.findall(r"\d{4}-\d{2}-\d{2}", time_text) if matched_dates: start_date = self._parse_iso_date_or_default(matched_dates[0], start_date) @@ -400,15 +397,13 @@ class ExpenseClaimDocumentItemBuilderMixin: review_form_values = context_json.get("review_form_values") text_parts: list[str] = [] if isinstance(review_form_values, dict): + review_form_values = normalize_ontology_form_values(review_form_values) text_parts.extend( str(review_form_values.get(key) or "") for key in ( "reason", - "business_reason", - "reason_value", - "scene_label", "time_range", - "business_time", + "expense_type", ) ) text_parts.extend( diff --git a/server/src/app/services/expense_claim_ontology_resolvers.py b/server/src/app/services/expense_claim_ontology_resolvers.py index 5e69472..6f26c71 100644 --- a/server/src/app/services/expense_claim_ontology_resolvers.py +++ b/server/src/app/services/expense_claim_ontology_resolvers.py @@ -108,6 +108,7 @@ from app.services.expense_rule_runtime import ( build_default_expense_rule_catalog, resolve_document_type_label, ) +from app.services.ontology_field_registry import normalize_ontology_form_values from app.services.ocr import OcrService @@ -204,11 +205,8 @@ class ExpenseClaimOntologyResolverMixin: def _resolve_explicit_review_expense_type(context_json: dict[str, Any]) -> str | None: review_form_values = context_json.get("review_form_values") if isinstance(review_form_values, dict): - compact = str( - review_form_values.get("expense_type") - or review_form_values.get("reimbursement_type") - or "" - ).replace(" ", "") + review_form_values = normalize_ontology_form_values(review_form_values) + compact = str(review_form_values.get("expense_type") or "").replace(" ", "") if compact: return resolve_expense_type_code_from_text(compact) return None @@ -238,10 +236,10 @@ class ExpenseClaimOntologyResolverMixin: ) -> str | None: review_form_values = context_json.get("review_form_values") if isinstance(review_form_values, dict): - for key in ("reason", "business_reason"): - value = str(review_form_values.get(key) or "").strip() - if value: - return ExpenseClaimOntologyResolverMixin._strip_leading_time_from_reason(value) + review_form_values = normalize_ontology_form_values(review_form_values) + value = str(review_form_values.get("reason") or "").strip() + if value: + return ExpenseClaimOntologyResolverMixin._strip_leading_time_from_reason(value) explicit_text = context_json.get("user_input_text") if isinstance(explicit_text, str): @@ -281,10 +279,10 @@ class ExpenseClaimOntologyResolverMixin: def _resolve_location(*, message: str, context_json: dict[str, Any]) -> str | None: review_form_values = context_json.get("review_form_values") if isinstance(review_form_values, dict): - for key in ("business_location", "location"): - value = str(review_form_values.get(key) or "").strip() - if value: - return value + review_form_values = normalize_ontology_form_values(review_form_values) + value = str(review_form_values.get("location") or "").strip() + if value: + return value request_context = context_json.get("request_context") if ( @@ -314,16 +312,9 @@ class ExpenseClaimOntologyResolverMixin: ) -> datetime | None: review_form_values = context_json.get("review_form_values") if isinstance(review_form_values, dict): - for key in ( - "occurred_date", - "time_range", - "business_time", - "application_business_time", - "application_time", - ): - value = str(review_form_values.get(key) or "").strip() - if not value: - continue + review_form_values = normalize_ontology_form_values(review_form_values) + value = str(review_form_values.get("time_range") or "").strip() + if value: try: parsed = date.fromisoformat(value) return datetime(parsed.year, parsed.month, parsed.day, tzinfo=UTC) diff --git a/server/src/app/services/expense_claim_platform_risk.py b/server/src/app/services/expense_claim_platform_risk.py index c526370..c9a4a64 100644 --- a/server/src/app/services/expense_claim_platform_risk.py +++ b/server/src/app/services/expense_claim_platform_risk.py @@ -16,6 +16,7 @@ from app.services.expense_rule_runtime import ( ) from app.services.expense_type_keywords import resolve_expense_type_code_from_text from app.services.expense_claim_platform_risk_flag import build_platform_risk_flag +from app.services.risk_rule_manifest_classifier import is_budget_risk_manifest from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor @@ -23,6 +24,44 @@ from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor class ExpenseClaimPlatformRiskMixin: _DEFAULT_RISK_BUSINESS_STAGE = "reimbursement" _SUPPORTED_RISK_BUSINESS_STAGES = {"expense_application", "reimbursement"} + _CLEAR_TRAVEL_DOCUMENT_TYPES = { + "flight_itinerary", + "train_ticket", + "ship_ticket", + "hotel_invoice", + "taxi_receipt", + "parking_toll_receipt", + } + _CLEAR_TRAVEL_SCENE_CODES = {"travel", "hotel", "transport"} + _GOODS_DESCRIPTION_FIELD_KEYS = { + "goodsname", + "servicename", + "itemname", + "project", + "productname", + "description", + "content", + "expensecontent", + "feeitem", + } + _GOODS_DESCRIPTION_LABEL_TOKENS = ( + "商品", + "服务", + "货物", + "项目", + "品名", + "名称", + "费用内容", + "消费内容", + ) + _VAGUE_KEYWORD_NEGATION_MARKERS = ( + "不含", + "不包含", + "不包括", + "未包含", + "不涉及", + "不属于", + ) def evaluate_platform_risk_rules( self, @@ -127,6 +166,8 @@ class ExpenseClaimPlatformRiskMixin: manifest_code = str(payload.get("rule_code") or rule_code).strip() if not manifest_code or (code_filter and manifest_code not in code_filter): continue + if is_budget_risk_manifest(payload): + continue if payload.get("enabled") is False or not self._risk_manifest_matches_business_stage( payload, business_stage=business_stage, @@ -162,6 +203,8 @@ class ExpenseClaimPlatformRiskMixin: continue if code_filter and rule_code not in missing_codes: continue + if is_budget_risk_manifest(payload): + continue if payload.get("enabled") is False or not self._risk_manifest_matches_business_stage( payload, business_stage=business_stage, @@ -364,7 +407,7 @@ class ExpenseClaimPlatformRiskMixin: fallback_message="票据文本中出现作废、红冲或红字发票相关信息,建议退回补充或人工复核。", ) if evaluator == "vague_goods_description": - return self._evaluate_text_keyword_risk( + return self._evaluate_vague_goods_description_risk( manifest, contexts=contexts, keywords=["详见清单", "服务费", "咨询费", "其他", "办公用品"], @@ -663,6 +706,107 @@ class ExpenseClaimPlatformRiskMixin: evidence={"matched_keywords": matched}, ) + def _evaluate_vague_goods_description_risk( + self, + manifest: dict[str, Any], + *, + contexts: list[dict[str, Any]], + keywords: list[str], + fallback_message: str, + ) -> dict[str, Any] | None: + matched_keywords: list[str] = [] + matched_fields: list[dict[str, str]] = [] + + for context in contexts: + document_info = context.get("document_info") or {} + if self._is_clear_travel_document(document_info): + continue + + field_values = self._collect_goods_description_field_values(document_info) + if field_values: + for value in field_values: + hits = self._collect_non_negated_keyword_hits(value, keywords) + for keyword in hits: + if keyword not in matched_keywords: + matched_keywords.append(keyword) + if hits: + matched_fields.append( + { + "item_index": str(context.get("index") or ""), + "value": value[:80], + } + ) + continue + + fallback_text = f"{context.get('ocr_summary') or ''}\n{context.get('ocr_text') or ''}" + hits = self._collect_non_negated_keyword_hits(fallback_text, keywords) + for keyword in hits: + if keyword not in matched_keywords: + matched_keywords.append(keyword) + if hits: + matched_fields.append( + { + "item_index": str(context.get("index") or ""), + "value": "OCR全文兜底", + } + ) + + if not matched_keywords: + return None + + return self._build_platform_risk_flag( + manifest, + message=fallback_message, + evidence={ + "matched_keywords": matched_keywords, + "matched_fields": matched_fields[:5], + }, + ) + + @classmethod + def _is_clear_travel_document(cls, document_info: dict[str, Any]) -> bool: + document_type = str(document_info.get("document_type") or "").strip().lower() + scene_code = str(document_info.get("scene_code") or "").strip().lower() + return ( + document_type in cls._CLEAR_TRAVEL_DOCUMENT_TYPES + or scene_code in cls._CLEAR_TRAVEL_SCENE_CODES + ) + + @classmethod + def _collect_goods_description_field_values(cls, document_info: dict[str, Any]) -> list[str]: + values: list[str] = [] + for field in list(document_info.get("fields") or []): + if not isinstance(field, dict): + continue + field_key = str(field.get("key") or "").strip().lower().replace("_", "") + label = str(field.get("label") or "").replace(" ", "") + value = str(field.get("value") or "").strip() + if not value: + continue + if field_key in cls._GOODS_DESCRIPTION_FIELD_KEYS or any( + token in label for token in cls._GOODS_DESCRIPTION_LABEL_TOKENS + ): + values.append(value) + return values + + @classmethod + def _collect_non_negated_keyword_hits(cls, text: str, keywords: list[str]) -> list[str]: + normalized = str(text or "") + if not normalized: + return [] + + hits: list[str] = [] + for keyword in keywords: + if not keyword: + continue + for match in re.finditer(re.escape(keyword), normalized): + window = normalized[max(0, match.start() - 12): match.end() + 12] + if any(marker in window for marker in cls._VAGUE_KEYWORD_NEGATION_MARKERS): + continue + hits.append(keyword) + break + return hits + def _evaluate_multi_city_reason_required_risk( self, manifest: dict[str, Any], diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index 3490802..f24d425 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -36,6 +36,7 @@ from app.services.agent_foundation import AgentFoundationService from app.services.audit import AuditLogService from app.services.document_intelligence import build_document_insight from app.services.document_numbering import is_application_claim_no +from app.services.budget_types import BudgetControlError from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin from app.services.expense_claim_approval_routing import ExpenseClaimApprovalRoutingMixin @@ -57,6 +58,7 @@ from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyRe from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin from app.services.expense_claim_risk_stage import with_risk_business_stage from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin +from app.services.receipt_folder import ReceiptFolderService from app.services.expense_claim_constants import ( EXPENSE_TYPE_LABELS, MAX_DRAFT_CLAIMS_PER_USER, @@ -320,6 +322,8 @@ class ExpenseClaimService( item.item_location = ( self._normalize_optional_text(payload.item_location, allow_empty=True) or "" ) + if payload.item_note is not None: + item.item_note = self._normalize_optional_text(payload.item_note, allow_empty=True) or "" if payload.item_amount is not None: amount = payload.item_amount.quantize(Decimal("0.01")) if amount < Decimal("0.00"): @@ -376,6 +380,7 @@ class ExpenseClaimService( or "other", item_reason=self._normalize_optional_text(payload.item_reason, fallback="") or "", item_location=self._normalize_optional_text(payload.item_location, fallback="") or "", + item_note=self._normalize_optional_text(payload.item_note, allow_empty=True) or "", item_amount=item_amount, invoice_id=self._normalize_optional_text(payload.invoice_id, allow_empty=True), ) @@ -462,11 +467,16 @@ class ExpenseClaimService( if missing_fields: raise ExpenseClaimSubmissionBlockedError(missing_fields) - budget_flags = self._reserve_budget_for_submission( - claim, - current_user, - is_application_claim=is_application_claim, - ) + try: + budget_flags = self._reserve_budget_for_submission( + claim, + current_user, + is_application_claim=is_application_claim, + ) + except BudgetControlError as exc: + if is_application_claim: + raise + budget_flags = list(exc.flags or []) before_json = self._serialize_claim(claim) if is_application_claim: submitted_at = datetime.now(UTC) @@ -576,6 +586,7 @@ class ExpenseClaimService( self._release_budget_for_delete(claim, current_user) self._delete_claim_analysis_records(resource_id) self._attachment_storage.delete_claim_files(claim) + ReceiptFolderService().delete_receipts_for_claim(resource_id) self.db.delete(claim) self.db.commit() @@ -747,11 +758,6 @@ class ExpenseClaimService( - - - - - diff --git a/server/src/app/services/finance_dashboard.py b/server/src/app/services/finance_dashboard.py index 7ec8ed7..df0a6db 100644 --- a/server/src/app/services/finance_dashboard.py +++ b/server/src/app/services/finance_dashboard.py @@ -49,8 +49,18 @@ class FinanceDashboardService(BudgetSupportMixin): now=now, ) previous_start = start - (end - start) - trend_start, trend_end, trend_labels = self._resolve_trend_scope(trend_range, now) - ranking_start, ranking_end = self._resolve_ranking_scope(department_range, now) + trend_start, trend_end, trend_labels = self._resolve_trend_scope( + trend_range, + now, + fallback_start=start, + fallback_end=end, + ) + ranking_start, ranking_end = self._resolve_ranking_scope( + department_range, + now, + fallback_start=start, + fallback_end=end, + ) claims = [ claim for claim in self._fetch_claims() if is_finance_reimbursement_claim(claim) @@ -127,10 +137,31 @@ class FinanceDashboardService(BudgetSupportMixin): self, trend_range: str, now: datetime, + *, + fallback_start: datetime | None = None, + fallback_end: datetime | None = None, ) -> tuple[datetime, datetime, list[str]]: - days = self._days_from_label(trend_range, default=12) - end_day = now.date() - start_day = end_day - timedelta(days=days - 1) + today = now.date() + key = str(trend_range or "").strip() + if key in {"custom", "自定义"} and fallback_start and fallback_end: + start_day = fallback_start.date() + end_day = (fallback_end - timedelta(days=1)).date() + elif key == "今日": + start_day = today + end_day = today + elif key == "本周": + start_day = today - timedelta(days=today.weekday()) + end_day = today + elif key == "本月": + start_day = today.replace(day=1) + end_day = today + else: + days = self._days_from_label(trend_range, default=12) + end_day = today + start_day = end_day - timedelta(days=days - 1) + if start_day > end_day: + start_day, end_day = end_day, start_day + days = max(1, (end_day - start_day).days + 1) labels = [self._date_label(start_day + timedelta(days=index)) for index in range(days)] return self._day_start(start_day), self._day_after(end_day), labels @@ -138,9 +169,32 @@ class FinanceDashboardService(BudgetSupportMixin): self, department_range: str, now: datetime, + *, + fallback_start: datetime | None = None, + fallback_end: datetime | None = None, ) -> tuple[datetime, datetime]: today = now.date() key = str(department_range or "").strip() + if key in {"custom", "自定义"} and fallback_start and fallback_end: + return fallback_start, fallback_end + if key == "今日": + return self._day_start(today), self._day_after(today) + if key == "本周": + start_day = today - timedelta(days=today.weekday()) + return self._day_start(start_day), self._day_after(today) + if key == "全部": + return datetime(1970, 1, 1, tzinfo=UTC), self._day_after(today) + if key == "本季度": + quarter_month = ((today.month - 1) // 3) * 3 + 1 + return self._day_start(today.replace(month=quarter_month, day=1)), self._day_after(today) + if key == "本年": + return self._day_start(today.replace(month=1, day=1)), self._day_after(today) + if key == "本月": + return self._day_start(today.replace(day=1)), self._day_after(today) + if re.search(r"\d+", key): + days = self._days_from_label(key, default=10) + start_day = today - timedelta(days=days - 1) + return self._day_start(start_day), self._day_after(today) if key == "全部": return datetime(1970, 1, 1, tzinfo=UTC), self._day_after(today) if key == "本季度": @@ -227,6 +281,8 @@ class FinanceDashboardService(BudgetSupportMixin): claim_count = [0 for _ in labels] claim_amount = [Decimal("0.00") for _ in labels] success_count = [0 for _ in labels] + category_amounts: dict[str, list[Decimal]] = {} + category_totals: dict[str, Decimal] = defaultdict(Decimal) hours: list[list[Decimal]] = [[] for _ in labels] index = {label: idx for idx, label in enumerate(labels)} @@ -237,8 +293,12 @@ class FinanceDashboardService(BudgetSupportMixin): if label not in index: continue bucket = index[label] + amount = self._claim_amount(claim) + category = self._expense_type_label(claim.expense_type) claim_count[bucket] += 1 - claim_amount[bucket] += self._claim_amount(claim) + claim_amount[bucket] += amount + category_amounts.setdefault(category, [Decimal("0.00") for _ in labels])[bucket] += amount + category_totals[category] += amount if self._status(claim) in SUCCESS_STATUSES: success_count[bucket] += 1 if claim.submitted_at: @@ -248,6 +308,17 @@ class FinanceDashboardService(BudgetSupportMixin): "labels": labels, "claimCount": claim_count, "claimAmount": [self._decimal_number(value) for value in claim_amount], + "categoryAmountSeries": [ + { + "name": name, + "color": CHART_COLORS[index % len(CHART_COLORS)], + "data": [self._decimal_number(value) for value in category_amounts[name]], + "total": self._decimal_number(category_totals[name]), + } + for index, name in enumerate( + sorted(category_amounts, key=lambda item: category_totals[item], reverse=True)[:6] + ) + ], "successCount": success_count, # 兼容旧前端字段;新财务看板不再使用审批趋势语义。 "applications": claim_count, diff --git a/server/src/app/services/ontology.py b/server/src/app/services/ontology.py index e769fde..1d01414 100644 --- a/server/src/app/services/ontology.py +++ b/server/src/app/services/ontology.py @@ -29,6 +29,7 @@ from app.services.agent_foundation import AgentFoundationService from app.services.agent_runs import AgentRunService from app.services.ontology_detection import OntologyDetectionMixin from app.services.ontology_extraction import OntologyExtractionMixin +from app.services.ontology_field_registry import normalize_ontology_context_json from app.services.ontology_rules import ( CONTEXTUAL_SCENARIOS, EXPENSE_REVIEW_ACTIONS, @@ -103,7 +104,8 @@ class SemanticOntologyService( raise ValueError("query 不能为空。") AgentFoundationService(self.db).ensure_foundation_ready() - context_json = payload.context_json or {} + context_json = normalize_ontology_context_json(payload.context_json or {}) + payload = payload.model_copy(update={"context_json": context_json}) reference = self._load_reference_catalog() compact_query = self._compact(query) entities = self._extract_entities(query, compact_query, reference, context_json=context_json) diff --git a/server/src/app/services/ontology_extraction.py b/server/src/app/services/ontology_extraction.py index 77c8a96..bdeb467 100644 --- a/server/src/app/services/ontology_extraction.py +++ b/server/src/app/services/ontology_extraction.py @@ -14,6 +14,7 @@ from app.schemas.ontology import ( OntologyTimeRange, ) from app.services.document_numbering import DOCUMENT_NUMBER_EXTRACT_PATTERN +from app.services.ontology_field_registry import normalize_ontology_form_values from app.services.ontology_budget import BudgetOntologyMixin from app.services.ontology_rules import ( AMOUNT_PATTERN, @@ -82,9 +83,7 @@ class OntologyExtractionMixin(BudgetOntologyMixin): ) if application_mode: - form_values = context_json.get("review_form_values") - if not isinstance(form_values, dict): - form_values = {} + form_values = normalize_ontology_form_values(context_json.get("review_form_values")) expense_type_codes = { str(item.normalized_value or item.value or "").strip() for item in entities @@ -95,17 +94,10 @@ class OntologyExtractionMixin(BudgetOntologyMixin): missing_slots.append("expense_type") if "amount" not in entity_types and not str(form_values.get("amount") or "").strip(): missing_slots.append("amount") - if not time_range.start_date and not ( - str(form_values.get("time_range") or form_values.get("business_time") or "").strip() - ): + if not time_range.start_date and not str(form_values.get("time_range") or "").strip(): missing_slots.append("time_range") - reason_value = str( - form_values.get("reason") - or form_values.get("business_reason") - or form_values.get("reason_value") - or "" - ).strip() - if not reason_value and compact_query in GENERIC_EXPENSE_APPLICATION_PROMPTS: + reason_text = str(form_values.get("reason") or "").strip() + if not reason_text and compact_query in GENERIC_EXPENSE_APPLICATION_PROMPTS: missing_slots.append("reason") if ( attachment_count <= 0 @@ -171,12 +163,33 @@ class OntologyExtractionMixin(BudgetOntologyMixin): ) -> list[OntologyEntity]: entities: dict[tuple[str, str], OntologyEntity] = {} context_json = context_json or {} + form_values = normalize_ontology_form_values(context_json.get("review_form_values")) def upsert(entity: OntologyEntity) -> None: key = (entity.type, entity.normalized_value) if key not in entities: entities[key] = entity + context_entity_specs = ( + ("expense_type", "expense_type", "filter", 0.86), + ("location", "location", "filter", 0.82), + ("reason", "reason", "target", 0.82), + ("amount", "amount", "target", 0.82), + ("transport_mode", "transport_mode", "filter", 0.9), + ) + for field_key, entity_type, role, confidence in context_entity_specs: + value = str(form_values.get(field_key) or "").strip() + if value: + upsert( + self._make_entity( + entity_type, + value, + value, + role=role, + confidence=confidence, + ) + ) + if ( self._is_expense_application_context_value(context_json) or self._has_expense_application_signal(compact_query) diff --git a/server/src/app/services/ontology_field_registry.py b/server/src/app/services/ontology_field_registry.py new file mode 100644 index 0000000..c151a12 --- /dev/null +++ b/server/src/app/services/ontology_field_registry.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +from typing import Any + + +ONTOLOGY_FIELD_ALIASES: dict[str, tuple[str, ...]] = { + "expense_type": ("reimbursement_type", "scene_label", "expenseType"), + "time_range": ( + "business_time", + "businessTime", + "occurred_date", + "occurredDate", + "application_business_time", + "applicationBusinessTime", + "application_time", + "applicationTime", + ), + "location": ( + "business_location", + "businessLocation", + "application_location", + "applicationLocation", + ), + "reason": ( + "reason_value", + "reasonValue", + "business_reason", + "businessReason", + "application_reason", + "applicationReason", + ), + "amount": ( + "application_amount", + "applicationAmount", + "application_amount_label", + "applicationAmountLabel", + ), + "transport_mode": ( + "transport_type", + "transportType", + "transportMode", + "application_transport_mode", + "applicationTransportMode", + ), + "attachments": ("attachment_names", "attachmentNames"), + "customer_name": ("customerName",), + "merchant_name": ("merchantName",), + "cost_center": ("costCenter",), + "department_name": ("department", "departmentName", "deptName"), + "employee_grade": ("grade", "user_grade", "employeeGrade", "position_grade"), + "employee_name": ("name", "user_name", "applicant", "claimant_name", "reporter_name"), + "employee_no": ("employeeNo",), + "employee_position": ("position", "employeePosition"), + "manager_name": ("managerName", "direct_manager_name", "directManagerName"), +} + +CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset( + { + "participants", + "department", + "budget_period", + "budget_subject", + "budget_amount", + "cost_center", + "warning_threshold", + "control_action", + "employee_location", + "employee_risk_profile", + "finance_owner_name", + "document_id", + "application_claim_id", + "application_claim_no", + "application_days", + "application_date", + "application_lodging_daily_cap", + "application_subsidy_daily_cap", + "application_transport_policy", + "application_policy_estimate", + "application_rule_name", + "application_rule_version", + } +) + +ONTOLOGY_CONTEXT_METADATA_FIELDS = frozenset( + { + "_claim_no_retry_count", + "actor", + "application_edit_claim_id", + "application_edit_mode", + "applicationEditClaimId", + "applicationEditMode", + "application_fields", + "application_preview", + "application_stage", + "attachment_count", + "attachment_names", + "business_time_context", + "budget_details", + "budget_header", + "client_now_iso", + "client_timezone_offset_minutes", + "conversation_history", + "conversation_id", + "conversation_intent", + "conversation_scenario", + "conversation_state", + "document_type", + "draft_claim_id", + "dry_run_email", + "email", + "entry_source", + "expense_scene_selection", + "force", + "is_admin", + "ocr_documents", + "ocr_summary", + "report_type", + "request_context", + "requested_by_name", + "requested_by_username", + "review_action", + "review_document_form_values", + "review_form_values", + "role_codes", + "role", + "send_email", + "session_type", + "simulate_orchestrator_exception", + "simulate_tool_failure", + "time_range_raw", + "user_id", + "user_input_text", + "username", + } +) + +REGISTERED_ONTOLOGY_CONTEXT_FIELDS = ( + CANONICAL_ONTOLOGY_FIELDS + | ONTOLOGY_CONTEXT_METADATA_FIELDS + | frozenset(alias for aliases in ONTOLOGY_FIELD_ALIASES.values() for alias in aliases) +) + + +def normalize_ontology_form_values(values: Any) -> dict[str, str]: + if not isinstance(values, dict): + return {} + + normalized: dict[str, str] = {} + for key, value in values.items(): + cleaned_key = str(key or "").strip() + if not cleaned_key: + continue + normalized[cleaned_key] = str(value or "").strip() + + for canonical_key, aliases in ONTOLOGY_FIELD_ALIASES.items(): + if normalized.get(canonical_key): + continue + for alias in aliases: + if normalized.get(alias): + normalized[canonical_key] = normalized[alias] + break + + return normalized + + +def normalize_ontology_context_json(context_json: Any) -> dict[str, Any]: + if not isinstance(context_json, dict): + return {} + + normalized = dict(context_json) + for canonical_key, aliases in ONTOLOGY_FIELD_ALIASES.items(): + if normalized.get(canonical_key): + continue + for alias in aliases: + if normalized.get(alias): + normalized[canonical_key] = normalized[alias] + break + form_values = normalize_ontology_form_values(normalized.get("review_form_values")) + if form_values: + normalized["review_form_values"] = form_values + return normalized + + +def is_registered_ontology_context_field(field_name: str) -> bool: + return str(field_name or "").strip() in REGISTERED_ONTOLOGY_CONTEXT_FIELDS diff --git a/server/src/app/services/receipt_folder.py b/server/src/app/services/receipt_folder.py index f5690bf..2f0a481 100644 --- a/server/src/app/services/receipt_folder.py +++ b/server/src/app/services/receipt_folder.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import hashlib import mimetypes import re import shutil @@ -85,6 +86,26 @@ class ReceiptFolderService: if not self._should_persist_source(filename, content): enriched.append(document) continue + duplicate_receipt = self.find_duplicate_receipt( + filename=filename, + content=content, + current_user=current_user, + ) + if duplicate_receipt is not None: + warning = "已上传过同样的单据,请不要重复上传。" + existing_warnings = [str(item) for item in list(document.warnings or []) if str(item).strip()] + enriched.append( + document.model_copy( + update={ + "receipt_id": duplicate_receipt.id, + "receipt_status": duplicate_receipt.status, + "receipt_preview_url": duplicate_receipt.preview_url, + "receipt_source_url": duplicate_receipt.source_url, + "warnings": list(dict.fromkeys([*existing_warnings, warning])), + } + ) + ) + continue receipt = self.save_receipt( filename=filename, content=content, @@ -140,6 +161,7 @@ class ReceiptFolderService: "source_file_name": normalized_name, "media_type": resolved_media_type, "size_bytes": len(content), + "file_sha256": self._content_hash(content), "uploaded_at": now.isoformat(), "status": "linked" if linked else "unlinked", "linked_claim_id": str(linked_claim_id or "").strip(), @@ -243,8 +265,24 @@ class ReceiptFolderService: ], fields=self._resolve_fields(meta), raw_meta=meta, + edit_logs=self._resolve_edit_logs(meta), ) + def find_duplicate_receipt( + self, + *, + filename: str, + content: bytes, + current_user: CurrentUserContext, + ) -> ReceiptFolderItemRead | None: + if not self._should_persist_source(filename, content): + return None + file_hash = self._content_hash(content) + for meta in self._iter_owner_meta(self._owner_key(current_user)): + if file_hash and str(meta.get("file_sha256") or "").strip() == file_hash: + return self._build_item(meta) + return None + def update_receipt( self, *, @@ -255,6 +293,7 @@ class ReceiptFolderService: owner_key = self._owner_key(current_user) receipt_dir = self._receipt_dir(owner_key, receipt_id) meta = self._read_meta(receipt_dir) + before_meta = json.loads(json.dumps(meta, ensure_ascii=False)) updates = payload.model_dump(exclude_unset=True) for key in ("document_type", "document_type_label", "scene_code", "scene_label", "summary"): if key in updates and updates[key] is not None: @@ -270,6 +309,18 @@ class ReceiptFolderService: for field in payload.fields or [] ] meta["editable_fields"] = editable + changes = self._build_edit_changes(before_meta, meta) + if changes: + logs = list(meta.get("edit_logs") or []) + logs.insert( + 0, + { + "operated_at": datetime.now(UTC).isoformat(), + "operator": self._operator_label(current_user), + "changes": changes, + }, + ) + meta["edit_logs"] = logs[:50] meta["updated_at"] = datetime.now(UTC).isoformat() self._write_meta(receipt_dir, meta) return self.get_receipt(receipt_id, current_user) @@ -285,6 +336,23 @@ class ReceiptFolderService: shutil.rmtree(receipt_dir) return ReceiptFolderDeleteResponse(message="票据已删除。", receipt_id=receipt_id) + def delete_receipts_for_claim(self, claim_id: str) -> int: + normalized_claim_id = str(claim_id or "").strip() + if not normalized_claim_id: + return 0 + deleted_count = 0 + self.root.mkdir(parents=True, exist_ok=True) + for meta_path in list(self.root.glob("*/*/meta.json")): + try: + meta = self._read_meta(meta_path.parent) + except FileNotFoundError: + continue + if str(meta.get("linked_claim_id") or "").strip() != normalized_claim_id: + continue + shutil.rmtree(meta_path.parent, ignore_errors=True) + deleted_count += 1 + return deleted_count + def resolve_source(self, receipt_id: str, current_user: CurrentUserContext) -> tuple[Path, str, str]: meta = self._read_receipt_meta(receipt_id, current_user) receipt_dir = self._receipt_dir(self._owner_key(current_user), receipt_id) @@ -501,6 +569,14 @@ class ReceiptFolderService: encoding="utf-8", ) + @staticmethod + def _content_hash(content: bytes) -> str: + return hashlib.sha256(content or b"").hexdigest() if content else "" + + @staticmethod + def _operator_label(current_user: CurrentUserContext) -> str: + return str(current_user.name or current_user.username or "当前用户").strip() or "当前用户" + @staticmethod def _matches_status(meta: dict[str, Any], status_filter: str) -> bool: if status_filter in {"", "all"}: @@ -557,6 +633,97 @@ class ReceiptFolderService: ] return fields + def _resolve_edit_logs(self, meta: dict[str, Any]) -> list[dict[str, Any]]: + logs = [] + for log in list(meta.get("edit_logs") or []): + if not isinstance(log, dict): + continue + changes = [ + { + "key": str(change.get("key") or ""), + "label": str(change.get("label") or ""), + "before": str(change.get("before") or ""), + "after": str(change.get("after") or ""), + } + for change in list(log.get("changes") or []) + if isinstance(change, dict) + and str(change.get("label") or change.get("key") or "").strip() + ] + if not changes: + continue + logs.append( + { + "operated_at": self._parse_datetime(log.get("operated_at")), + "operator": str(log.get("operator") or "当前用户").strip() or "当前用户", + "changes": changes, + } + ) + return logs + + def _build_edit_changes(self, before_meta: dict[str, Any], after_meta: dict[str, Any]) -> list[dict[str, str]]: + before_values = self._flatten_editable_receipt_values(before_meta) + after_values = self._flatten_editable_receipt_values(after_meta) + changes = [] + for key in sorted(set(before_values) | set(after_values)): + before = before_values.get(key, {}) + after = after_values.get(key, {}) + before_value = str(before.get("value") or "").strip() + after_value = str(after.get("value") or "").strip() + if before_value == after_value: + continue + label = str(after.get("label") or before.get("label") or key).strip() + changes.append( + { + "key": key, + "label": label, + "before": before_value, + "after": after_value, + } + ) + return changes + + def _flatten_editable_receipt_values(self, meta: dict[str, Any]) -> dict[str, dict[str, str]]: + values = { + "document_type_label": { + "label": "票据类型", + "value": str(meta.get("document_type_label") or "").strip(), + }, + "scene_label": { + "label": "费用场景", + "value": str(meta.get("scene_label") or "").strip(), + }, + "summary": { + "label": "摘要", + "value": str(meta.get("summary") or "").strip(), + }, + "amount": { + "label": "金额", + "value": self._resolve_editable_or_field(meta, "amount", labels=("金额", "价税合计", "票价")), + }, + "document_date": { + "label": "票据日期", + "value": self._resolve_receipt_document_date(meta), + }, + "merchant_name": { + "label": "商户", + "value": self._resolve_receipt_merchant_name(meta), + }, + } + for index, field in enumerate(list(meta.get("document_fields") or [])): + if not isinstance(field, dict): + continue + key = str(field.get("key") or "").strip() + label = str(field.get("label") or "").strip() + value = str(field.get("value") or "").strip() + stable_key = key or f"field_{index}_{label}" + if not stable_key and not label: + continue + values[stable_key] = { + "label": label or stable_key, + "value": value, + } + return values + def _resolve_receipt_document_date(self, meta: dict[str, Any]) -> str: editable = meta.get("editable_fields") if isinstance(editable, dict): diff --git a/server/src/app/services/risk_rule_dsl_validator.py b/server/src/app/services/risk_rule_dsl_validator.py index 9e1ffb0..bd39f03 100644 --- a/server/src/app/services/risk_rule_dsl_validator.py +++ b/server/src/app/services/risk_rule_dsl_validator.py @@ -202,7 +202,7 @@ def _build_structured_conditions(text: str, fields: list[RiskRuleField]) -> list field_keys = [field.key for field in fields] attachment_fields = [key for key in field_keys if key.startswith("attachment.")] city_left = [key for key in field_keys if key in {"attachment.hotel_city", "attachment.route_cities"}] - city_right = [key for key in field_keys if key in {"claim.location", "item.item_location", "employee.location"}] + city_right = [key for key in field_keys if key in {"claim.location", "item.item_location"}] date_fields = [key for key in field_keys if _field_type(key, fields) == "date" and key.startswith("attachment.")] range_start = [key for key in field_keys if key in {"claim.trip_start_date", "item.item_date"}] range_end = [key for key in field_keys if key in {"claim.trip_end_date", "item.item_date"}] diff --git a/server/src/app/services/risk_rule_execution_trace.py b/server/src/app/services/risk_rule_execution_trace.py index 35694e8..4c76b3d 100644 --- a/server/src/app/services/risk_rule_execution_trace.py +++ b/server/src/app/services/risk_rule_execution_trace.py @@ -65,9 +65,10 @@ def _build_condition_steps(manifest: dict[str, Any], evidence: dict[str, Any]) - ), "operator": "route_city_consistency", "inputs": { + "application_reference_values": city_consistency.get("application_reference_values") or [], + "claim_reference_values": city_consistency.get("claim_reference_values") or [], "attachment_values": city_consistency.get("attachment_values") or [], "reference_values": city_consistency.get("reference_values") or [], - "home_values": city_consistency.get("home_values") or [], "unexpected_route_cities": city_consistency.get("unexpected_route_cities") or [], "explanation_hits": city_consistency.get("explanation_hits") or [], }, diff --git a/server/src/app/services/risk_rule_generation.py b/server/src/app/services/risk_rule_generation.py index cd7fed3..05088d8 100644 --- a/server/src/app/services/risk_rule_generation.py +++ b/server/src/app/services/risk_rule_generation.py @@ -621,7 +621,6 @@ class RiskRuleGenerationService: in { "claim.reason", "claim.location", - "employee.location", "item.item_date", "item.item_reason", "item.item_location", diff --git a/server/src/app/services/risk_rule_generation_ontology.py b/server/src/app/services/risk_rule_generation_ontology.py index d535a18..7759233 100644 --- a/server/src/app/services/risk_rule_generation_ontology.py +++ b/server/src/app/services/risk_rule_generation_ontology.py @@ -111,7 +111,7 @@ FIELD_ONTOLOGY: tuple[RiskRuleField, ...] = ( "员工常驻地", "text", "employee", - ("常驻地", "办公地", "员工所在地", "出发地", "所在城市"), + ("常驻地", "办公地", "员工所在地", "所在城市"), ), RiskRuleField("item.item_type", "费用类型", "enum", "item", ("费用类型", "科目", "类型")), RiskRuleField("item.item_reason", "明细事由", "text", "item", ("明细事由", "明细说明")), diff --git a/server/src/app/services/risk_rule_generation_prompt.py b/server/src/app/services/risk_rule_generation_prompt.py index 33ebe2c..f38b92c 100644 --- a/server/src/app/services/risk_rule_generation_prompt.py +++ b/server/src/app/services/risk_rule_generation_prompt.py @@ -105,9 +105,10 @@ def build_risk_rule_compiler_messages( "重复发票、同一票据号、重复报销等规则必须用 duplicate_value;例如 attachment.invoice_no 在本次附件或明细中出现重复,不得写成重复风险关键词匹配。", "差旅路线规则中,交通票行程城市和住宿发票城市属于附件城市集合。", "申报目的地和明细发生地点属于申报行程城市集合。", - "员工常驻地/出发地如可用,属于合理起终点集合,不等同于申报目的地。", + "员工常驻地只能作为员工档案背景,不能作为本次出发地或返回地的硬依据。", + "本次出发地和返回地应来自申请单明确字段或交通票路线本身。", "绕行、跨城办事、临时改签是例外说明证据,不是风险命中关键词。", - "如果票据路线出现申报目的地和常驻地之外的额外城市,应描述为中途周转/绕行异常。", + "如果票据路线出现无法由本次票据起终点和申报目的地解释的额外城市,应描述为中途周转/绕行异常。", "keyword_match_v1 只用于品名、摘要、票据全文中出现明确风险词的规则。", "不要直接指定 risk_level 或 risk_score;只输出 risk_scoring_evidence,后端会按固定评分模型计算 0-100 分和风险等级。", "评分证据必须围绕六个指标:业务影响、违规确定性、证据强度、例外/规避空间、处置强度、场景敏感度。", @@ -128,13 +129,12 @@ def build_risk_rule_compiler_messages( "attachment.hotel_city", "claim.location", "item.item_location", - "employee.location", "claim.reason", "item.item_reason", ], "condition_summary": ( "A=交通票行程城市∪住宿发票城市,B=申报目的地∪明细发生地点," - "C=员工常驻地/合理起终点;A与B无交集且无合理说明,或A中出现B∪C之外城市时命中。" + "A与B无交集且无合理说明,或A中出现无法由本次票据起终点和申报目的地解释的额外城市时命中。" ), "keywords": [], "exception_keywords": ["绕行", "跨城办事", "临时改签"], diff --git a/server/src/app/services/risk_rule_generation_semantics.py b/server/src/app/services/risk_rule_generation_semantics.py index 981dd71..1adadc7 100644 --- a/server/src/app/services/risk_rule_generation_semantics.py +++ b/server/src/app/services/risk_rule_generation_semantics.py @@ -19,7 +19,7 @@ RISK_LEVEL_LABELS = { CITY_ATTACHMENT_FIELDS = ("attachment.route_cities", "attachment.hotel_city") CITY_REFERENCE_FIELDS = ("claim.location", "item.item_location") -CITY_HOME_FIELDS = ("employee.location",) +CITY_HOME_FIELDS: tuple[str, ...] = () CITY_EXCEPTION_FIELDS = ("claim.reason", "item.item_reason") CITY_EXCEPTION_KEYWORDS = ("绕行", "跨城办事", "跨城", "临时改签", "改签", "变更") @@ -64,8 +64,9 @@ def build_city_consistency_draft( risk_label = RISK_LEVEL_LABELS.get(risk_level, "风险") condition_summary = ( "判断公式:A=交通票行程城市∪住宿发票城市,B=申报目的地∪明细发生地点," - "C=员工常驻地/合理出发地。若A或B为空则要求补充识别;若A与B无交集且无合理说明," - "或票据路线中存在不属于B∪C的额外城市,则命中目的地不一致/中途周转异常风险。" + "若A或B为空则要求补充识别;若A与B无交集且无合理说明," + "或票据路线中存在无法由本次票据起终点和申报目的地解释的额外城市," + "则命中目的地不一致/中途周转异常风险。" ) flow = draft.get("flow") if isinstance(draft.get("flow"), dict) else {} return { @@ -79,9 +80,9 @@ def build_city_consistency_draft( "flow": { **flow, "start": "差旅报销单据提交,并上传交通票据、住宿票据或其他可识别城市的附件", - "evidence": "读取员工常驻地、申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由", - "decision": "附件城市是否覆盖申报行程,且票据路线是否出现申报目的地和常驻地之外的中转城市", - "pass": "票据城市覆盖申报行程,且未出现申报目的地和常驻地之外的额外城市", + "evidence": "读取申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由", + "decision": "附件城市是否覆盖申报行程,且票据路线是否出现无法由本次票据起终点和申报目的地解释的中转城市", + "pass": "票据城市覆盖申报行程,且未出现无法由本次票据起终点和申报目的地解释的额外城市", "fail": f"票据路线存在目的地不一致或额外中转城市,命中{risk_label}并要求补充说明或退回修改", }, } @@ -102,16 +103,15 @@ def build_city_consistency_params(draft: dict[str, Any]) -> dict[str, Any]: "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)" + "OR EXISTS(city IN route_cities WHERE city NOT EXPLAINED BY B OR ROUTE_ENDPOINTS)" ), "conditions": [ { "left_group": list(CITY_ATTACHMENT_FIELDS), "operator": "route_city_consistency", "right_group": list(CITY_REFERENCE_FIELDS), - "home_group": list(CITY_HOME_FIELDS), + "home_group": [], "exception_fields": list(CITY_EXCEPTION_FIELDS), "exception_keywords": exception_keywords, } diff --git a/server/src/app/services/risk_rule_manifest_classifier.py b/server/src/app/services/risk_rule_manifest_classifier.py new file mode 100644 index 0000000..78a4925 --- /dev/null +++ b/server/src/app/services/risk_rule_manifest_classifier.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from typing import Any + + +BUDGET_RISK_STAGES = {"budget_execution", "budget_control", "budget_review"} + + +def is_budget_risk_manifest(manifest: dict[str, Any]) -> bool: + """判断规则是否属于预算治理风险,而不是普通费用行为风险。""" + + if not isinstance(manifest, dict): + return False + + metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {} + applies_to = manifest.get("applies_to") if isinstance(manifest.get("applies_to"), dict) else {} + rule_code = str(manifest.get("rule_code") or "").strip().lower() + finance_rule_code = str( + manifest.get("finance_rule_code") or metadata.get("finance_rule_code") or "" + ).strip().lower() + + if rule_code.startswith("risk.budget.") or rule_code.startswith("budget."): + return True + if finance_rule_code.startswith("budget."): + return True + if _normalized_text(manifest.get("risk_domain") or metadata.get("risk_domain")) == "budget": + return True + + domains = {_normalized_text(value) for value in _as_list(applies_to.get("domains"))} + if "budget" in domains and not domains.difference({"budget"}): + return True + + stages = { + _normalized_text(value) + for value in [ + *_as_list(manifest.get("business_stage")), + *_as_list(metadata.get("business_stage")), + *_as_list(applies_to.get("business_stages")), + ] + } + if stages & BUDGET_RISK_STAGES: + return True + + category_text = " ".join( + str(value or "") + for value in ( + manifest.get("risk_category"), + metadata.get("risk_category"), + manifest.get("name"), + ) + ) + if "预算" in category_text and any(key.startswith("budget.") for key in _iter_field_keys(manifest)): + return True + + return any(key.startswith("budget.") for key in _iter_field_keys(manifest)) + + +def _iter_field_keys(value: Any) -> list[str]: + keys: list[str] = [] + + def visit(node: Any) -> None: + if isinstance(node, dict): + for key, item in node.items(): + normalized_key = str(key or "").strip() + if normalized_key in { + "key", + "field", + "left", + "right", + "field_key", + "fieldKey", + }: + _append_key(item) + elif normalized_key in { + "fields", + "field_keys", + "fieldKeys", + "search_fields", + "searchFields", + "left_fields", + "leftFields", + "right_fields", + "rightFields", + "left_group", + "leftGroup", + "right_group", + "rightGroup", + "date_fields", + "range_start_fields", + "range_end_fields", + }: + for child in _as_list(item): + _append_key(child) + visit(item) + return + if isinstance(node, list): + for item in node: + visit(item) + + def _append_key(item: Any) -> None: + text = str(item or "").strip().lower() + if text and text not in keys: + keys.append(text) + + visit(value) + return keys + + +def _as_list(value: Any) -> list[Any]: + if isinstance(value, list): + return value + if isinstance(value, (tuple, set)): + return list(value) + if value in (None, ""): + return [] + return [value] + + +def _normalized_text(value: Any) -> str: + return str(value or "").strip().lower() diff --git a/server/src/app/services/risk_rule_manifest_normalizer.py b/server/src/app/services/risk_rule_manifest_normalizer.py index 53a6c30..108a9c1 100644 --- a/server/src/app/services/risk_rule_manifest_normalizer.py +++ b/server/src/app/services/risk_rule_manifest_normalizer.py @@ -28,14 +28,15 @@ RISK_LEVEL_LABELS = { CITY_ROUTE_CONDITION_SUMMARY = ( "判断公式:A=交通票行程城市∪住宿发票城市,B=申报目的地∪明细发生地点," - "C=员工常驻地/合理出发地。若A或B为空则要求补充识别;若A与B无交集且无合理说明," - "或票据路线中存在不属于B∪C的额外城市,则命中目的地不一致/中途周转异常风险。" + "若A或B为空则要求补充识别;若A与B无交集且无合理说明," + "或票据路线中存在无法由本次票据起终点和申报目的地解释的额外城市," + "则命中目的地不一致/中途周转异常风险。" ) CITY_ROUTE_FLOW_DECISION = ( - "附件城市是否覆盖申报行程,且票据路线是否出现申报目的地和常驻地之外的中转城市" + "附件城市是否覆盖申报行程,且票据路线是否出现无法由本次票据起终点和申报目的地解释的中转城市" ) CITY_ROUTE_FLOW_EVIDENCE = ( - "读取员工常驻地、申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由" + "读取申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由" ) @@ -82,7 +83,7 @@ def normalize_risk_rule_manifest(manifest: dict[str, Any]) -> dict[str, Any]: ) flow.setdefault( "pass", - "票据城市覆盖申报行程,且未出现申报目的地和常驻地之外的额外城市", + "票据城市覆盖申报行程,且未出现无法由本次票据起终点和申报目的地解释的额外城市", ) flow["fail"] = ( f"票据路线存在目的地不一致或额外中转城市,命中{severity_label}并要求补充说明或退回修改" diff --git a/server/src/app/services/risk_rule_template_catalog.py b/server/src/app/services/risk_rule_template_catalog.py index 58b3150..5546e04 100644 --- a/server/src/app/services/risk_rule_template_catalog.py +++ b/server/src/app/services/risk_rule_template_catalog.py @@ -212,14 +212,13 @@ _TEMPLATE_DEFINITIONS: tuple[dict[str, Any], ...] = ( "requires_attachment": True, "natural_language": ( "差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;" - "再读取员工常驻地、申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由。" + "再读取申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由。" "若交通票或住宿票据中的城市无法与申报目的地、明细地点形成一致关系," - "或票据路线中出现申报目的地与员工常驻地之外的额外中转城市," + "或票据路线中出现无法由本次票据起终点和申报目的地解释的额外中转城市," "且报销事由中没有说明绕行、跨城办事或临时改签原因," "则标记为高风险,要求补充行程说明或退回修改。" ), "field_keys": [ - "employee.location", "claim.location", "item.item_location", "attachment.route_cities", @@ -236,7 +235,7 @@ _TEMPLATE_DEFINITIONS: tuple[dict[str, Any], ...] = ( "id": "city_outside_business_scope", "operator": "not_in_scope", "left_fields": ["attachment.route_cities", "attachment.hotel_city"], - "right_fields": ["claim.location", "item.item_location", "employee.location"], + "right_fields": ["claim.location", "item.item_location"], }, { "id": "missing_route_exception", diff --git a/server/src/app/services/risk_rule_template_executor.py b/server/src/app/services/risk_rule_template_executor.py index 9ef9f56..8517fe7 100644 --- a/server/src/app/services/risk_rule_template_executor.py +++ b/server/src/app/services/risk_rule_template_executor.py @@ -198,25 +198,23 @@ class RiskRuleTemplateExecutor: for key in field_keys if key in {"attachment.route_cities", "attachment.hotel_city"} ] or ["attachment.route_cities", "attachment.hotel_city"] - home_keys = self._read_string_list(params.get("home_city_fields")) or ["employee.location"] - reference_values: list[str] = [] + application_reference_values: list[str] = [] attachment_values: list[str] = [] - home_values: list[str] = [] route_values: list[str] = [] for key in reference_keys: reference_values.extend(self._resolve_values(key, claim=claim, contexts=contexts)) + application_reference_values.extend(self._iter_application_location_values(claim)) + reference_values.extend(application_reference_values) for key in attachment_keys: resolved = self._resolve_values(key, claim=claim, contexts=contexts) attachment_values.extend(resolved) if key == "attachment.route_cities": route_values.extend(resolved) - for key in home_keys: - home_values.extend(self._resolve_values(key, claim=claim, contexts=contexts)) - + route_sequence_values = list(route_values) reference_values = self._dedupe_values(reference_values) + application_reference_values = self._dedupe_values(application_reference_values) attachment_values = self._dedupe_values(attachment_values) - home_values = self._dedupe_values(home_values) route_values = self._dedupe_values(route_values) if not reference_values or not attachment_values: return None @@ -239,9 +237,8 @@ class RiskRuleTemplateExecutor: if keyword and keyword in explanation_corpus ] unexpected_route_cities = self._resolve_unexpected_route_cities( - route_values, + route_sequence_values, reference_values=reference_values, - home_values=home_values, ) has_destination_overlap = self._condition_passes( "overlap", @@ -252,7 +249,7 @@ class RiskRuleTemplateExecutor: return None reason = ( - "票据路线包含申报行程和常驻地之外的中转城市。" + "票据路线包含无法由申请单、报销单或附件起终点解释的额外城市。" if unexpected_route_cities else "票据城市与申报目的地或明细地点不一致,且未说明绕行、跨城或改签原因。" ) @@ -280,9 +277,15 @@ class RiskRuleTemplateExecutor: "reasonable_exception": bool(keyword_hits), }, "city_consistency": { + "application_reference_values": application_reference_values[:8], + "claim_reference_values": self._dedupe_values( + [ + *self._resolve_values("claim.location", claim=claim, contexts=contexts), + *self._resolve_values("item.item_location", claim=claim, contexts=contexts), + ] + )[:8], "attachment_values": attachment_values[:8], "reference_values": reference_values[:8], - "home_values": home_values[:8], "route_values": route_values[:8], "unexpected_route_cities": unexpected_route_cities[:8], "explanation_keywords": explanation_keywords[:8], @@ -609,14 +612,19 @@ class RiskRuleTemplateExecutor: route_values: list[str], *, reference_values: list[str], - home_values: list[str], ) -> list[str]: if len(route_values) < 2: return [] - allowed_values = [value for value in [*reference_values, *home_values] if value] + allowed_values = [value for value in reference_values if value] if not allowed_values: return [] - candidates = route_values if home_values else route_values[1:-1] + allowed_values.extend( + RiskRuleTemplateExecutor._resolve_inferred_route_endpoint_values( + route_values, + reference_values=reference_values, + ) + ) + candidates = route_values unexpected: list[str] = [] for city in candidates: if RiskRuleTemplateExecutor._values_overlap([city], allowed_values): @@ -625,6 +633,37 @@ class RiskRuleTemplateExecutor: unexpected.append(city) return unexpected + @staticmethod + def _resolve_inferred_route_endpoint_values( + route_values: list[str], + *, + reference_values: list[str], + ) -> list[str]: + if len(route_values) < 2 or not reference_values: + return [] + has_declared_destination = any( + RiskRuleTemplateExecutor._values_overlap([city], reference_values) + for city in route_values + ) + if not has_declared_destination: + return [] + + inferred: list[str] = [] + first_city = str(route_values[0] or "").strip() + last_city = str(route_values[-1] or "").strip() + if first_city: + inferred.append(first_city) + if ( + last_city + and ( + len(route_values) == 2 + or RiskRuleTemplateExecutor._values_overlap([last_city], [first_city]) + ) + and last_city not in inferred + ): + inferred.append(last_city) + return inferred + @staticmethod def _expand_route_city_values(values: list[Any]) -> list[Any]: expanded: list[Any] = [] @@ -750,6 +789,56 @@ class RiskRuleTemplateExecutor: return parsed.year return None + @staticmethod + def _iter_application_location_values(claim: ExpenseClaim) -> list[Any]: + values: list[Any] = [] + application_sources = {"application_detail", "application_handoff", "application_link"} + location_keys = ( + "application_location", + "applicationLocation", + "business_location", + "businessLocation", + "location", + "destination", + "destination_city", + "destinationCity", + "matched_city", + "matchedCity", + ) + nested_keys = ( + "application_detail", + "applicationDetail", + "review_form_values", + "reviewFormValues", + "expense_scene_selection", + "expenseSceneSelection", + ) + for flag in list(getattr(claim, "risk_flags_json", None) or []): + if not isinstance(flag, dict): + continue + source = str(flag.get("source") or "").strip() + has_application_anchor = ( + source in application_sources + or any(key in flag for key in ("application_claim_no", "applicationClaimNo")) + or any( + isinstance(flag.get(key), dict) + for key in ("application_detail", "applicationDetail") + ) + ) + if not has_application_anchor: + continue + sources: list[dict[str, Any]] = [flag] + for key in nested_keys: + nested = flag.get(key) + if isinstance(nested, dict): + sources.append(nested) + for source_dict in sources: + for key in location_keys: + value = source_dict.get(key) + if value not in (None, ""): + values.append(value) + return RiskRuleTemplateExecutor._normalize_values(values) + @staticmethod def _iter_application_time_values(claim: ExpenseClaim) -> list[Any]: values: list[Any] = [] diff --git a/server/src/app/services/user_agent_review_core.py b/server/src/app/services/user_agent_review_core.py index d43f353..74808a4 100644 --- a/server/src/app/services/user_agent_review_core.py +++ b/server/src/app/services/user_agent_review_core.py @@ -35,6 +35,7 @@ from app.schemas.user_agent import ( from app.services.agent_assets import AgentAssetService from app.services.expense_claims import ExpenseClaimService from app.services.expense_rule_runtime import ExpenseRuleRuntimeService, RuntimeTravelPolicy, resolve_document_type_label +from app.services.ontology_field_registry import normalize_ontology_form_values from app.services.risk_ontology_bridge import resolve_rule_codes_for_risk_check from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService from app.services.user_agent_constants import * @@ -49,8 +50,8 @@ class UserAgentReviewCoreMixin: return False if str(payload.context_json.get("review_action") or "").strip(): return False - review_form_values = self._resolve_review_form_values(payload) - if str(review_form_values.get("expense_type") or review_form_values.get("reimbursement_type") or "").strip(): + review_form_values = normalize_ontology_form_values(self._resolve_review_form_values(payload)) + if str(review_form_values.get("expense_type") or "").strip(): return False if self._resolve_attachment_count(payload) > 0 or self._resolve_ocr_documents(payload): return False diff --git a/server/src/app/services/user_agent_review_slots.py b/server/src/app/services/user_agent_review_slots.py index e5c092b..3c58a28 100644 --- a/server/src/app/services/user_agent_review_slots.py +++ b/server/src/app/services/user_agent_review_slots.py @@ -38,6 +38,7 @@ from app.services.expense_rule_runtime import ExpenseRuleRuntimeService, Runtime from app.services.risk_ontology_bridge import resolve_rule_codes_for_risk_check from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService from app.services.expense_type_keywords import resolve_expense_type_label_from_text +from app.services.ontology_field_registry import normalize_ontology_form_values from app.services.user_agent_constants import * @@ -151,10 +152,9 @@ class UserAgentReviewSlotMixin: def _resolve_location_value(self, payload: UserAgentRequest) -> str: review_form_values = self._resolve_review_form_values(payload) - for key in ("business_location", "location"): - value = str(review_form_values.get(key) or "").strip() - if value: - return value + value = str(review_form_values.get("location") or "").strip() + if value: + return value if str(payload.context_json.get("entry_source") or "").strip() == "detail": request_context = payload.context_json.get("request_context") @@ -181,21 +181,7 @@ class UserAgentReviewSlotMixin: @staticmethod def _resolve_review_form_values(payload: UserAgentRequest) -> dict[str, str]: - values = payload.context_json.get("review_form_values") - if not isinstance(values, dict): - return {} - normalized: dict[str, str] = {} - for key, value in values.items(): - cleaned_key = str(key or "").strip() - if not cleaned_key: - continue - normalized[cleaned_key] = str(value or "").strip() - if not normalized.get("transport_mode"): - for alias in ("transportMode", "application_transport_mode", "applicationTransportMode"): - if normalized.get(alias): - normalized["transport_mode"] = normalized[alias] - break - return normalized + return normalize_ontology_form_values(payload.context_json.get("review_form_values")) @staticmethod @@ -220,12 +206,7 @@ class UserAgentReviewSlotMixin: def _build_time_slot(self, payload: UserAgentRequest) -> dict[str, str | float]: review_form_values = self._resolve_review_form_values(payload) - edited_value = str( - review_form_values.get("time_range") - or review_form_values.get("business_time") - or review_form_values.get("occurred_date") - or "" - ).strip() + edited_value = str(review_form_values.get("time_range") or "").strip() if edited_value: raw_value = str(review_form_values.get("time_range_raw") or edited_value).strip() return self._build_slot_value( @@ -237,17 +218,6 @@ class UserAgentReviewSlotMixin: evidence="来源于用户修改后的结构化表单。", ) - application_time = str(review_form_values.get("application_business_time") or "").strip() - if application_time: - return self._build_slot_value( - value=application_time, - raw_value=application_time, - normalized_value=application_time, - source="detail_context", - confidence=0.86, - evidence="来源于已关联申请单,作为本次报销草稿的发生时间依据。", - ) - time_range = payload.ontology.time_range if time_range.start_date and time_range.end_date: normalized_value = ( @@ -270,25 +240,14 @@ class UserAgentReviewSlotMixin: def _build_location_slot(self, payload: UserAgentRequest) -> dict[str, str | float]: review_form_values = self._resolve_review_form_values(payload) - for key in ("business_location", "location"): - value = str(review_form_values.get(key) or "").strip() - if value: - return self._build_slot_value( - value=value, - normalized_value=value, - source="user_form", - confidence=1.0, - evidence="来源于用户修改后的结构化表单。", - ) - - application_location = str(review_form_values.get("application_location") or "").strip() - if application_location: + value = str(review_form_values.get("location") or "").strip() + if value: return self._build_slot_value( - value=application_location, - normalized_value=application_location, - source="detail_context", - confidence=0.86, - evidence="来源于已关联申请单,作为本次报销草稿的地点依据。", + value=value, + normalized_value=value, + source="user_form", + confidence=1.0, + evidence="来源于用户修改后的结构化表单。", ) if str(payload.context_json.get("entry_source") or "").strip() == "detail": @@ -396,17 +355,6 @@ class UserAgentReviewSlotMixin: evidence="来源于用户修改后的结构化表单。", ) - application_reason = str(review_form_values.get("application_reason") or "").strip() - if application_reason: - return self._build_slot_value( - value=application_reason, - raw_value=application_reason, - normalized_value=application_reason, - source="detail_context", - confidence=0.9, - evidence="来源于已关联申请单,作为本次报销草稿的事由依据。", - ) - inferred_reason = self._infer_reason_from_claim_groups( claim_groups=claim_groups, ) @@ -457,22 +405,6 @@ class UserAgentReviewSlotMixin: evidence="来源于用户修改后的结构化表单。", ) - application_amount = str( - review_form_values.get("application_amount") - or review_form_values.get("application_amount_label") - or "" - ).strip() - if application_amount: - normalized = self._normalize_amount_text(application_amount) - return self._build_slot_value( - value=normalized, - raw_value=application_amount, - normalized_value=normalized, - source="detail_context", - confidence=0.86, - evidence="来源于已关联申请单,作为本次报销草稿的金额依据。", - ) - amount_value = entity_map.get("amount", "") if amount_value: normalized = self._normalize_amount_text(amount_value) @@ -506,7 +438,7 @@ class UserAgentReviewSlotMixin: ocr_documents: list[dict[str, object]], ) -> dict[str, str | float]: review_form_values = self._resolve_review_form_values(payload) - edited_value = str(review_form_values.get("expense_type") or review_form_values.get("reimbursement_type") or "").strip() + edited_value = str(review_form_values.get("expense_type") or "").strip() if edited_value: normalized_code, normalized_label = self._normalize_expense_type_input(edited_value) return self._build_slot_value( @@ -581,7 +513,7 @@ class UserAgentReviewSlotMixin: def _build_attachment_slot(self, payload: UserAgentRequest) -> dict[str, str | float]: review_form_values = self._resolve_review_form_values(payload) - attachment_names = str(review_form_values.get("attachment_names") or "").strip() + attachment_names = str(review_form_values.get("attachments") or "").strip() if attachment_names: return self._build_slot_value( value=attachment_names, diff --git a/server/src/app/services/user_agent_review_travel_receipts.py b/server/src/app/services/user_agent_review_travel_receipts.py index 2ab3786..42a5c2d 100644 --- a/server/src/app/services/user_agent_review_travel_receipts.py +++ b/server/src/app/services/user_agent_review_travel_receipts.py @@ -35,6 +35,7 @@ from app.schemas.user_agent import ( from app.services.agent_assets import AgentAssetService from app.services.expense_claims import ExpenseClaimService from app.services.expense_rule_runtime import ExpenseRuleRuntimeService, RuntimeTravelPolicy, resolve_document_type_label +from app.services.ontology_field_registry import normalize_ontology_form_values from app.services.risk_ontology_bridge import resolve_rule_codes_for_risk_check from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService from app.services.user_agent_constants import * @@ -422,22 +423,19 @@ class UserAgentReviewTravelReceiptMixin: def _build_review_reason_corpus(self, payload: UserAgentRequest) -> str: - review_form_values = self._resolve_review_form_values(payload) + review_form_values = normalize_ontology_form_values(self._resolve_review_form_values(payload)) parts = [ str(payload.message or ""), str(payload.context_json.get("user_input_text") or ""), str(review_form_values.get("reason") or ""), - str(review_form_values.get("business_reason") or ""), str(review_form_values.get("location") or ""), - str(review_form_values.get("business_location") or ""), ] return "\n".join(part.strip() for part in parts if part and part.strip()) def _resolve_declared_travel_city(self, payload: UserAgentRequest, policy: RuntimeTravelPolicy) -> str: - review_form_values = self._resolve_review_form_values(payload) + review_form_values = normalize_ontology_form_values(self._resolve_review_form_values(payload)) candidates = [ - str(review_form_values.get("business_location") or ""), str(review_form_values.get("location") or ""), self._resolve_location_value(payload), str(payload.message or ""), diff --git a/server/tests/test_agent_asset_service.py b/server/tests/test_agent_asset_service.py index 54fb645..c99dad2 100644 --- a/server/tests/test_agent_asset_service.py +++ b/server/tests/test_agent_asset_service.py @@ -202,7 +202,7 @@ def test_non_standard_finance_rule_spreadsheets_are_not_seeded() -> None: assert asset is None or asset.config_json["tag"] == "废弃规则" -def test_demo_budget_risk_rules_sync_with_finance_rule_references() -> None: +def test_demo_budget_risk_rules_are_excluded_from_risk_rule_center() -> None: with build_session() as db: service = AgentAssetService(db) service.list_assets(asset_type=AgentAssetType.RULE.value) @@ -218,16 +218,7 @@ def test_demo_budget_risk_rules_sync_with_finance_rule_references() -> None: ) ) - assert budget_rule is not None - assert budget_rule.scenario_json == ["全部"] - assert budget_rule.config_json["budget_required"] is True - assert budget_rule.config_json["expense_types"] == ["all"] - assert budget_rule.config_json["business_stage"] == [ - "expense_application", - "reimbursement", - "budget_execution", - ] - assert budget_rule.config_json["finance_rule_code"] == "budget.execution.policy" + assert budget_rule is None assert communication_rule is not None assert communication_rule.scenario_json == ["通信费"] @@ -237,6 +228,44 @@ def test_demo_budget_risk_rules_sync_with_finance_rule_references() -> None: assert communication_rule.config_json["budget_required"] is True +def test_existing_budget_risk_assets_are_hidden_from_rule_lists() -> None: + with build_session() as db: + db.add( + AgentAsset( + asset_type=AgentAssetType.RULE.value, + code="risk.budget.legacy.visible", + name="历史预算风险", + description="旧数据中已经存在的预算风险规则。", + domain=AgentAssetDomain.EXPENSE.value, + scenario_json=["全部"], + owner="pytest", + status=AgentAssetStatus.ACTIVE.value, + config_json={ + "detail_mode": "json_risk", + "finance_rule_code": "budget.execution.policy", + "rule_document": {"file_name": "risk.budget.available_balance_insufficient.json"}, + }, + ) + ) + db.commit() + + service = AgentAssetService(db) + listed_codes = { + item.code for item in service.list_assets(asset_type=AgentAssetType.RULE.value) + } + page = service.list_assets_page( + asset_type=AgentAssetType.RULE.value, + status=None, + domain=None, + keyword=None, + page=1, + page_size=100, + ) + + assert "risk.budget.legacy.visible" not in listed_codes + assert "risk.budget.legacy.visible" not in {item.code for item in page.items} + + def test_agent_asset_service_can_activate_rule_after_review() -> None: with build_session() as db: service = AgentAssetService(db) diff --git a/server/tests/test_demo_company_simulation_seed.py b/server/tests/test_demo_company_simulation_seed.py index b8588bf..fd6cb6b 100644 --- a/server/tests/test_demo_company_simulation_seed.py +++ b/server/tests/test_demo_company_simulation_seed.py @@ -14,11 +14,12 @@ from app.models.organization import OrganizationUnit from app.models.risk_observation import RiskObservation from app.services.budget import BudgetService from app.services.demo_company_simulation_seed import ( - SIM_CLAIM_PREFIX, SIM_EMPLOYEE_PREFIX, HalfYearExpenseSimulationSeeder, SimulationConfig, ) +from app.services.demo_company_simulation_catalog import SIM_PROJECT_CODE +from app.services.demo_company_simulation_rebalance import HalfYearExpenseSimulationRebalancer def build_session() -> Session: @@ -133,7 +134,7 @@ def test_half_year_simulation_feeds_budget_summary() -> None: summary = BudgetService(db).get_summary(fiscal_year=2026, period_key="2026Q2") sim_claim_count = db.scalar( - select(func.count()).select_from(ExpenseClaim).where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%")) + select(func.count()).select_from(ExpenseClaim).where(ExpenseClaim.project_code == SIM_PROJECT_CODE) ) sim_employee_count = db.scalar( select(func.count()).select_from(Employee).where(Employee.employee_no.like(f"{SIM_EMPLOYEE_PREFIX}%")) @@ -178,25 +179,128 @@ def test_half_year_simulation_excludes_admin_and_visible_month_has_real_volume() visible_claim_count = db.scalar( select(func.count()) .select_from(ExpenseClaim) - .where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%")) + .where(ExpenseClaim.project_code == SIM_PROJECT_CODE) .where(ExpenseClaim.occurred_at >= datetime(2026, 6, 1, tzinfo=UTC)) .where(ExpenseClaim.occurred_at < datetime(2026, 6, 3, tzinfo=UTC)) ) + total_claim_count = db.scalar( + select(func.count()) + .select_from(ExpenseClaim) + .where(ExpenseClaim.project_code == SIM_PROJECT_CODE) + ) + daily_counts = [ + row[0] + for row in db.execute( + select(func.count()) + .select_from(ExpenseClaim) + .where(ExpenseClaim.project_code == SIM_PROJECT_CODE) + .where(ExpenseClaim.occurred_at >= datetime(2026, 6, 1, tzinfo=UTC)) + .where(ExpenseClaim.occurred_at < datetime(2026, 6, 3, tzinfo=UTC)) + .group_by(func.date(ExpenseClaim.occurred_at)) + ).all() + ] + max_daily_count = max(daily_counts) if daily_counts else 0 earliest_claim_day = db.scalar( select(func.min(ExpenseClaim.occurred_at)).where( - ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%") + ExpenseClaim.project_code == SIM_PROJECT_CODE ) ) latest_claim_day = db.scalar( select(func.max(ExpenseClaim.occurred_at)).where( - ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%") + ExpenseClaim.project_code == SIM_PROJECT_CODE ) ) assert admin_claim_count == 0 + assert total_claim_count is not None + assert 400 <= total_claim_count <= 500 assert visible_claim_count is not None - assert 400 <= visible_claim_count <= 500 + assert 12 <= visible_claim_count <= 30 + assert max_daily_count <= 16 assert earliest_claim_day is not None assert latest_claim_day is not None assert earliest_claim_day.date() >= date(2026, 1, 1) assert latest_claim_day.date() <= date(2026, 6, 2) + + +def test_half_year_simulation_rebalance_spreads_existing_rows_without_deleting() -> None: + with build_session() as db: + seed_company(db) + config = SimulationConfig( + target_employees=100, + start_date=date(2026, 1, 1), + months=6, + seed=20260602, + ) + HalfYearExpenseSimulationSeeder(db, config).apply() + db.commit() + + claims = list( + db.scalars( + select(ExpenseClaim) + .where(ExpenseClaim.project_code == SIM_PROJECT_CODE) + .order_by(ExpenseClaim.claim_no.asc()) + ).all() + ) + for claim in claims: + claim.occurred_at = datetime(2026, 6, 1, 10, tzinfo=UTC) + claim.submitted_at = datetime(2026, 6, 1, 11, tzinfo=UTC) + claim.created_at = claim.occurred_at + claim.updated_at = claim.submitted_at + for item in claim.items: + item.item_date = date(2026, 6, 1) + db.commit() + + before_count = db.scalar( + select(func.count()).select_from(ExpenseClaim).where(ExpenseClaim.project_code == SIM_PROJECT_CODE) + ) + preview = HalfYearExpenseSimulationRebalancer(db).preview() + applied = HalfYearExpenseSimulationRebalancer(db).apply() + db.commit() + after_count = db.scalar( + select(func.count()).select_from(ExpenseClaim).where(ExpenseClaim.project_code == SIM_PROJECT_CODE) + ) + daily_counts = [ + row[0] + for row in db.execute( + select(func.count()) + .select_from(ExpenseClaim) + .where(ExpenseClaim.project_code == SIM_PROJECT_CODE) + .group_by(func.date(ExpenseClaim.occurred_at)) + ).all() + ] + month_keys = { + (claim.occurred_at.year, claim.occurred_at.month) + for claim in db.scalars( + select(ExpenseClaim).where(ExpenseClaim.project_code == SIM_PROJECT_CODE) + ).all() + } + sample_claim = db.scalar( + select(ExpenseClaim) + .where(ExpenseClaim.project_code == SIM_PROJECT_CODE) + .where(ExpenseClaim.status != "draft") + .order_by(ExpenseClaim.claim_no.asc()) + .limit(1) + ) + sample_transaction = db.scalar( + select(BudgetTransaction) + .where(BudgetTransaction.source_id == sample_claim.id) + .limit(1) + ) + sample_observation = db.scalar( + select(RiskObservation) + .where(RiskObservation.claim_id == sample_claim.id) + .limit(1) + ) + + assert before_count == after_count + assert preview.claims == applied.claims == after_count + assert applied.recent_claims <= 24 + assert max(daily_counts) <= 16 + assert {(2026, month) for month in range(1, 7)}.issubset(month_keys) + if sample_transaction is not None: + assert sample_transaction.source_no == sample_claim.claim_no + assert sample_transaction.created_at.date() == sample_claim.submitted_at.date() + if sample_observation is not None: + assert sample_observation.claim_no == sample_claim.claim_no + assert sample_observation.created_at.date() == sample_claim.submitted_at.date() diff --git a/server/tests/test_expense_claim_platform_risk_stage.py b/server/tests/test_expense_claim_platform_risk_stage.py index 502ef6b..ad7057d 100644 --- a/server/tests/test_expense_claim_platform_risk_stage.py +++ b/server/tests/test_expense_claim_platform_risk_stage.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from datetime import UTC, date, datetime from decimal import Decimal from typing import Any @@ -15,6 +16,7 @@ from app.models.agent_asset import AgentAsset from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY +from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage from app.services.expense_claims import ExpenseClaimService from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY @@ -111,6 +113,63 @@ def _add_active_rule_asset( ) +def _add_vague_goods_rule_asset( + db: Session, + manager: AgentAssetRuleLibraryManager, +) -> None: + rule_code = "risk.travel.low.vague_ticket_content" + file_name = f"{rule_code}.json" + payload = { + "schema_version": "2.0", + "rule_code": rule_code, + "name": "差旅票据服务内容笼统低风险", + "description": "票据商品或服务名称过于笼统,提醒补充明细。", + "evaluator": "vague_goods_description", + "enabled": True, + "requires_attachment": True, + "applies_to": { + "domains": ["expense", "travel"], + "expense_types": ["travel"], + "business_stages": ["reimbursement"], + }, + "outcomes": {"fail": {"severity": "low", "action": "warning"}}, + } + manager.write_rule_library_json( + library=RISK_RULES_LIBRARY, + file_name=file_name, + payload=payload, + ) + db.add( + AgentAsset( + asset_type=AgentAssetType.RULE.value, + code=rule_code, + name="差旅票据服务内容笼统低风险", + description="", + domain=AgentAssetDomain.EXPENSE.value, + scenario_json=["差旅费"], + owner="pytest", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + published_version="v1.0.0", + config_json={ + "detail_mode": "json_risk", + "rule_library": RISK_RULES_LIBRARY, + "rule_document": {"file_name": file_name}, + }, + ) + ) + + +def _write_attachment_meta(storage_root, invoice_id: str, meta: dict[str, Any]) -> None: + file_path = storage_root / invoice_id + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_bytes(b"attachment") + file_path.with_name(f"{file_path.name}.meta.json").write_text( + f"{json.dumps(meta, ensure_ascii=False, indent=2)}\n", + encoding="utf-8", + ) + + def _build_claim(*, claim_no: str, expense_type: str, status: str = "draft") -> ExpenseClaim: return ExpenseClaim( claim_no=claim_no, @@ -162,6 +221,13 @@ def test_platform_risk_rules_are_filtered_by_business_stage_and_category( business_stage="reimbursement", message="报账环节规则命中", ) + _add_active_rule_asset( + db, + manager, + rule_code="risk.budget.sample.reimbursement.rule", + business_stage="reimbursement", + message="预算风险规则不应进入行为风险检测", + ) _add_active_rule_asset( db, manager, @@ -297,3 +363,122 @@ def test_reimbursement_item_sync_persists_rule_center_risk_preview( assert rule_flags[0]["business_stage"] == "reimbursement" assert rule_flags[0]["visibility_scope"] == "submitter" assert rule_flags[0]["actionability"] == "fixable_by_submitter" + + +def test_vague_ticket_content_ignores_clear_hotel_receipt_text( + tmp_path, + monkeypatch, +) -> None: + with build_session() as db: + manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules") + storage_root = tmp_path / "attachments" + _patch_rule_manager(monkeypatch, manager) + monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: storage_root) + _add_vague_goods_rule_asset(db, manager) + + invoice_id = "claim-clear-hotel/item-hotel/hotel.jpg" + claim = _build_claim(claim_no="RE-CLEAR-HOTEL-001", expense_type="travel") + claim.invoice_count = 1 + claim.items = [ + ExpenseClaimItem( + item_date=date(2026, 2, 23), + item_type="hotel_ticket", + item_reason="上海喜来登酒店", + item_location="上海", + item_amount=Decimal("828.00"), + invoice_id=invoice_id, + ) + ] + db.add(claim) + db.commit() + _write_attachment_meta( + storage_root, + invoice_id, + { + "document_info": { + "document_type": "hotel_invoice", + "document_type_label": "酒店住宿票据", + "scene_code": "hotel", + "scene_label": "住宿票据", + "fields": [ + {"key": "amount", "label": "金额", "value": "828元"}, + {"key": "date", "label": "日期", "value": "2026-02-23"}, + {"key": "merchant_name", "label": "商户", "value": "上海喜来登酒店"}, + ], + }, + "ocr_summary": "上海喜来登酒店;住宿发票", + "ocr_text": "本发票仅含住宿费,不含其他增值服务费。", + }, + ) + + review = ExpenseClaimService(db).evaluate_platform_risk_rules( + claim, + business_stage="reimbursement", + ) + + assert not [ + flag + for flag in review["flags"] + if isinstance(flag, dict) + and flag.get("rule_code") == "risk.travel.low.vague_ticket_content" + ] + + +def test_vague_ticket_content_still_flags_unclear_goods_name( + tmp_path, + monkeypatch, +) -> None: + with build_session() as db: + manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules") + storage_root = tmp_path / "attachments" + _patch_rule_manager(monkeypatch, manager) + monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: storage_root) + _add_vague_goods_rule_asset(db, manager) + + invoice_id = "claim-vague/item-other/other.pdf" + claim = _build_claim(claim_no="RE-VAGUE-001", expense_type="travel") + claim.invoice_count = 1 + claim.items = [ + ExpenseClaimItem( + item_date=date(2026, 2, 23), + item_type="other", + item_reason="差旅相关补充票据", + item_location="上海", + item_amount=Decimal("200.00"), + invoice_id=invoice_id, + ) + ] + db.add(claim) + db.commit() + _write_attachment_meta( + storage_root, + invoice_id, + { + "document_info": { + "document_type": "other", + "document_type_label": "其他单据", + "scene_code": "other", + "scene_label": "其他票据", + "fields": [ + {"key": "goods_name", "label": "商品或服务名称", "value": "服务费"}, + ], + }, + "ocr_summary": "费用发票", + "ocr_text": "项目:服务费。", + }, + ) + + review = ExpenseClaimService(db).evaluate_platform_risk_rules( + claim, + business_stage="reimbursement", + ) + rule_flags = [ + flag + for flag in review["flags"] + if isinstance(flag, dict) + and flag.get("rule_code") == "risk.travel.low.vague_ticket_content" + ] + + assert len(rule_flags) == 1 + assert rule_flags[0]["severity"] == "low" + assert rule_flags[0]["evidence"]["matched_keywords"] == ["服务费"] diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index cbf63da..c6df4f2 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -1375,6 +1375,7 @@ def test_update_claim_item_allows_placeholder_date_reason_and_amount() -> None: payload=ExpenseClaimItemUpdate( item_reason="", item_location="", + item_note="票据行程存在改签,已核对业务真实发生。", item_amount=Decimal("0.00"), ), current_user=current_user, @@ -1385,6 +1386,7 @@ def test_update_claim_item_allows_placeholder_date_reason_and_amount() -> None: assert claim.items[0].item_date == date(2026, 5, 13) assert claim.items[0].item_reason == "" assert claim.items[0].item_location == "" + assert claim.items[0].item_note == "票据行程存在改签,已核对业务真实发生。" assert claim.items[0].item_amount == Decimal("0.00") @@ -1606,7 +1608,7 @@ def test_create_claim_item_adds_blank_draft_row_without_forcing_attachment() -> service = ExpenseClaimService(db) updated = service.create_claim_item( claim_id=claim.id, - payload=ExpenseClaimItemCreate(), + payload=ExpenseClaimItemCreate(item_note="待上传异常票据说明"), current_user=current_user, ) @@ -1619,6 +1621,7 @@ def test_create_claim_item_adds_blank_draft_row_without_forcing_attachment() -> assert new_item.item_type == "office" assert new_item.item_reason == "" assert new_item.item_location == "" + assert new_item.item_note == "待上传异常票据说明" assert new_item.item_amount == Decimal("0.00") assert new_item.invoice_id is None @@ -2808,6 +2811,154 @@ def test_submit_claim_routes_travel_route_mismatch_to_approval_with_review_flag( ) +def test_submit_claim_allows_round_trip_ticket_origin_inferred_from_route( + monkeypatch, + tmp_path, +) -> None: + current_user = CurrentUserContext( + username="emp-round-trip@example.com", + name="张三", + role_codes=[], + is_admin=False, + ) + + def fake_recognize( + self, + files: list[tuple[str, bytes, str | None]], + ) -> OcrRecognizeBatchRead: + documents: list[OcrRecognizeDocumentRead] = [] + for filename, _, media_type in files: + if filename == "outbound.png": + documents.append( + OcrRecognizeDocumentRead( + filename=filename, + media_type=media_type or "image/png", + text="铁路电子客票 2026-02-20 武汉-上海 二等座 票价 ¥354.00", + summary="武汉到上海高铁票", + avg_score=0.98, + line_count=1, + page_count=1, + document_type="train_ticket", + document_type_label="铁路电子客票", + scene_code="travel", + scene_label="差旅票据", + document_fields=[ + {"key": "route", "label": "行程", "value": "武汉-上海"}, + {"key": "amount", "label": "金额", "value": "354元"}, + {"key": "date", "label": "日期", "value": "2026-02-20"}, + ], + warnings=[], + ) + ) + elif filename == "return.png": + documents.append( + OcrRecognizeDocumentRead( + filename=filename, + media_type=media_type or "image/png", + text="铁路电子客票 2026-02-23 上海-武汉 二等座 票价 ¥354.00", + summary="上海到武汉高铁票", + avg_score=0.98, + line_count=1, + page_count=1, + document_type="train_ticket", + document_type_label="铁路电子客票", + scene_code="travel", + scene_label="差旅票据", + document_fields=[ + {"key": "route", "label": "行程", "value": "上海-武汉"}, + {"key": "amount", "label": "金额", "value": "354元"}, + {"key": "date", "label": "日期", "value": "2026-02-23"}, + ], + warnings=[], + ) + ) + return OcrRecognizeBatchRead( + total_file_count=len(files), + success_count=len(documents), + documents=documents, + ) + + monkeypatch.setattr(OcrService, "recognize_files", fake_recognize) + monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path) + + with build_session() as db: + manager = Employee( + employee_no="E7210", + name="李经理", + email="manager-round-trip@example.com", + ) + employee = Employee( + employee_no="E7211", + name="张三", + email="emp-round-trip@example.com", + grade="P4", + location="上海", + manager=manager, + ) + db.add_all([manager, employee]) + db.flush() + + claim = build_claim(expense_type="travel", location="上海") + claim.reason = "支撑国网仿生产环境部署" + claim.employee = employee + claim.employee_id = employee.id + claim.items = [ + ExpenseClaimItem( + id="round-trip-item-1", + claim_id=claim.id, + item_date=date(2026, 2, 20), + item_type="travel", + item_reason="支撑国网仿生产环境部署", + item_location="上海", + item_amount=Decimal("354.00"), + invoice_id=None, + ), + ExpenseClaimItem( + id="round-trip-item-2", + claim_id=claim.id, + item_date=date(2026, 2, 23), + item_type="travel", + item_reason="支撑国网仿生产环境部署", + item_location="上海", + item_amount=Decimal("354.00"), + invoice_id=None, + ), + ] + claim.amount = Decimal("708.00") + claim.invoice_count = 0 + db.add(claim) + db.commit() + + service = ExpenseClaimService(db) + service.upload_claim_item_attachment( + claim_id=claim.id, + item_id="round-trip-item-1", + filename="outbound.png", + content=b"outbound-image", + media_type="image/png", + current_user=current_user, + ) + service.upload_claim_item_attachment( + claim_id=claim.id, + item_id="round-trip-item-2", + filename="return.png", + content=b"return-image", + media_type="image/png", + current_user=current_user, + ) + + submitted = service.submit_claim(claim.id, current_user) + + assert submitted is not None + assert submitted.status == "submitted" + assert submitted.approval_stage == "直属领导审批" + assert not any( + isinstance(flag, dict) + and str(flag.get("rule_code") or "").strip() == "risk.travel.high.city_mismatch" + for flag in list(submitted.risk_flags_json or []) + ) + + def test_submit_claim_routes_hotel_amount_over_travel_policy_to_approval_with_review_flag( monkeypatch, tmp_path, @@ -4051,6 +4202,44 @@ def test_application_submit_blocks_when_budget_insufficient_without_state_change assert db.query(BudgetTransaction).count() == 0 +def test_reimbursement_submit_keeps_budget_insufficient_as_review_risk() -> None: + current_user = CurrentUserContext( + username="reimbursement-budget-risk@example.com", + name="张三", + role_codes=["employee"], + is_admin=True, + ) + + with build_session() as db: + _seed_budget_allocation( + db, + department_id="dept-1", + department_name="市场部", + subject_code="office", + amount=Decimal("1000.00"), + ) + claim = build_claim(expense_type="office", location="待补充") + claim.amount = Decimal("1200.00") + claim.items[0].item_amount = Decimal("1200.00") + db.add(claim) + db.commit() + + submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user) + + assert submitted is not None + assert submitted.status == "submitted" + assert submitted.submitted_at is not None + assert any( + isinstance(flag, dict) + and flag.get("source") == "budget_control" + and flag.get("event_type") == "budget_insufficient" + and flag.get("business_stage") == "reimbursement" + for flag in submitted.risk_flags_json + ) + assert db.query(BudgetReservation).count() == 0 + assert db.query(BudgetTransaction).count() == 0 + + def test_application_submit_skips_budget_for_non_demo_subject() -> None: current_user = CurrentUserContext( username="application-budget-skip@example.com", diff --git a/server/tests/test_finance_dashboard_service.py b/server/tests/test_finance_dashboard_service.py index dfc9109..c0ad677 100644 --- a/server/tests/test_finance_dashboard_service.py +++ b/server/tests/test_finance_dashboard_service.py @@ -332,6 +332,8 @@ def test_finance_dashboard_uses_financial_terms_instead_of_approval_terms() -> N assert "budget pressure" not in str(dashboard.exception_mix).lower() assert dashboard.trend["claimCount"][-1] == 1 assert dashboard.trend["claimAmount"][-1] == 700.0 + assert sum(series["data"][-1] for series in dashboard.trend["categoryAmountSeries"]) == 700.0 + assert "travel_application" not in str(dashboard.trend["categoryAmountSeries"]) assert dashboard.trend["applications"] == dashboard.trend["claimCount"] assert dashboard.department_ranking[0]["name"] == "Market" assert dashboard.department_ranking[0]["amount"] == 700.0 diff --git a/server/tests/test_ocr_endpoints.py b/server/tests/test_ocr_endpoints.py index 9df72a8..115178a 100644 --- a/server/tests/test_ocr_endpoints.py +++ b/server/tests/test_ocr_endpoints.py @@ -123,6 +123,17 @@ def test_ocr_recognize_endpoint_returns_structured_payload(monkeypatch, tmp_path repeated_document = repeated_response.json()["documents"][0] assert repeated_document["receipt_id"] == receipt_id + duplicate_response = client.post( + "/api/v1/ocr/recognize", + headers=auth_headers, + files=[("files", ("invoice.png", b"fake-image", "image/png"))], + ) + assert duplicate_response.status_code == 200 + duplicate_document = duplicate_response.json()["documents"][0] + assert duplicate_document["receipt_id"] == receipt_id + assert duplicate_document["receipt_status"] == "unlinked" + assert any("重复上传" in warning for warning in duplicate_document["warnings"]) + all_receipts_response = client.get("/api/v1/receipt-folder?status=all", headers=auth_headers) assert all_receipts_response.status_code == 200 assert len(all_receipts_response.json()) == 1 @@ -143,9 +154,16 @@ def test_ocr_recognize_endpoint_returns_structured_payload(monkeypatch, tmp_path }, ) assert update_response.status_code == 200 + updated_payload = update_response.json() assert update_response.json()["document_type_label"] == "电子发票" assert update_response.json()["amount"] == "108元" + assert updated_payload["edit_logs"] + assert any( + change["after"] == updated_payload["amount"] + for change in updated_payload["edit_logs"][0]["changes"] + ) + preview_response = client.get(f"/api/v1/receipt-folder/{receipt_id}/preview", headers=auth_headers) assert preview_response.status_code == 200 assert preview_response.content == b"fake-image" diff --git a/server/tests/test_ontology_service.py b/server/tests/test_ontology_service.py index e1bc3e4..f815b67 100644 --- a/server/tests/test_ontology_service.py +++ b/server/tests/test_ontology_service.py @@ -13,6 +13,7 @@ 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 +from app.services.ontology_field_registry import normalize_ontology_context_json from app.services.runtime_chat import RuntimeChatCallTrace, RuntimeChatResult @@ -866,6 +867,64 @@ def test_semantic_ontology_service_treats_application_session_as_application_con assert "amount" in result.missing_slots +def test_semantic_ontology_service_normalizes_business_aliases_to_ontology_fields( + monkeypatch, +) -> None: + session_factory = build_session_factory() + with session_factory() as db: + service = SemanticOntologyService(db) + monkeypatch.setattr( + service, + "_parse_with_model", + lambda **kwargs: (None, [], "model_disabled_for_field_registry_test"), + ) + + result = service.parse( + OntologyParseRequest( + query="生成差旅费报销草稿", + user_id="pytest", + context_json={ + "review_action": "save_draft", + "review_form_values": { + "reimbursement_type": "差旅费", + "business_time": "2026-06-01 至 2026-06-03", + "business_location": "上海", + "reason_value": "支撑国网仿生产环境部署", + "application_amount": "3000元", + "transport_type": "火车", + }, + }, + ) + ) + + entity_map = {(item.type, item.normalized_value) for item in result.entities} + assert ("transport_mode", "火车") in entity_map + assert ("reason", "支撑国网仿生产环境部署") in entity_map + assert ("location", "上海") in entity_map + assert "time_range" not in result.missing_slots + assert "reason" not in result.missing_slots + + +def test_ontology_context_normalizes_employee_profile_aliases() -> None: + context = normalize_ontology_context_json( + { + "name": "曹笑竹", + "department": "技术部", + "position": "财务智能化产品经理", + "grade": "P5", + "managerName": "向万红", + "costCenter": "TECH-DEPT", + } + ) + + assert context["employee_name"] == "曹笑竹" + assert context["department_name"] == "技术部" + assert context["employee_position"] == "财务智能化产品经理" + assert context["employee_grade"] == "P5" + assert context["manager_name"] == "向万红" + assert context["cost_center"] == "TECH-DEPT" + + def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None: session_factory = build_session_factory() with session_factory() as db: diff --git a/server/tests/test_receipt_folder_service.py b/server/tests/test_receipt_folder_service.py index 4d7e41d..c45537a 100644 --- a/server/tests/test_receipt_folder_service.py +++ b/server/tests/test_receipt_folder_service.py @@ -1,5 +1,7 @@ from __future__ import annotations +import pytest + from app.api.deps import CurrentUserContext from app.core.config import get_settings from app.schemas.ocr import OcrRecognizeDocumentRead @@ -67,3 +69,41 @@ def test_receipt_folder_train_ticket_uses_invoice_date_and_enriches_fields(monke assert fields["列车出发时间"] == "2026-02-20 08:30" finally: get_settings.cache_clear() + + +def test_receipt_folder_delete_receipts_for_claim_removes_linked_receipts(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) + get_settings.cache_clear() + try: + current_user = CurrentUserContext( + username="pytest", + name="Py Test", + role_codes=[], + is_admin=False, + ) + service = ReceiptFolderService() + receipt = service.save_receipt( + filename="linked-receipt.pdf", + content=b"%PDF-1.4 linked", + media_type="application/pdf", + current_user=current_user, + linked_claim_id="claim-1", + linked_claim_no="RE-001", + linked_item_id="item-1", + document=OcrRecognizeDocumentRead( + filename="linked-receipt.pdf", + media_type="application/pdf", + text="invoice number 123 amount 100", + document_type="vat_invoice", + document_type_label="invoice", + scene_code="other", + scene_label="receipt", + ), + ) + + assert service.get_receipt(receipt.id, current_user).linked_claim_id == "claim-1" + assert service.delete_receipts_for_claim("claim-1") == 1 + with pytest.raises(FileNotFoundError): + service.get_receipt(receipt.id, current_user) + finally: + get_settings.cache_clear() diff --git a/server/tests/test_risk_rule_generation.py b/server/tests/test_risk_rule_generation.py index 6095913..320e607 100644 --- a/server/tests/test_risk_rule_generation.py +++ b/server/tests/test_risk_rule_generation.py @@ -3,6 +3,7 @@ from __future__ import annotations import json from datetime import UTC, date, datetime from decimal import Decimal +from pathlib import Path from types import SimpleNamespace import pytest @@ -33,6 +34,7 @@ from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY from app.services.agent_assets import AgentAssetService from app.services.agent_foundation_risk_rules import AgentFoundationRiskRuleMixin from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMixin +from app.services.risk_rule_manifest_classifier import is_budget_risk_manifest from app.services.risk_rule_flow_diagram import ( RiskRuleFlowDiagramRenderer, RiskRuleFlowDiagramSpec, @@ -62,13 +64,12 @@ class TravelRouteSemanticRuntimeChatService: "attachment.hotel_city", "claim.location", "item.item_location", - "employee.location", "claim.reason", "item.item_reason", ], "condition_summary": ( "A=交通票行程城市∪住宿发票城市,B=申报目的地∪明细发生地点," - "C=员工常驻地;A与B无交集且无合理说明,或A出现B∪C之外城市时命中。" + "A与B无交集且无合理说明,或A中出现无法由本次票据起终点和申报目的地解释的额外城市时命中。" ), "keywords": [], "exception_keywords": ["绕行", "跨城办事", "临时改签"], @@ -577,6 +578,39 @@ def test_risk_rule_flow_diagram_uses_risk_level_palette() -> None: assert "#10a37f" not in high_svg +def test_non_budget_platform_risk_manifests_do_not_use_budget_or_employee_location() -> None: + rule_root = Path("server/rules/risk-rules") + checked = 0 + for path in sorted(rule_root.glob("*.json")): + payload = json.loads(path.read_text(encoding="utf-8")) + if is_budget_risk_manifest(payload): + continue + + checked += 1 + normalized = normalize_risk_rule_manifest(payload) + params = normalized.get("params") if isinstance(normalized.get("params"), dict) else {} + text_blob = json.dumps(normalized, ensure_ascii=False) + home_city_fields = params.get("home_city_fields") + condition_summary = str( + normalized.get("condition_summary") or params.get("condition_summary") or "" + ) + template_key = str( + normalized.get("template_key") or params.get("template_key") or "" + ).strip() + looks_like_city_rule = any(token in text_blob for token in ("城市", "目的地", "行程城市")) + + assert "budget." not in text_blob, path.name + assert "employee.location" not in text_blob, path.name + assert not ( + isinstance(home_city_fields, list) + and any(str(item or "").strip() for item in home_city_fields) + ), path.name + assert "风险关键词" not in condition_summary, path.name + assert not (template_key == "keyword_match_v1" and looks_like_city_rule), path.name + + assert checked == 28 + + def test_risk_rule_simulation_extracts_ticket_route_cities() -> None: with build_session() as db: service = AgentAssetService(db) @@ -742,6 +776,280 @@ def test_travel_route_city_consistency_allows_normal_round_trip_to_declared_dest assert result is None +def test_travel_route_city_consistency_allows_inferred_round_trip_origin() -> None: + manifest = normalize_risk_rule_manifest( + AgentAssetRuleLibraryManager().read_rule_library_json( + library=RISK_RULES_LIBRARY, + file_name="risk.travel.high.city_mismatch.json", + ) + ) + claim = ExpenseClaim( + claim_no="TEST-INFERRED-ROUND-TRIP", + employee_name="测试员工", + department_name="测试部门", + expense_type="travel", + reason="支撑国网仿生产环境部署", + location="上海", + amount=Decimal("708.00"), + currency="CNY", + invoice_count=2, + occurred_at=datetime.now(UTC), + status="draft", + ) + claim.employee = Employee( + employee_no="TEST-INFERRED-ROUND-TRIP-EMP", + name="测试员工", + email="inferred-round-trip@example.com", + location="上海", + ) + claim.items = [ + ExpenseClaimItem( + item_date=date(2026, 2, 20), + item_type="travel", + item_reason="支撑国网仿生产环境部署", + item_location="上海", + item_amount=Decimal("354.00"), + ) + ] + + result = RiskRuleTemplateExecutor().evaluate( + manifest, + claim=claim, + contexts=[ + { + "document_info": { + "document_type": "train_ticket", + "scene_code": "travel", + "fields": [{"key": "route", "label": "行程", "value": "武汉-上海"}], + }, + "ocr_text": "铁路电子客票 2026-02-20 武汉-上海 二等座", + "ocr_summary": "武汉到上海高铁票", + "item": claim.items[0], + }, + { + "document_info": { + "document_type": "train_ticket", + "scene_code": "travel", + "fields": [{"key": "route", "label": "行程", "value": "上海-武汉"}], + }, + "ocr_text": "铁路电子客票 2026-02-23 上海-武汉 二等座", + "ocr_summary": "上海到武汉高铁票", + "item": claim.items[0], + }, + ], + ) + + assert result is None + + +def test_travel_route_city_consistency_uses_application_location_not_employee_origin() -> None: + manifest = normalize_risk_rule_manifest( + AgentAssetRuleLibraryManager().read_rule_library_json( + library=RISK_RULES_LIBRARY, + file_name="risk.travel.high.city_mismatch.json", + ) + ) + claim = ExpenseClaim( + claim_no="TEST-APPLICATION-LOCATION-NO-FALSE-POSITIVE", + employee_name="测试员工", + department_name="测试部门", + expense_type="travel", + reason="支撑国网仿生产环境部署", + location="待补充", + amount=Decimal("354.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime.now(UTC), + status="draft", + risk_flags_json=[ + { + "source": "application_link", + "application_claim_no": "AP-202606-LOCAL", + "application_detail": { + "application_location": "上海", + "application_reason": "支撑国网仿生产环境部署", + "application_time": "2026-02-20 至 2026-02-23", + }, + } + ], + ) + claim.employee = Employee( + employee_no="TEST-APPLICATION-LOCATION-EMP", + name="测试员工", + email="application-location@example.com", + location="武汉", + ) + claim.items = [ + ExpenseClaimItem( + item_date=date(2026, 2, 20), + item_type="travel", + item_reason="支撑国网仿生产环境部署", + item_location="", + item_amount=Decimal("354.00"), + ) + ] + + result = RiskRuleTemplateExecutor().evaluate( + manifest, + claim=claim, + contexts=[ + { + "document_info": { + "document_type": "train_ticket", + "scene_code": "travel", + "fields": [{"key": "route", "label": "行程", "value": "武汉-上海"}], + }, + "ocr_text": "铁路电子客票 2026-02-20 武汉-上海 二等座", + "ocr_summary": "武汉到上海高铁票", + "item": claim.items[0], + } + ], + ) + + assert result is None + + +def test_travel_route_city_mismatch_evidence_uses_application_claim_and_attachment() -> None: + manifest = normalize_risk_rule_manifest( + AgentAssetRuleLibraryManager().read_rule_library_json( + library=RISK_RULES_LIBRARY, + file_name="risk.travel.high.city_mismatch.json", + ) + ) + claim = ExpenseClaim( + claim_no="TEST-APPLICATION-LOCATION-MISMATCH", + employee_name="测试员工", + department_name="测试部门", + expense_type="travel", + reason="去北京参加项目会议", + location="北京", + amount=Decimal("354.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime.now(UTC), + status="draft", + risk_flags_json=[ + { + "source": "application_link", + "application_claim_no": "AP-202606-MISMATCH", + "application_detail": { + "application_location": "北京", + "application_reason": "去北京参加项目会议", + "application_time": "2026-02-20 至 2026-02-23", + }, + } + ], + ) + claim.employee = Employee( + employee_no="TEST-APPLICATION-MISMATCH-EMP", + name="测试员工", + email="application-mismatch@example.com", + location="武汉", + ) + claim.items = [ + ExpenseClaimItem( + item_date=date(2026, 2, 20), + item_type="travel", + item_reason="去北京参加项目会议", + item_location="北京", + item_amount=Decimal("354.00"), + ) + ] + + result = RiskRuleTemplateExecutor().evaluate( + manifest, + claim=claim, + contexts=[ + { + "document_info": { + "document_type": "train_ticket", + "scene_code": "travel", + "fields": [{"key": "route", "label": "行程", "value": "武汉-上海"}], + }, + "ocr_text": "铁路电子客票 2026-02-20 武汉-上海 二等座", + "ocr_summary": "武汉到上海高铁票", + "item": claim.items[0], + } + ], + ) + + assert result is not None + evidence = result["evidence"]["city_consistency"] + assert evidence["application_reference_values"] == ["北京"] + assert evidence["claim_reference_values"] == ["北京"] + assert evidence["attachment_values"] == ["武汉", "上海"] + assert evidence["unexpected_route_cities"] == ["武汉", "上海"] + assert "home_values" not in evidence + assert "ignored_employee_context_values" not in evidence + + +def test_travel_route_city_consistency_still_hits_onward_city_after_destination() -> None: + manifest = normalize_risk_rule_manifest( + AgentAssetRuleLibraryManager().read_rule_library_json( + library=RISK_RULES_LIBRARY, + file_name="risk.travel.high.city_mismatch.json", + ) + ) + claim = ExpenseClaim( + claim_no="TEST-ONWARD-CITY", + employee_name="测试员工", + department_name="测试部门", + expense_type="travel", + reason="支撑国网仿生产环境部署", + location="上海", + amount=Decimal("840.00"), + currency="CNY", + invoice_count=2, + occurred_at=datetime.now(UTC), + status="draft", + ) + claim.employee = Employee( + employee_no="TEST-ONWARD-CITY-EMP", + name="测试员工", + email="onward-city@example.com", + location="上海", + ) + claim.items = [ + ExpenseClaimItem( + item_date=date(2026, 2, 20), + item_type="travel", + item_reason="支撑国网仿生产环境部署", + item_location="上海", + item_amount=Decimal("480.00"), + ) + ] + + result = RiskRuleTemplateExecutor().evaluate( + manifest, + claim=claim, + contexts=[ + { + "document_info": { + "document_type": "flight_itinerary", + "scene_code": "travel", + "fields": [{"key": "route", "label": "行程", "value": "武汉-上海"}], + }, + "ocr_text": "电子行程单 2026-02-20 武汉-上海 金额 480元", + "ocr_summary": "武汉到上海机票", + "item": claim.items[0], + }, + { + "document_info": { + "document_type": "flight_itinerary", + "scene_code": "travel", + "fields": [{"key": "route", "label": "行程", "value": "上海-成都"}], + }, + "ocr_text": "电子行程单 2026-02-21 上海-成都 金额 360元", + "ocr_summary": "上海到成都机票", + "item": claim.items[0], + }, + ], + ) + + assert result is not None + assert result["evidence"]["city_consistency"]["unexpected_route_cities"] == ["成都"] + + def test_generate_complex_travel_route_rule_uses_formula_not_keyword_match(tmp_path) -> None: text = ( "差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;" @@ -783,7 +1091,7 @@ def test_generate_complex_travel_route_rule_uses_formula_not_keyword_match(tmp_p assert payload["params"]["exception_keywords"][:3] == ["绕行", "跨城办事", "跨城"] assert "A=交通票行程城市" in payload["params"]["condition_summary"] assert "风险关键词" not in payload["params"]["condition_summary"] - assert "employee.location" in payload["params"]["field_keys"] + assert "employee.location" not in payload["params"]["field_keys"] assert "route_anomaly_policy" in payload["params"] @@ -882,10 +1190,10 @@ def test_legacy_city_route_keyword_manifest_is_normalized_before_display_and_exe ) assert result is not None - assert result["evidence"]["city_consistency"]["unexpected_route_cities"] == ["北京"] + assert result["evidence"]["city_consistency"]["unexpected_route_cities"] == ["北京", "武汉"] -def test_travel_route_rule_hits_unexpected_intermediate_city_even_when_returning_home() -> None: +def test_travel_route_rule_does_not_use_employee_location_as_allowed_endpoint() -> None: manifest = { "template_key": "field_compare_v1", "params": { @@ -904,8 +1212,8 @@ def test_travel_route_rule_hits_unexpected_intermediate_city_even_when_returning "exception_fields": ["claim.reason"], "exception_keywords": ["绕行", "跨城办事", "临时改签"], "condition_summary": ( - "A=票据路线城市,B=申报城市,C=员工常驻地," - "A中出现B∪C之外城市则命中。" + "A=票据路线城市,B=申报城市," + "A中出现无法由本次票据起终点和申报目的地解释的额外城市则命中。" ), }, "outcomes": {"fail": {"severity": "high"}}, @@ -962,8 +1270,8 @@ def test_travel_route_rule_hits_unexpected_intermediate_city_even_when_returning assert result is not None evidence = result["evidence"]["city_consistency"] assert evidence["reference_values"] == ["上海"] - assert evidence["home_values"] == ["武汉"] - assert evidence["unexpected_route_cities"] == ["北京"] + assert evidence["unexpected_route_cities"] == ["北京", "武汉"] + assert "home_values" not in evidence def test_simulation_uses_current_rule_manifest_for_ticket_city_mismatch(tmp_path) -> None: diff --git a/web/src/assets/styles/views/receipt-folder-view.css b/web/src/assets/styles/views/receipt-folder-view.css index 4043dca..a85ce09 100644 --- a/web/src/assets/styles/views/receipt-folder-view.css +++ b/web/src/assets/styles/views/receipt-folder-view.css @@ -44,8 +44,6 @@ align-items: center; } -.receipt-key-grid input, -.receipt-edit-field-row input, .receipt-ocr-field input { width: 100%; border: 1px solid #d7e0ea; @@ -56,15 +54,11 @@ transition: border-color 160ms ease, box-shadow 160ms ease; } -.receipt-key-grid input, -.receipt-edit-field-row input, .receipt-ocr-field input { height: 36px; padding: 0 10px; } -.receipt-key-grid input:focus, -.receipt-edit-field-row input:focus, .receipt-ocr-field input:focus { border-color: var(--theme-primary); box-shadow: 0 0 0 3px rgba(58, 124, 165, 0.14); @@ -105,6 +99,7 @@ } .receipt-folder-detail { + min-width: 0; display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: 12px; @@ -112,99 +107,49 @@ } .receipt-folder-detail :deep(.detail-scroll) { + min-width: 0; min-height: 0; - display: grid; - align-content: start; + display: flex; + flex-direction: column; gap: 16px; - padding-right: 4px; + padding-right: 0; overflow: auto; } +.receipt-folder-detail :deep(.detail-scroll) > * { + min-width: 0; + flex: 0 0 auto; +} + .receipt-folder-detail :deep(.detail-actions) { + flex-wrap: wrap; margin-top: 10px; padding-top: 10px; } -.receipt-detail-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 12px 14px; - border: 1px solid #dbe4ee; - border-radius: 4px; - background: #fff; -} - -.receipt-detail-title { +.receipt-folder-detail :deep(.detail-action-group) { min-width: 0; - display: grid; - gap: 3px; -} - -.receipt-detail-title strong { - color: #0f172a; - font-size: 18px; - font-weight: 850; -} - -.receipt-detail-title span { - color: #0f172a; - font-size: 13px; - font-weight: 780; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.receipt-detail-title p { - margin: 0; - color: #64748b; - font-size: 12px; -} - -.receipt-toolbar-actions { - display: inline-flex; - align-items: center; - justify-content: flex-end; - gap: 8px; flex-wrap: wrap; } -.receipt-dashboard { - min-height: 0; - display: grid; - grid-template-columns: minmax(420px, 0.92fr) minmax(520px, 1.08fr); - gap: 14px; - align-items: stretch; -} - -.receipt-dashboard-side { - min-height: 0; - display: grid; - gap: 14px; -} - -.receipt-dashboard-bottom { - grid-column: 1 / -1; - display: grid; - grid-template-columns: minmax(260px, 0.95fr) minmax(320px, 1.2fr) minmax(240px, 0.85fr); - gap: 14px; -} - .receipt-folder-detail :deep(.detail-grid) { - min-height: 0; + min-width: 0; display: grid; - grid-template-columns: minmax(360px, 0.95fr) minmax(420px, 1.05fr); + grid-template-columns: minmax(0, .86fr) minmax(0, 1.14fr); gap: 16px; - align-items: stretch; + align-items: start; overflow: visible; } +.receipt-folder-detail :deep(.detail-bottom) { + min-width: 0; + display: block; +} + .receipt-folder-detail :deep(.detail-main), .receipt-folder-detail :deep(.detail-side) { - min-height: 0; - display: grid; + min-width: 0; + display: block; } .receipt-folder-detail :deep(.enterprise-detail-card .card-head) { @@ -228,60 +173,80 @@ font-size: 12px; } -.receipt-basic-panel, .receipt-preview-panel, -.receipt-ocr-panel, -.receipt-status-panel, -.receipt-info-panel, -.receipt-log-panel { +.receipt-ticket-info-panel, +.receipt-association-panel { + min-width: 0; min-height: 0; overflow: hidden; border: 1px solid #dbe4ee; border-radius: 4px; background: #fff; -} - -.receipt-basic-panel { - display: block; padding: 14px; - overflow: hidden; } -.receipt-field-list-head { +.receipt-ticket-info-panel { + display: grid; + gap: 10px; +} + +.receipt-card-actions { + min-width: 0; + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.receipt-ticket-info-panel :deep(.card-head) { + margin-bottom: 10px; +} + +.receipt-ticket-info-panel input { + height: 32px; + padding: 0 9px; +} + +.receipt-ticket-section { + min-width: 0; + display: grid; + gap: 10px; +} + +.receipt-ticket-section + .receipt-ticket-section { + padding-top: 10px; + border-top: 1px solid #edf2f7; +} + +.receipt-section-head { + display: flex; + align-items: center; justify-content: space-between; gap: 12px; } -.receipt-field-list-head strong { +.receipt-section-head strong { color: #0f172a; font-size: 15px; + font-weight: 850; } -.receipt-field-list-head small { +.receipt-field-list-head small, +.receipt-section-head small { color: #64748b; font-size: 12px; font-weight: 750; } -.receipt-key-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; -} - -.receipt-key-field, -.receipt-edit-field-row label, .receipt-ocr-field { display: grid; gap: 6px; } -.receipt-key-field span, -.receipt-edit-field-row label span, .receipt-ocr-field span, .receipt-static-item span, -.receipt-data-item span, -.receipt-status-item span { +.receipt-data-item span { color: #64748b; font-size: 12px; font-weight: 750; @@ -294,23 +259,20 @@ } .receipt-static-grid, -.receipt-ocr-grid, -.receipt-status-grid, .receipt-data-list { display: grid; gap: 10px; } .receipt-static-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - margin-top: 14px; - padding-top: 12px; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + margin-top: 10px; + padding-top: 10px; border-top: 1px solid #edf2f7; } .receipt-static-item, -.receipt-data-item, -.receipt-status-item { +.receipt-data-item { min-width: 0; display: grid; gap: 4px; @@ -326,110 +288,34 @@ overflow-wrap: anywhere; } -.receipt-ocr-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - margin-bottom: 12px; -} - -.receipt-status-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.receipt-status-item { - grid-template-columns: minmax(90px, 1fr) auto; - align-items: center; - min-height: 30px; -} - -.receipt-status-item strong { - min-height: 24px; - display: inline-flex; - align-items: center; - justify-self: start; - padding: 0 10px; - border-radius: 4px; - font-size: 12px; - font-weight: 800; -} - -.receipt-status-item .tone-success { - background: var(--success-soft); - color: var(--success-active); -} - -.receipt-status-item .tone-warning { - background: #fff7ed; - color: #ea580c; -} - -.receipt-status-item .tone-info { - background: #eff6ff; - color: #2563eb; -} - -.receipt-other-info { - margin-top: 18px; -} - -.receipt-other-collapse { - border-top: 1px solid #e5edf5; - border-bottom: 0; -} - -.receipt-other-collapse :deep(.el-collapse-item__header) { - min-height: 42px; - height: auto; - border-bottom: 1px solid #e5edf5; - background: #fff; - color: #0f172a; -} - -.receipt-other-collapse :deep(.el-collapse-item__wrap) { - border-bottom: 0; -} - -.receipt-other-collapse :deep(.el-collapse-item__content) { - padding: 12px 0 0; -} - -.receipt-collapse-title { - width: 100%; - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding-right: 10px; -} - -.receipt-collapse-title strong { - color: #0f172a; - font-size: 15px; -} - -.receipt-collapse-title small { - color: #64748b; - font-size: 12px; - font-weight: 750; -} - -.receipt-other-scroll { - max-height: 320px; +.receipt-all-field-grid { + max-height: clamp(360px, 60vh, 640px); display: grid; gap: 10px; - overflow-y: auto; padding-right: 4px; + overflow-y: auto; } -.receipt-edit-field-row { - display: grid; - grid-template-columns: minmax(120px, .72fr) minmax(180px, 1.28fr); - gap: 10px; - padding: 10px; - border: 1px solid #e1e8f0; +.receipt-all-field-grid.editing { + max-height: clamp(420px, 64vh, 680px); +} + +.receipt-ocr-field { + padding: 8px 10px; + border: 1px solid #e5edf5; border-radius: 4px; background: #f8fafc; } +.receipt-ocr-field strong { + min-width: 0; + color: #0f172a; + font-size: 13px; + font-weight: 780; + line-height: 1.45; + overflow-wrap: anywhere; +} + .receipt-field-empty { min-height: 64px; display: inline-flex; @@ -445,21 +331,25 @@ } .receipt-preview-panel { + align-self: start; display: grid; - grid-template-rows: auto minmax(0, 1fr) auto; - padding: 14px; + gap: 12px; } .receipt-preview-frame { + min-width: 0; min-height: 0; padding: 10px; border: 1px solid #e5edf5; border-radius: 4px; background: #fff; + overflow: hidden; } .receipt-preview-box { - min-height: 340px; + width: 100%; + height: clamp(380px, 56vh, 640px); + min-height: 0; display: grid; place-items: center; overflow: auto; @@ -467,8 +357,8 @@ } .receipt-preview-box img { - max-width: 100%; - max-height: 100%; + width: 100%; + height: 100%; object-fit: contain; transform-origin: center center; transition: transform 180ms ease; @@ -477,6 +367,7 @@ .receipt-preview-box iframe { width: 100%; height: 100%; + min-height: 380px; border: 0; background: #fff; } @@ -495,10 +386,12 @@ } .receipt-preview-tools { + min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 10px; + flex-wrap: wrap; padding-top: 12px; } @@ -546,59 +439,107 @@ font-weight: 800; } -.receipt-log-list { - position: relative; +.receipt-edit-log-section { display: grid; gap: 10px; - margin: 0; - padding: 0 0 0 16px; - list-style: none; + padding-top: 12px; + border-top: 1px solid #edf2f7; } -.receipt-log-list::before { - content: ""; - position: absolute; - left: 4px; - top: 6px; - bottom: 6px; - width: 1px; - background: #dbe4ee; +.receipt-edit-log-section header, +.receipt-edit-log-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; } -.receipt-log-list li { - position: relative; +.receipt-edit-log-section header strong { + color: #0f172a; + font-size: 14px; + font-weight: 850; +} + +.receipt-edit-log-section header span, +.receipt-edit-log-meta span { + color: #64748b; + font-size: 12px; + font-weight: 750; +} + +.receipt-edit-log-list { display: grid; - grid-template-columns: 120px 54px minmax(0, 1fr); + max-height: 180px; gap: 8px; - align-items: start; + margin: 0; + padding: 0 4px 0 0; + list-style: none; + overflow-y: auto; +} + +.receipt-edit-log-list li { + display: grid; + gap: 7px; + padding: 9px 10px; + border: 1px solid #e5edf5; + border-radius: 4px; + background: #f8fafc; +} + +.receipt-edit-log-meta strong { + color: #0f172a; + font-size: 12px; + font-weight: 800; +} + +.receipt-edit-log-list p { + margin: 0; + display: flex; + align-items: center; + gap: 7px; + flex-wrap: wrap; color: #334155; font-size: 12px; } -.receipt-log-list li::before { - content: ""; - position: absolute; - left: -15px; - top: 5px; - width: 8px; - height: 8px; - border-radius: 999px; - background: var(--theme-primary); -} - -.receipt-log-list span { +.receipt-edit-log-list p span { color: #64748b; - font-variant-numeric: tabular-nums; + font-weight: 750; } -.receipt-log-list strong { +.receipt-edit-log-list p em { + max-width: 160px; + font-style: normal; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.receipt-edit-log-list p strong { + max-width: 180px; color: #0f172a; - font-weight: 780; + font-weight: 800; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.receipt-log-list p { - margin: 0; - line-height: 1.45; +.receipt-edit-log-empty { + min-height: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px dashed #d7e0ea; + border-radius: 4px; + background: #f8fafc; + color: #64748b; + font-size: 13px; + font-weight: 700; +} + +.receipt-data-list.association { + grid-template-columns: repeat(3, minmax(0, 1fr)); } .associate-step { @@ -662,16 +603,9 @@ gap: 3px; } -@media (max-width: 1120px) { - .receipt-dashboard, - .receipt-dashboard-bottom, +@media (max-width: 1180px) { .receipt-folder-detail :deep(.detail-grid) { - grid-template-columns: 1fr; - overflow-y: auto; - } - - .receipt-preview-panel { - min-height: 520px; + grid-template-columns: minmax(0, 1fr); } } @@ -711,120 +645,32 @@ width: 100%; } - .receipt-folder-list .table-wrap { - min-height: 0; - max-height: none; - display: block; - overflow: visible; - border: 0; - border-radius: 0; - background: transparent; - } - - .receipt-folder-list .table-wrap table, - .receipt-folder-list .table-wrap thead, - .receipt-folder-list .table-wrap tbody, - .receipt-folder-list .table-wrap tr, - .receipt-folder-list .table-wrap th, - .receipt-folder-list .table-wrap td { - display: block; - } - - .receipt-folder-list .table-wrap table { - width: 100%; - min-width: 0; - border-collapse: separate; - } - - .receipt-folder-list .table-wrap thead, - .receipt-folder-list .table-wrap colgroup { - display: none; - } - - .receipt-folder-list .table-wrap tbody { - display: grid; - gap: 10px; - } - - .receipt-folder-list .table-wrap tr { - padding: 12px; - border: 1px solid #e2e8f0; - border-radius: 4px; - background: #fff; - box-shadow: 0 6px 18px rgba(15, 23, 42, .05); - } - - .receipt-folder-list .table-wrap td { - display: grid; - grid-template-columns: 82px minmax(0, 1fr); - align-items: center; - gap: 10px; - min-height: 30px; - padding: 7px 0; - border-bottom: 1px dashed #edf2f7; - color: #273142; - font-size: 13px; - line-height: 1.45; - text-align: left; - white-space: normal; - overflow: visible; - text-overflow: clip; - } - - .receipt-folder-list .table-wrap td:last-child { - border-bottom: 0; - } - - .receipt-folder-list .table-wrap td::before { - content: attr(data-label); - color: #64748b; - font-size: 12px; - font-weight: 800; - line-height: 1.6; - } - - .receipt-folder-list .table-wrap td:first-child { - grid-template-columns: 1fr; - padding-top: 0; - } - - .receipt-folder-list .table-wrap td:first-child::before { - display: none; - } - .receipt-folder-list td:first-child .doc-id { white-space: normal; overflow: visible; text-overflow: clip; } - .receipt-folder-list .list-foot { - display: grid; - justify-items: stretch; - } - - .receipt-folder-list .pager { - width: 100%; - justify-content: flex-start; - overflow-x: auto; - } - - .receipt-detail-toolbar, - .receipt-toolbar-actions, .receipt-preview-tools { align-items: stretch; flex-direction: column; } - .receipt-key-grid, - .receipt-edit-field-row, + .receipt-preview-tools > *, + .preview-tool-group { + width: 100%; + } + + .preview-tool-group { + justify-content: center; + } + .receipt-static-grid, - .receipt-ocr-grid, - .receipt-status-grid { + .receipt-data-list.association { grid-template-columns: 1fr; } - .receipt-log-list li { - grid-template-columns: 1fr; + .receipt-preview-box { + height: clamp(320px, 60vh, 520px); } } diff --git a/web/src/assets/styles/views/travel-request-detail-view.css b/web/src/assets/styles/views/travel-request-detail-view.css index 2617394..816c1e3 100644 --- a/web/src/assets/styles/views/travel-request-detail-view.css +++ b/web/src/assets/styles/views/travel-request-detail-view.css @@ -865,6 +865,25 @@ overflow-x: auto; } +.expense-recognition-banner { + min-width: 760px; + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 10px; + padding: 10px 12px; + border: 1px solid rgba(var(--theme-primary-rgb), .20); + border-radius: 4px; + background: var(--theme-primary-soft); + color: var(--theme-primary-active); + font-size: 12px; + font-weight: 800; +} + +.expense-recognition-banner i { + font-size: 16px; +} + .detail-expense-table table { width: 100%; min-width: 0; @@ -907,12 +926,13 @@ background: var(--success-soft); } -.detail-expense-table .col-time { width: 11%; } -.detail-expense-table .col-filled-at { width: 15%; } -.detail-expense-table .col-type { width: 13%; } -.detail-expense-table .col-desc { width: 19%; } -.detail-expense-table .col-amount { width: 11%; } -.detail-expense-table .col-attachment { width: 22%; } +.detail-expense-table .col-time { width: 10%; } +.detail-expense-table .col-filled-at { width: 13%; } +.detail-expense-table .col-type { width: 11%; } +.detail-expense-table .col-desc { width: 15%; } +.detail-expense-table .col-amount { width: 9%; } +.detail-expense-table .col-attachment { width: 18%; } +.detail-expense-table .col-risk-note { width: 15%; } .detail-expense-table .col-action { width: 9%; } .expense-time { @@ -929,12 +949,25 @@ top: 50%; width: 18px; height: 18px; + padding: 0; + border: 0; + background: transparent; display: inline-grid; place-items: center; transform: translateY(-50%); color: #dc2626; font-size: 18px; line-height: 1; + cursor: pointer; +} + +.expense-risk-indicator:hover { + color: #b91c1c; +} + +.expense-risk-indicator:focus-visible { + outline: 2px solid rgba(220, 38, 38, .28); + outline-offset: 2px; } .cell-editor { @@ -948,7 +981,8 @@ } .editor-input, -.editor-select { +.editor-select, +.editor-textarea { width: 100%; min-height: 34px; padding: 0 10px; @@ -959,6 +993,13 @@ font-size: 12px; } +.editor-textarea { + min-height: 68px; + padding: 8px 10px; + resize: vertical; + line-height: 1.45; +} + .currency-editor { display: grid; grid-template-columns: 34px minmax(0, 1fr); @@ -979,7 +1020,8 @@ } .editor-input:focus, -.editor-select:focus { +.editor-select:focus, +.editor-textarea:focus { border-color: var(--theme-primary); box-shadow: 0 0 0 3px var(--theme-focus-ring); outline: none; @@ -1036,6 +1078,29 @@ text-align: left; } +.expense-risk-note strong { + display: block; + color: #0f172a; + font-size: 12px; + font-weight: 800; + line-height: 1.45; + text-align: center; + overflow-wrap: anywhere; +} + +.expense-risk-note span { + display: block; + color: #64748b; + font-size: 12px; + line-height: 1.45; + text-align: center; +} + +.expense-risk-note .risk-note-missing { + color: #b45309; + font-weight: 750; +} + .over-tag { display: inline-flex; align-items: center; @@ -1339,6 +1404,12 @@ font-weight: 700; } +.system-attachment-note.pending { + border-color: rgba(var(--theme-primary-rgb), .20); + background: var(--theme-primary-soft); + color: var(--theme-primary-active); +} + .empty-row-cell { padding: 22px 16px; color: #64748b; @@ -1352,6 +1423,105 @@ display: none; } +.smart-entry-upload-panel { + display: grid; + gap: 12px; +} + +.smart-entry-upload-picker { + min-height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 0 14px; + border: 1px solid rgba(var(--theme-primary-rgb), .28); + border-radius: 4px; + background: #fff; + color: var(--theme-primary-active); + font-size: 13px; + font-weight: 850; +} + +.smart-entry-upload-picker:hover { + background: var(--theme-primary-soft); +} + +.smart-entry-upload-picker:disabled { + cursor: not-allowed; + opacity: .64; +} + +.smart-entry-upload-file { + display: grid; + grid-template-columns: 32px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 68px; + padding: 12px; + border: 1px solid #e2e8f0; + border-radius: 4px; + background: #f8fafc; +} + +.smart-entry-upload-file > i { + color: var(--theme-primary-active); + font-size: 24px; +} + +.smart-entry-upload-file strong, +.smart-entry-upload-file span { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.smart-entry-upload-file strong { + color: #0f172a; + font-size: 13px; + font-weight: 850; + white-space: nowrap; +} + +.smart-entry-upload-file span { + margin-top: 3px; + color: #64748b; + font-size: 12px; + line-height: 1.45; +} + +.smart-entry-upload-list { + display: grid; + gap: 2px; + max-height: 84px; + margin: 8px 0 0; + padding: 0; + overflow: auto; + list-style: none; +} + +.smart-entry-upload-list li { + min-width: 0; + overflow: hidden; + color: #334155; + font-size: 12px; + line-height: 1.45; + text-overflow: ellipsis; + white-space: nowrap; +} + +.smart-entry-upload-clear { + min-height: 30px; + padding: 0 10px; + border: 1px solid #d7e0ea; + border-radius: 4px; + background: #fff; + color: #475569; + font-size: 12px; + font-weight: 800; +} + .attachment-preview-mask { position: fixed; inset: 0; @@ -1813,6 +1983,30 @@ border-radius: 2px; background: #ffffff; box-shadow: 0 1px 1px rgba(15, 23, 42, 0.03); + transition: border-color .18s ease, box-shadow .18s ease, background .18s ease; +} + +.validation-section--risk .risk-advice-card.is-highlighted { + border-color: #f59e0b; + background: #fff7ed; + box-shadow: 0 0 0 3px rgba(245, 158, 11, .20), 0 8px 18px rgba(15, 23, 42, .08); + animation: risk-card-flash 1.2s ease-in-out 1; +} + +@keyframes risk-card-flash { + 0%, + 100% { + box-shadow: 0 0 0 3px rgba(245, 158, 11, .18), 0 8px 18px rgba(15, 23, 42, .08); + } + 45% { + box-shadow: 0 0 0 6px rgba(245, 158, 11, .30), 0 10px 22px rgba(15, 23, 42, .10); + } +} + +@media (prefers-reduced-motion: reduce) { + .validation-section--risk .risk-advice-card.is-highlighted { + animation: none; + } } .validation-section--risk .risk-advice-card::before { diff --git a/web/src/components/charts/TrendChart.vue b/web/src/components/charts/TrendChart.vue index 6f76bcf..0dbb9d0 100644 --- a/web/src/components/charts/TrendChart.vue +++ b/web/src/components/charts/TrendChart.vue @@ -1,7 +1,17 @@