feat: 本体字段治理与风险规则模板执行器重构
- 新增本体字段注册表与字段治理审计脚本 - 重构风险规则模板执行器、DSL 验证与清单分类器 - 完善票据夹服务与差旅请求详情页交互 - 优化趋势图表与总览页数据展示 - 增强报销平台风险分级与模拟公司筛选 - 补充本体字段、风险规则生成与票据夹服务测试覆盖
This commit is contained in:
78
document/development/ontology-field-governance/CONCEPT.md
Normal file
78
document/development/ontology-field-governance/CONCEPT.md
Normal file
@@ -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` 对外输出本体字段,不输出页面别名字段。
|
||||
- 后端测试覆盖别名归一到本体字段。
|
||||
- 前端测试覆盖快速报销和核对抽屉只输出本体字段。
|
||||
@@ -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` 作为收口质量闸。
|
||||
15
document/development/ontology-field-governance/TODO.md
Normal file
15
document/development/ontology-field-governance/TODO.md
Normal file
@@ -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` 字段进行检查。
|
||||
@@ -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 中提示“已上传过同样的单据,请不要重复上传。”
|
||||
|
||||
报销助手联动:
|
||||
|
||||
- 用户在报销助手上传新附件前,如果票据夹中存在未关联票据,先提示用户是否进入票据夹关联。
|
||||
- 用户可以选择“去票据夹关联”,也可以选择“继续上传新附件”;继续上传时只跳过本次未关联提醒,不影响后续重复附件校验。
|
||||
|
||||
删除级联:
|
||||
|
||||
- 已关联票据对应的报销单被删除时,票据夹中关联该报销单的票据源文件、预览文件和元数据一并删除。
|
||||
|
||||
@@ -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` 删除后不可再读取票据。
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
104
server/scripts/audit_ontology_context_fields.py
Normal file
104
server/scripts/audit_ontology_context_fields.py
Normal file
@@ -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())
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
373
server/src/app/services/demo_company_simulation_rebalance.py
Normal file
373
server/src/app/services/demo_company_simulation_rebalance.py
Normal file
@@ -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
|
||||
)
|
||||
@@ -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 []),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
185
server/src/app/services/ontology_field_registry.py
Normal file
185
server/src/app/services/ontology_field_registry.py
Normal file
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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"}]
|
||||
|
||||
@@ -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 [],
|
||||
},
|
||||
|
||||
@@ -621,7 +621,6 @@ class RiskRuleGenerationService:
|
||||
in {
|
||||
"claim.reason",
|
||||
"claim.location",
|
||||
"employee.location",
|
||||
"item.item_date",
|
||||
"item.item_reason",
|
||||
"item.item_location",
|
||||
|
||||
@@ -111,7 +111,7 @@ FIELD_ONTOLOGY: tuple[RiskRuleField, ...] = (
|
||||
"员工常驻地",
|
||||
"text",
|
||||
"employee",
|
||||
("常驻地", "办公地", "员工所在地", "出发地", "所在城市"),
|
||||
("常驻地", "办公地", "员工所在地", "所在城市"),
|
||||
),
|
||||
RiskRuleField("item.item_type", "费用类型", "enum", "item", ("费用类型", "科目", "类型")),
|
||||
RiskRuleField("item.item_reason", "明细事由", "text", "item", ("明细事由", "明细说明")),
|
||||
|
||||
@@ -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": ["绕行", "跨城办事", "临时改签"],
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
120
server/src/app/services/risk_rule_manifest_classifier.py
Normal file
120
server/src/app/services/risk_rule_manifest_classifier.py
Normal file
@@ -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()
|
||||
@@ -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}并要求补充说明或退回修改"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ""),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"] == ["服务费"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
<template>
|
||||
<div class="trend-chart">
|
||||
<div class="chart-legend">
|
||||
<span><i :style="{ background: activeColor }"></i>{{ legendLabel }}</span>
|
||||
<div class="chart-toolbar">
|
||||
<div class="chart-legend">
|
||||
<span
|
||||
v-for="item in legendItems"
|
||||
:key="item.name"
|
||||
class="legend-pill"
|
||||
:title="item.title"
|
||||
>
|
||||
<i :style="{ background: item.color }"></i>{{ item.name }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="chart-unit">{{ unitLabel }}</span>
|
||||
</div>
|
||||
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
|
||||
</div>
|
||||
@@ -9,34 +19,59 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import { BarChart as EChartsBarChart, LineChart as EChartsLineChart } from 'echarts/charts'
|
||||
import {
|
||||
BarChart as EChartsBarChart,
|
||||
CustomChart as EChartsCustomChart,
|
||||
LineChart as EChartsLineChart
|
||||
} from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
|
||||
import { useEcharts } from '../../composables/useEcharts.js'
|
||||
import { useThemeColors } from '../../composables/useThemeColors.js'
|
||||
import { resolveCssColor, useThemeColors } from '../../composables/useThemeColors.js'
|
||||
|
||||
use([GridComponent, TooltipComponent, EChartsBarChart, EChartsLineChart, CanvasRenderer])
|
||||
use([GridComponent, TooltipComponent, EChartsBarChart, EChartsCustomChart, EChartsLineChart, CanvasRenderer])
|
||||
|
||||
const props = defineProps({
|
||||
labels: { type: Array, required: true },
|
||||
mode: { type: String, default: 'amount' },
|
||||
claimCount: { type: Array, default: () => [] },
|
||||
claimAmount: { type: Array, default: () => [] },
|
||||
categoryAmountSeries: { type: Array, default: () => [] },
|
||||
applications: { type: Array, default: () => [] },
|
||||
approved: { type: Array, default: () => [] },
|
||||
avgHours: { type: Array, default: () => [] }
|
||||
approved: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const chartElement = shallowRef(null)
|
||||
const themeColors = useThemeColors()
|
||||
const isCountMode = computed(() => props.mode === 'count')
|
||||
const chartColors = computed(() => ({
|
||||
primary: themeColors.value.chartPrimary,
|
||||
blue: themeColors.value.chartBlue
|
||||
blue: themeColors.value.chartBlue,
|
||||
amber: themeColors.value.chartAmber,
|
||||
purple: themeColors.value.chartPurple,
|
||||
success: themeColors.value.success,
|
||||
danger: themeColors.value.chartDanger
|
||||
}))
|
||||
const fallbackSeriesColors = computed(() => [
|
||||
chartColors.value.blue,
|
||||
chartColors.value.amber,
|
||||
chartColors.value.purple,
|
||||
chartColors.value.success,
|
||||
chartColors.value.danger,
|
||||
chartColors.value.primary
|
||||
])
|
||||
const expenseCategoryColorMap = computed(() => ({
|
||||
'差旅': chartColors.value.blue,
|
||||
'办公用品': chartColors.value.amber,
|
||||
'业务招待': chartColors.value.purple,
|
||||
'通讯': chartColors.value.success,
|
||||
'培训': '#65789b',
|
||||
'交通': chartColors.value.primary,
|
||||
'餐饮': '#9a7b4f',
|
||||
'会议': '#7f6c9f'
|
||||
}))
|
||||
const isCountMode = computed(() => props.mode === 'count')
|
||||
|
||||
const claimCountSeries = computed(() => (
|
||||
props.claimCount.length ? props.claimCount : props.applications
|
||||
))
|
||||
@@ -46,22 +81,108 @@ const claimAmountSeries = computed(() => (
|
||||
const activeSeries = computed(() => (
|
||||
isCountMode.value ? claimCountSeries.value : claimAmountSeries.value
|
||||
))
|
||||
const amountCategorySeries = computed(() => {
|
||||
if (isCountMode.value) {
|
||||
return []
|
||||
}
|
||||
return (Array.isArray(props.categoryAmountSeries) ? props.categoryAmountSeries : [])
|
||||
.filter((item) => Array.isArray(item.data) && item.data.some((value) => Number(value || 0) > 0))
|
||||
.slice(0, 6)
|
||||
})
|
||||
const stackedAmountData = computed(() => props.labels.map((_, index) => [
|
||||
index,
|
||||
...amountCategorySeries.value.map((item) => Number(item.data?.[index] || 0))
|
||||
]))
|
||||
const activeColor = computed(() => (
|
||||
isCountMode.value ? chartColors.value.primary : chartColors.value.blue
|
||||
))
|
||||
const legendLabel = computed(() => (
|
||||
isCountMode.value ? '报销数量(单)' : '报销金额(元)'
|
||||
isCountMode.value ? '报销数量' : '报销金额'
|
||||
))
|
||||
const unitLabel = computed(() => (isCountMode.value ? '单位:单' : '单位:元'))
|
||||
const legendItems = computed(() => {
|
||||
if (amountCategorySeries.value.length) {
|
||||
return amountCategorySeries.value.map((item, index) => ({
|
||||
name: item.name || `费用类型 ${index + 1}`,
|
||||
color: resolveCategoryColor(item, index),
|
||||
title: `${item.name || `费用类型 ${index + 1}`} ${formatCurrency(item.total || 0)}`
|
||||
}))
|
||||
}
|
||||
return [{
|
||||
name: legendLabel.value,
|
||||
color: activeColor.value,
|
||||
title: `${legendLabel.value} ${unitLabel.value}`
|
||||
}]
|
||||
})
|
||||
const maxValue = computed(() => Math.max(...activeSeries.value.map((value) => Number(value || 0)), 1))
|
||||
|
||||
const stackedMaxValue = computed(() => {
|
||||
if (!amountCategorySeries.value.length) {
|
||||
return maxValue.value
|
||||
}
|
||||
const dailyTotals = props.labels.map((_, index) => amountCategorySeries.value
|
||||
.reduce((sum, item) => sum + Number(item.data?.[index] || 0), 0))
|
||||
return Math.max(...dailyTotals, 1)
|
||||
})
|
||||
const ariaLabel = computed(() =>
|
||||
props.labels.map((label, index) => (
|
||||
isCountMode.value
|
||||
? `${label}报销${claimCountSeries.value[index] || 0}单`
|
||||
: `${label}报销金额${formatCurrency(claimAmountSeries.value[index] || 0)}`
|
||||
)).join(';')
|
||||
)).join(',')
|
||||
)
|
||||
const chartSeries = computed(() => {
|
||||
if (!isCountMode.value && amountCategorySeries.value.length) {
|
||||
return [{
|
||||
name: '费用类型占比',
|
||||
type: 'custom',
|
||||
data: stackedAmountData.value,
|
||||
renderItem: renderStackedAmountBar,
|
||||
animationDelay: (index) => index * 18,
|
||||
tooltip: {
|
||||
formatter: (params) => formatStackedTooltip(params)
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
return [{
|
||||
name: legendLabel.value,
|
||||
type: isCountMode.value ? 'line' : 'bar',
|
||||
data: activeSeries.value,
|
||||
barWidth: 16,
|
||||
smooth: isCountMode.value,
|
||||
symbol: isCountMode.value ? 'circle' : 'none',
|
||||
symbolSize: 7,
|
||||
lineStyle: {
|
||||
width: 2.5,
|
||||
color: activeColor.value
|
||||
},
|
||||
itemStyle: {
|
||||
color: isCountMode.value ? '#ffffff' : activeColor.value,
|
||||
borderColor: activeColor.value,
|
||||
borderWidth: isCountMode.value ? 2.5 : 0,
|
||||
borderRadius: [4, 4, 0, 0]
|
||||
},
|
||||
areaStyle: {
|
||||
opacity: isCountMode.value ? 1 : 0,
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: toRgba(activeColor.value, 0.14) },
|
||||
{ offset: 1, color: toRgba(activeColor.value, 0.02) }
|
||||
]
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
valueFormatter: (value) => (
|
||||
isCountMode.value ? `${Number(value || 0)} 单` : formatCurrency(value)
|
||||
)
|
||||
}
|
||||
}]
|
||||
})
|
||||
const chartOptions = computed(() => ({
|
||||
backgroundColor: 'transparent',
|
||||
animation: true,
|
||||
@@ -70,7 +191,7 @@ const chartOptions = computed(() => ({
|
||||
animationEasing: 'linear',
|
||||
animationEasingUpdate: 'linear',
|
||||
grid: {
|
||||
top: 18,
|
||||
top: 12,
|
||||
right: 24,
|
||||
bottom: 22,
|
||||
left: 36,
|
||||
@@ -89,7 +210,8 @@ const chartOptions = computed(() => ({
|
||||
fontSize: 12,
|
||||
fontWeight: 700
|
||||
},
|
||||
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);'
|
||||
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
|
||||
formatter: (params) => formatTooltip(params)
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
@@ -106,14 +228,9 @@ const chartOptions = computed(() => ({
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: Math.ceil(maxValue.value * 1.2),
|
||||
max: Math.ceil(stackedMaxValue.value * 1.18),
|
||||
splitNumber: 5,
|
||||
name: isCountMode.value ? '单' : '元',
|
||||
nameTextStyle: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontWeight: 700
|
||||
},
|
||||
name: '',
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
@@ -122,46 +239,7 @@ const chartOptions = computed(() => ({
|
||||
},
|
||||
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: legendLabel.value,
|
||||
type: isCountMode.value ? 'line' : 'bar',
|
||||
data: activeSeries.value,
|
||||
barWidth: 16,
|
||||
smooth: isCountMode.value,
|
||||
symbol: isCountMode.value ? 'circle' : 'none',
|
||||
symbolSize: 7,
|
||||
lineStyle: {
|
||||
width: 2.5,
|
||||
color: activeColor.value
|
||||
},
|
||||
itemStyle: {
|
||||
color: isCountMode.value ? '#ffffff' : activeColor.value,
|
||||
borderColor: activeColor.value,
|
||||
borderWidth: isCountMode.value ? 2.5 : 0,
|
||||
borderRadius: [4, 4, 0, 0]
|
||||
},
|
||||
areaStyle: {
|
||||
opacity: isCountMode.value ? 1 : 0,
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: toRgba(activeColor.value, 0.14) },
|
||||
{ offset: 1, color: toRgba(activeColor.value, 0.02) }
|
||||
]
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
valueFormatter: (value) => (
|
||||
isCountMode.value ? `${Number(value || 0)} 单` : formatCurrency(value)
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
series: chartSeries.value
|
||||
}))
|
||||
|
||||
useEcharts(chartElement, chartOptions)
|
||||
@@ -178,6 +256,134 @@ function toRgba(color, alpha) {
|
||||
return `rgba(58, 124, 165, ${alpha})`
|
||||
}
|
||||
|
||||
function resolveCategoryColor(item, index) {
|
||||
const name = String(item?.name || '').trim()
|
||||
const mapped = expenseCategoryColorMap.value[name]
|
||||
if (mapped) {
|
||||
return mapped
|
||||
}
|
||||
|
||||
const fallback = fallbackSeriesColors.value[index % fallbackSeriesColors.value.length]
|
||||
return resolveCssColor(item?.color, fallback)
|
||||
}
|
||||
|
||||
function renderStackedAmountBar(params, api) {
|
||||
const categoryIndex = Number(api.value(0))
|
||||
const zeroPoint = api.coord([categoryIndex, 0])
|
||||
const xCenter = zeroPoint[0]
|
||||
const zeroY = zeroPoint[1]
|
||||
const categoryWidth = api.size([1, 0])?.[0] || 32
|
||||
const barWidth = Math.max(12, Math.min(24, categoryWidth * 0.48))
|
||||
const barX = xCenter - barWidth / 2
|
||||
let accumulated = 0
|
||||
const values = amountCategorySeries.value.map((_, index) => Number(api.value(index + 1) || 0))
|
||||
const lastVisibleIndex = values.reduce((last, value, index) => (value > 0 ? index : last), -1)
|
||||
const children = []
|
||||
let topY = zeroY
|
||||
|
||||
values.forEach((value, index) => {
|
||||
if (value <= 0) {
|
||||
return
|
||||
}
|
||||
const lower = accumulated
|
||||
const upper = accumulated + value
|
||||
const lowerY = api.coord([categoryIndex, lower])[1]
|
||||
const upperY = api.coord([categoryIndex, upper])[1]
|
||||
const height = Math.max(1, lowerY - upperY)
|
||||
topY = Math.min(topY, upperY)
|
||||
accumulated = upper
|
||||
children.push({
|
||||
type: 'rect',
|
||||
shape: {
|
||||
x: barX,
|
||||
y: upperY,
|
||||
width: barWidth,
|
||||
height,
|
||||
r: index === lastVisibleIndex ? [4, 4, 0, 0] : 0
|
||||
},
|
||||
style: {
|
||||
fill: resolveCategoryColor(amountCategorySeries.value[index], index)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (!children.length) {
|
||||
return {
|
||||
type: 'group',
|
||||
children: []
|
||||
}
|
||||
}
|
||||
|
||||
const totalHeight = Math.max(1, zeroY - topY)
|
||||
return {
|
||||
type: 'group',
|
||||
originX: xCenter,
|
||||
originY: zeroY,
|
||||
scaleY: 1,
|
||||
enterFrom: {
|
||||
scaleY: 0
|
||||
},
|
||||
transition: ['scaleY'],
|
||||
clipPath: {
|
||||
type: 'rect',
|
||||
shape: {
|
||||
x: barX,
|
||||
y: topY,
|
||||
width: barWidth,
|
||||
height: totalHeight
|
||||
},
|
||||
enterFrom: {
|
||||
shape: {
|
||||
x: barX,
|
||||
y: zeroY,
|
||||
width: barWidth,
|
||||
height: 0
|
||||
}
|
||||
},
|
||||
transition: ['shape']
|
||||
},
|
||||
children
|
||||
}
|
||||
}
|
||||
|
||||
function formatTooltip(params) {
|
||||
const items = Array.isArray(params) ? params : [params]
|
||||
const first = items[0]
|
||||
if (!first) {
|
||||
return ''
|
||||
}
|
||||
if (!isCountMode.value && amountCategorySeries.value.length) {
|
||||
return formatStackedTooltip(first)
|
||||
}
|
||||
|
||||
const index = Number(first.dataIndex || 0)
|
||||
const label = props.labels[index] || first.axisValueLabel || first.name || ''
|
||||
const value = isCountMode.value ? claimCountSeries.value[index] : activeSeries.value[index]
|
||||
const displayValue = isCountMode.value ? `${Number(value || 0)} 单` : formatCurrency(value)
|
||||
return `${label}<br/>${legendLabel.value}:${displayValue}`
|
||||
}
|
||||
|
||||
function formatStackedTooltip(params) {
|
||||
const index = Number(params?.data?.[0] ?? params?.dataIndex ?? 0)
|
||||
const label = props.labels[index] || params?.axisValueLabel || ''
|
||||
const rows = amountCategorySeries.value
|
||||
.map((item, itemIndex) => ({
|
||||
name: item.name || `费用类型 ${itemIndex + 1}`,
|
||||
color: resolveCategoryColor(item, itemIndex),
|
||||
value: Number(item.data?.[index] || 0)
|
||||
}))
|
||||
.filter((item) => item.value > 0)
|
||||
const total = rows.reduce((sum, item) => sum + item.value, 0)
|
||||
const details = rows.map((item) => (
|
||||
`<span style="display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:6px;background:${item.color};"></span>${item.name}:${formatCurrency(item.value)}`
|
||||
))
|
||||
return [
|
||||
label,
|
||||
...details,
|
||||
`合计:${formatCurrency(total)}`
|
||||
].join('<br/>')
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
const number = Number(value || 0)
|
||||
if (number >= 1000000) return `¥${(number / 1000000).toFixed(1)}M`
|
||||
@@ -200,24 +406,61 @@ function formatAxisCurrency(value) {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chart-toolbar {
|
||||
min-height: 30px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.legend-pill {
|
||||
max-width: 132px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
color: #475569;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chart-legend i {
|
||||
flex: 0 0 auto;
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
margin-right: 4px;
|
||||
margin-right: 5px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.chart-unit {
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.chart-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@@ -37,6 +37,10 @@
|
||||
<slot name="side"></slot>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section v-if="$slots.bottom" class="detail-bottom">
|
||||
<slot name="bottom"></slot>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ const emptyFinanceTrend = {
|
||||
labels: [],
|
||||
claimCount: [],
|
||||
claimAmount: [],
|
||||
categoryAmountSeries: [],
|
||||
applications: [],
|
||||
approved: [],
|
||||
avgHours: []
|
||||
@@ -130,6 +131,9 @@ function resolveTopRangeKey(range, customRange = {}) {
|
||||
if (key === '\u672c\u5468' || key === '\u4eca\u65e5') {
|
||||
return `recent-${resolveTopRangeDays(key, customRange)}-days`
|
||||
}
|
||||
if (/\d+/.test(key)) {
|
||||
return `recent-${resolveTopRangeDays(key, customRange)}-days`
|
||||
}
|
||||
return key || DEFAULT_OVERVIEW_RANGE
|
||||
}
|
||||
|
||||
@@ -155,7 +159,9 @@ export function useOverviewView(options = {}) {
|
||||
const financeDashboardPayload = ref(null)
|
||||
const financeDashboardLoading = ref(false)
|
||||
const financeDashboardError = ref(null)
|
||||
const financeDashboardRenderKey = ref(0)
|
||||
const financeDashboardLoaded = computed(() => Boolean(financeDashboardPayload.value))
|
||||
let financeDashboardRequestSeq = 0
|
||||
const systemDashboardPayload = ref(null)
|
||||
const systemDashboardLoading = ref(false)
|
||||
const systemDashboardError = ref(null)
|
||||
@@ -226,16 +232,27 @@ export function useOverviewView(options = {}) {
|
||||
}
|
||||
|
||||
const loadFinanceDashboard = async () => {
|
||||
const requestSeq = ++financeDashboardRequestSeq
|
||||
financeDashboardLoading.value = true
|
||||
financeDashboardError.value = null
|
||||
|
||||
try {
|
||||
financeDashboardPayload.value = await fetchFinanceDashboard(getFinanceRangeParams())
|
||||
const payload = await fetchFinanceDashboard(getFinanceRangeParams())
|
||||
if (requestSeq !== financeDashboardRequestSeq) {
|
||||
return
|
||||
}
|
||||
financeDashboardPayload.value = payload
|
||||
financeDashboardRenderKey.value += 1
|
||||
} catch (error) {
|
||||
if (requestSeq !== financeDashboardRequestSeq) {
|
||||
return
|
||||
}
|
||||
financeDashboardPayload.value = null
|
||||
financeDashboardError.value = error
|
||||
} finally {
|
||||
financeDashboardLoading.value = false
|
||||
if (requestSeq === financeDashboardRequestSeq) {
|
||||
financeDashboardLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -889,6 +906,7 @@ export function useOverviewView(options = {}) {
|
||||
financeDashboardError,
|
||||
financeDashboardLoaded,
|
||||
financeDashboardLoading,
|
||||
financeDashboardRenderKey,
|
||||
formatCompact,
|
||||
formatCurrency,
|
||||
formatMetricValue,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
import { fetchExpenseClaims } from '../services/reimbursements.js'
|
||||
import { filterActionableRiskFlags } from '../utils/riskFlags.js'
|
||||
import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../utils/riskFlags.js'
|
||||
|
||||
const EXPENSE_TYPE_LABELS = {
|
||||
travel: '差旅费',
|
||||
@@ -429,13 +429,47 @@ function stringifyRiskFlag(value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
function buildRiskSummary(riskFlags) {
|
||||
const RISK_TONE_LABELS = {
|
||||
high: '高风险',
|
||||
medium: '中风险',
|
||||
low: '低风险'
|
||||
}
|
||||
|
||||
function resolveHighestRiskTone(flags) {
|
||||
const tones = flags.map((item) => normalizeRiskFlagTone(item)).filter(Boolean)
|
||||
if (tones.includes('high')) {
|
||||
return 'high'
|
||||
}
|
||||
if (tones.includes('medium')) {
|
||||
return 'medium'
|
||||
}
|
||||
if (tones.includes('low')) {
|
||||
return 'low'
|
||||
}
|
||||
return 'low'
|
||||
}
|
||||
|
||||
function buildRiskMeta(riskFlags) {
|
||||
if (!Array.isArray(riskFlags) || !riskFlags.length) {
|
||||
return '无'
|
||||
return { summary: '无', tone: 'low', label: '无' }
|
||||
}
|
||||
|
||||
const items = filterActionableRiskFlags(riskFlags).map((item) => stringifyRiskFlag(item)).filter(Boolean)
|
||||
return items.length ? items.join(';') : '无'
|
||||
const actionableFlags = filterActionableRiskFlags(riskFlags)
|
||||
const items = actionableFlags.map((item) => stringifyRiskFlag(item)).filter(Boolean)
|
||||
if (!items.length) {
|
||||
return { summary: '无', tone: 'low', label: '无' }
|
||||
}
|
||||
|
||||
const tone = resolveHighestRiskTone(actionableFlags)
|
||||
return {
|
||||
summary: items.join(';'),
|
||||
tone,
|
||||
label: RISK_TONE_LABELS[tone] || '待关注'
|
||||
}
|
||||
}
|
||||
|
||||
function buildRiskSummary(riskFlags) {
|
||||
return buildRiskMeta(riskFlags).summary
|
||||
}
|
||||
|
||||
function buildOccurredDisplay(claim) {
|
||||
@@ -1218,11 +1252,19 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}
|
||||
})
|
||||
}
|
||||
|
||||
function buildExpenseItems(claim, riskSummary) {
|
||||
function buildExpenseItems(claim, riskMeta) {
|
||||
if (!Array.isArray(claim?.items)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const normalizedRiskMeta = typeof riskMeta === 'string'
|
||||
? { summary: riskMeta, tone: riskMeta === '无' ? 'low' : 'medium', label: riskMeta === '无' ? '无' : '待关注' }
|
||||
: {
|
||||
summary: String(riskMeta?.summary || '无').trim() || '无',
|
||||
tone: String(riskMeta?.tone || 'low').trim() || 'low',
|
||||
label: String(riskMeta?.label || '').trim() || (String(riskMeta?.summary || '').trim() === '无' ? '无' : '待关注')
|
||||
}
|
||||
|
||||
const visibleItems = filterVisibleExpenseRawItems(claim.items, claim)
|
||||
const sortedItems = [...visibleItems].sort((left, right) => {
|
||||
const leftType = normalizeExpenseType(left?.item_type)
|
||||
@@ -1241,6 +1283,7 @@ function buildExpenseItems(claim, riskSummary) {
|
||||
const itemTypeLabel = resolveTypeLabel(itemType)
|
||||
const itemLocation = String(item?.item_location || '').trim()
|
||||
const itemReason = String(item?.item_reason || '').trim()
|
||||
const itemNote = String(item?.item_note || item?.itemNote || '').trim()
|
||||
const itemAmount = parseNumber(item?.item_amount)
|
||||
const itemAmountDisplay = itemAmount > 0 ? formatAmount(itemAmount) : '待补充'
|
||||
|
||||
@@ -1252,6 +1295,7 @@ function buildExpenseItems(claim, riskSummary) {
|
||||
itemType,
|
||||
itemReason,
|
||||
itemLocation,
|
||||
itemNote,
|
||||
itemAmount,
|
||||
invoiceId,
|
||||
isSystemGenerated,
|
||||
@@ -1273,9 +1317,9 @@ function buildExpenseItems(claim, riskSummary) {
|
||||
attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachments[0] : '仅支持上传 1 张 JPG、PNG、PDF 单据',
|
||||
attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing',
|
||||
attachments,
|
||||
riskLabel: riskSummary === '无' ? '无' : '待关注',
|
||||
riskText: riskSummary === '无' ? '' : riskSummary,
|
||||
riskTone: riskSummary === '无' ? 'low' : 'medium'
|
||||
riskLabel: normalizedRiskMeta.summary === '无' ? '无' : normalizedRiskMeta.label,
|
||||
riskText: normalizedRiskMeta.summary === '无' ? '' : normalizedRiskMeta.summary,
|
||||
riskTone: normalizedRiskMeta.summary === '无' ? 'low' : normalizedRiskMeta.tone
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1288,9 +1332,10 @@ export function mapExpenseClaimToRequest(claim) {
|
||||
const approvalMeta = resolveApprovalMeta(claim?.status)
|
||||
const workflowNode = resolveWorkflowNode(claim, approvalMeta, isApplicationDocument)
|
||||
const invoiceCount = Math.max(0, parseNumber(claim?.invoice_count))
|
||||
const riskSummary = buildRiskSummary(claim?.risk_flags_json)
|
||||
const riskMeta = buildRiskMeta(claim?.risk_flags_json)
|
||||
const riskSummary = riskMeta.summary
|
||||
const relatedApplication = isApplicationDocument ? null : resolveRelatedApplicationInfo(claim, typeLabel)
|
||||
const expenseItems = buildExpenseItems(claim, riskSummary)
|
||||
const expenseItems = buildExpenseItems(claim, riskMeta)
|
||||
const visibleExpenseAmount = expenseItems.reduce((sum, item) => sum + parseNumber(item.itemAmount), 0)
|
||||
const amountValue = relatedApplication
|
||||
? expenseItems.length
|
||||
@@ -1340,6 +1385,8 @@ export function mapExpenseClaimToRequest(claim) {
|
||||
updatedAt: claim?.updated_at || '',
|
||||
amount: amountValue,
|
||||
riskFlags: Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : [],
|
||||
riskTone: riskMeta.tone,
|
||||
riskLabel: riskMeta.label,
|
||||
invoiceCount,
|
||||
workflowNode,
|
||||
approvalKey: approvalMeta.key,
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import EnterprisePagination from '../components/shared/EnterprisePagination.vue'
|
||||
import TableEmptyState from '../components/shared/TableEmptyState.vue'
|
||||
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
||||
@@ -252,6 +253,17 @@ const DOCUMENT_SCOPE_REVIEW = '审核单'
|
||||
const DOCUMENT_SCOPE_ARCHIVE = '归档'
|
||||
const scopeTabs = [DOCUMENT_SCOPE_ALL, DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT, DOCUMENT_SCOPE_REVIEW, DOCUMENT_SCOPE_ARCHIVE]
|
||||
const DOCUMENT_LOADING_MIN_VISIBLE_MS = 720
|
||||
const DOCUMENT_CENTER_QUERY_KEYS = new Set([
|
||||
'dc_page',
|
||||
'dc_page_size',
|
||||
'dc_scope',
|
||||
'dc_status',
|
||||
'dc_doc_type',
|
||||
'dc_scene',
|
||||
'dc_q',
|
||||
'dc_start',
|
||||
'dc_end'
|
||||
])
|
||||
const statusTabs = ['全部', '草稿', '待提交', '审批中', '待补充', '待付款', '已完成']
|
||||
const FILTER_CONFIG_BY_SCOPE = {
|
||||
[DOCUMENT_SCOPE_ALL]: {
|
||||
@@ -296,11 +308,14 @@ const FILTER_CONFIG_BY_SCOPE = {
|
||||
}
|
||||
}
|
||||
const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
|
||||
const pageSizeValues = pageSizeOptions.map((item) => item.value)
|
||||
const documentTypeOptions = [
|
||||
{ value: DOCUMENT_TYPE_ALL, label: '单据类型' },
|
||||
{ value: DOCUMENT_TYPE_APPLICATION, label: '申请单' },
|
||||
{ value: DOCUMENT_TYPE_REIMBURSEMENT, label: '报销单' }
|
||||
]
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const props = defineProps({
|
||||
filteredRequests: { type: Array, required: true },
|
||||
hasData: { type: Boolean, default: false },
|
||||
@@ -315,19 +330,91 @@ const emit = defineEmits([
|
||||
'reload',
|
||||
'summary-change'
|
||||
])
|
||||
const activeScopeTab = ref(readDocumentScope(DOCUMENT_SCOPE_ALL, scopeTabs))
|
||||
const activeStatusTab = ref('全部')
|
||||
const activeDocumentType = ref(DOCUMENT_TYPE_ALL)
|
||||
const activeScene = ref(SCENE_ALL)
|
||||
|
||||
function readDocumentCenterQueryText(key) {
|
||||
const value = route.query?.[key]
|
||||
return String(Array.isArray(value) ? value[0] || '' : value || '').trim()
|
||||
}
|
||||
|
||||
function readDocumentCenterQueryNumber(key, fallback) {
|
||||
const parsed = Number(readDocumentCenterQueryText(key))
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback
|
||||
}
|
||||
|
||||
function resolveInitialScopeTab() {
|
||||
const queryScope = readDocumentCenterQueryText('dc_scope')
|
||||
if (scopeTabs.includes(queryScope)) {
|
||||
return queryScope
|
||||
}
|
||||
|
||||
return readDocumentScope(DOCUMENT_SCOPE_ALL, scopeTabs)
|
||||
}
|
||||
|
||||
function resolveInitialStatusTab(scope) {
|
||||
const queryStatus = readDocumentCenterQueryText('dc_status') || '全部'
|
||||
const config = FILTER_CONFIG_BY_SCOPE[scope] || FILTER_CONFIG_BY_SCOPE[DOCUMENT_SCOPE_ALL]
|
||||
return config.statusTabs.includes(queryStatus) ? queryStatus : '全部'
|
||||
}
|
||||
|
||||
function resolveInitialDocumentType() {
|
||||
const queryType = readDocumentCenterQueryText('dc_doc_type')
|
||||
return documentTypeOptions.some((item) => item.value === queryType)
|
||||
? queryType
|
||||
: DOCUMENT_TYPE_ALL
|
||||
}
|
||||
|
||||
function resolveInitialPageSize() {
|
||||
const queryPageSize = readDocumentCenterQueryNumber('dc_page_size', 20)
|
||||
return pageSizeValues.includes(queryPageSize) ? queryPageSize : 20
|
||||
}
|
||||
|
||||
function buildDocumentCenterRouteQuery() {
|
||||
const nextQuery = {}
|
||||
Object.entries(route.query || {}).forEach(([key, value]) => {
|
||||
if (!DOCUMENT_CENTER_QUERY_KEYS.has(key)) {
|
||||
nextQuery[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
if (currentPage.value > 1) nextQuery.dc_page = String(currentPage.value)
|
||||
if (pageSize.value !== 20) nextQuery.dc_page_size = String(pageSize.value)
|
||||
if (activeScopeTab.value !== DOCUMENT_SCOPE_ALL) nextQuery.dc_scope = activeScopeTab.value
|
||||
if (activeStatusTab.value !== '全部') nextQuery.dc_status = activeStatusTab.value
|
||||
if (showDocumentTypeFilter.value && activeDocumentType.value !== DOCUMENT_TYPE_ALL) {
|
||||
nextQuery.dc_doc_type = activeDocumentType.value
|
||||
}
|
||||
if (activeScene.value !== SCENE_ALL) nextQuery.dc_scene = activeScene.value
|
||||
if (listKeyword.value.trim()) nextQuery.dc_q = listKeyword.value.trim()
|
||||
if (appliedStart.value) nextQuery.dc_start = appliedStart.value
|
||||
if (appliedEnd.value) nextQuery.dc_end = appliedEnd.value
|
||||
|
||||
return nextQuery
|
||||
}
|
||||
|
||||
function routeQueryEquals(left, right) {
|
||||
const leftEntries = Object.entries(left || {}).map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : String(value ?? '')])
|
||||
const rightEntries = Object.entries(right || {}).map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : String(value ?? '')])
|
||||
if (leftEntries.length !== rightEntries.length) return false
|
||||
const rightMap = new Map(rightEntries)
|
||||
return leftEntries.every(([key, value]) => rightMap.get(key) === value)
|
||||
}
|
||||
|
||||
const initialScopeTab = resolveInitialScopeTab()
|
||||
const initialAppliedStart = readDocumentCenterQueryText('dc_start')
|
||||
const initialAppliedEnd = readDocumentCenterQueryText('dc_end')
|
||||
const activeScopeTab = ref(initialScopeTab)
|
||||
const activeStatusTab = ref(resolveInitialStatusTab(initialScopeTab))
|
||||
const activeDocumentType = ref(resolveInitialDocumentType())
|
||||
const activeScene = ref(readDocumentCenterQueryText('dc_scene') || SCENE_ALL)
|
||||
const openFilterKey = ref('')
|
||||
const listKeyword = ref('')
|
||||
const listKeyword = ref(readDocumentCenterQueryText('dc_q'))
|
||||
const datePopover = ref(false)
|
||||
const rangeStart = ref('')
|
||||
const rangeEnd = ref('')
|
||||
const appliedStart = ref('')
|
||||
const appliedEnd = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const rangeStart = ref(initialAppliedStart)
|
||||
const rangeEnd = ref(initialAppliedEnd)
|
||||
const appliedStart = ref(initialAppliedStart)
|
||||
const appliedEnd = ref(initialAppliedEnd)
|
||||
const currentPage = ref(readDocumentCenterQueryNumber('dc_page', 1))
|
||||
const pageSize = ref(resolveInitialPageSize())
|
||||
const archiveRows = ref([])
|
||||
const approvalRows = ref([])
|
||||
const supportingLoading = ref(false)
|
||||
@@ -795,6 +882,20 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
[currentPage, pageSize, activeScopeTab, activeStatusTab, activeDocumentType, activeScene, listKeyword, appliedStart, appliedEnd],
|
||||
() => {
|
||||
if (route.name !== 'app-documents') {
|
||||
return
|
||||
}
|
||||
|
||||
const nextQuery = buildDocumentCenterRouteQuery()
|
||||
if (!routeQueryEquals(route.query, nextQuery)) {
|
||||
router.replace({ name: 'app-documents', query: nextQuery })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(activeFilterConfig, () => {
|
||||
openFilterKey.value = ''
|
||||
datePopover.value = false
|
||||
|
||||
@@ -39,10 +39,12 @@
|
||||
</div>
|
||||
|
||||
<TrendChart
|
||||
:key="`finance-amount-${financeDashboardRenderKey}`"
|
||||
mode="amount"
|
||||
:labels="activeTrend.labels"
|
||||
:claim-count="activeTrend.claimCount"
|
||||
:claim-amount="activeTrend.claimAmount"
|
||||
:category-amount-series="activeTrend.categoryAmountSeries"
|
||||
/>
|
||||
</article>
|
||||
|
||||
@@ -52,6 +54,7 @@
|
||||
</div>
|
||||
|
||||
<TrendChart
|
||||
:key="`finance-count-${financeDashboardRenderKey}`"
|
||||
mode="count"
|
||||
:labels="activeTrend.labels"
|
||||
:claim-count="activeTrend.claimCount"
|
||||
@@ -362,13 +365,12 @@ const {
|
||||
digitalEmployeeCategoryRows,
|
||||
digitalEmployeeDashboard,
|
||||
digitalEmployeeDashboardError,
|
||||
digitalEmployeeDashboardLoaded,
|
||||
digitalEmployeeDashboardLoading,
|
||||
digitalEmployeeDailyRows,
|
||||
digitalEmployeeKpiMetrics,
|
||||
digitalEmployeeTaskRanking,
|
||||
financeDashboardLoading,
|
||||
financeDashboardLoaded,
|
||||
financeDashboardRenderKey,
|
||||
kpiMetrics,
|
||||
rankedDepartments,
|
||||
rankedEmployees,
|
||||
@@ -385,7 +387,6 @@ const {
|
||||
spendCenterValue,
|
||||
spendLegend,
|
||||
systemDashboardLoading,
|
||||
systemDashboardLoaded,
|
||||
systemAccuracyComparison,
|
||||
systemAgentDailyRatio,
|
||||
systemFeedbackSummary,
|
||||
@@ -413,15 +414,15 @@ const activeKpiMetrics = computed(() => {
|
||||
})
|
||||
const activeDashboardLoading = computed(() => {
|
||||
if (activeDashboard.value === 'system') {
|
||||
return systemDashboardLoading.value && !systemDashboardLoaded.value
|
||||
return systemDashboardLoading.value
|
||||
}
|
||||
if (activeDashboard.value === 'digitalEmployee') {
|
||||
return digitalEmployeeDashboardLoading.value && !digitalEmployeeDashboardLoaded.value
|
||||
return digitalEmployeeDashboardLoading.value
|
||||
}
|
||||
if (activeDashboard.value === 'risk') {
|
||||
return riskDashboardLoading.value && !riskDashboardLoaded.value
|
||||
}
|
||||
return financeDashboardLoading.value && !financeDashboardLoaded.value
|
||||
return financeDashboardLoading.value
|
||||
})
|
||||
const activeDashboardLoadingText = computed(() => {
|
||||
if (activeDashboard.value === 'system') return '正在加载系统看板数据'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<section class="receipt-folder-page">
|
||||
<article v-if="!detailMode" class="receipt-folder-list panel">
|
||||
<article v-if="!detailMode" class="receipt-folder-list documents-list panel">
|
||||
<nav class="status-tabs receipt-status-tabs" aria-label="票据关联状态">
|
||||
<button
|
||||
v-for="tab in receiptTabs"
|
||||
@@ -134,36 +134,8 @@
|
||||
loading-icon="mdi mdi-receipt-text-outline"
|
||||
@back="backToList"
|
||||
>
|
||||
<section class="receipt-detail-toolbar panel">
|
||||
<div class="receipt-detail-title">
|
||||
<strong>票据详情</strong>
|
||||
<span>{{ receiptDetailTitle }}</span>
|
||||
<p>查看识别结果、校验状态、关联单据与处理记录</p>
|
||||
</div>
|
||||
|
||||
<div class="receipt-toolbar-actions">
|
||||
<button class="minor-action" type="button" @click="reloadCurrentReceipt">
|
||||
<i class="mdi mdi-refresh"></i>
|
||||
<span>重新读取</span>
|
||||
</button>
|
||||
<button
|
||||
class="minor-action"
|
||||
type="button"
|
||||
:disabled="selectedReceipt?.status === 'linked'"
|
||||
@click="openAssociateDialogForCurrentReceipt"
|
||||
>
|
||||
<i class="mdi mdi-link-variant-plus"></i>
|
||||
<span>关联单据</span>
|
||||
</button>
|
||||
<button class="major-action" type="button" :disabled="savingDetail" @click="saveDetail">
|
||||
<i class="mdi mdi-content-save-outline"></i>
|
||||
<span>{{ savingDetail ? '保存中' : '保存修改' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="receipt-dashboard">
|
||||
<EnterpriseDetailCard class="receipt-preview-panel receipt-dashboard-preview" title="票据预览">
|
||||
<template #main>
|
||||
<EnterpriseDetailCard class="receipt-preview-panel" title="票据预览">
|
||||
<div class="receipt-preview-frame">
|
||||
<div class="receipt-preview-box">
|
||||
<img
|
||||
@@ -172,7 +144,7 @@
|
||||
:style="{ transform: previewTransform }"
|
||||
alt="票据预览"
|
||||
/>
|
||||
<iframe v-else-if="previewKind === 'pdf' && previewObjectUrl" :src="previewObjectUrl" title="票据 PDF 预览"></iframe>
|
||||
<iframe v-else-if="previewKind === 'pdf' && previewObjectUrl" :src="previewFrameUrl" title="票据 PDF 预览"></iframe>
|
||||
<div v-else class="preview-empty">
|
||||
<i class="mdi mdi-file-eye-outline"></i>
|
||||
<strong>当前文件暂不支持内嵌预览</strong>
|
||||
@@ -201,115 +173,110 @@
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<section class="receipt-edit-log-section">
|
||||
<header>
|
||||
<strong>用户编辑操作</strong>
|
||||
<span>{{ receiptEditLogs.length }} 条</span>
|
||||
</header>
|
||||
<ol v-if="receiptEditLogs.length" class="receipt-edit-log-list">
|
||||
<li v-for="log in receiptEditLogs" :key="`${log.operated_at}-${log.operator}`">
|
||||
<div class="receipt-edit-log-meta">
|
||||
<strong>{{ log.operator || '当前用户' }}</strong>
|
||||
<span>{{ formatDateTime(log.operated_at) }}</span>
|
||||
</div>
|
||||
<p v-for="change in log.changes" :key="`${change.key}-${change.before}-${change.after}`">
|
||||
<span>{{ change.label || change.key }}</span>
|
||||
<em>{{ change.before || '空' }}</em>
|
||||
<i class="mdi mdi-arrow-right"></i>
|
||||
<strong>{{ change.after || '空' }}</strong>
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
<div v-else class="receipt-edit-log-empty">
|
||||
<i class="mdi mdi-history"></i>
|
||||
<span>暂无用户修改记录。</span>
|
||||
</div>
|
||||
</section>
|
||||
</EnterpriseDetailCard>
|
||||
</template>
|
||||
|
||||
<div class="receipt-dashboard-side">
|
||||
<EnterpriseDetailCard class="receipt-basic-panel" title="基础信息">
|
||||
<template #actions>
|
||||
<span class="receipt-card-count">{{ keyReceiptFields.length }} 项可编辑</span>
|
||||
</template>
|
||||
<template #side>
|
||||
<EnterpriseDetailCard class="receipt-ticket-info-panel" title="识别票据详情">
|
||||
<template #actions>
|
||||
<div class="receipt-card-actions">
|
||||
<button v-if="!receiptInfoEditing" class="minor-action" type="button" @click="startReceiptInfoEdit">
|
||||
<i class="mdi mdi-pencil-outline"></i>
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
<template v-else>
|
||||
<button class="minor-action" type="button" :disabled="savingDetail" @click="cancelReceiptInfoEdit">
|
||||
<span>取消</span>
|
||||
</button>
|
||||
<button class="major-action" type="button" :disabled="savingDetail" @click="saveDetail">
|
||||
<i class="mdi mdi-content-save-outline"></i>
|
||||
<span>{{ savingDetail ? '保存中' : '保存' }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="receipt-key-grid">
|
||||
<label v-for="field in keyReceiptFields" :key="field.id" class="receipt-key-field">
|
||||
<span>{{ field.label }}</span>
|
||||
<input
|
||||
:value="field.value"
|
||||
type="text"
|
||||
:placeholder="field.placeholder"
|
||||
@input="updateReceiptField(field, $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
<div class="receipt-static-grid">
|
||||
<div v-for="item in basicInfoItems" :key="item.label" class="receipt-static-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="receipt-ticket-section">
|
||||
<div class="receipt-section-head">
|
||||
<strong>识别字段</strong>
|
||||
<small>{{ detailForm.fields.length }} 项</small>
|
||||
</div>
|
||||
|
||||
<div class="receipt-static-grid">
|
||||
<div v-for="item in basicInfoItems" :key="item.label" class="receipt-static-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</EnterpriseDetailCard>
|
||||
|
||||
<EnterpriseDetailCard class="receipt-ocr-panel" title="OCR识别结果">
|
||||
<div v-if="ocrPreviewFields.length" class="receipt-ocr-grid">
|
||||
<label v-for="field in ocrPreviewFields" :key="field.key || field.label" class="receipt-ocr-field">
|
||||
<div v-if="detailForm.fields.length" class="receipt-all-field-grid" :class="{ editing: receiptInfoEditing }">
|
||||
<label v-for="field in detailForm.fields" :key="field.key || field.label" class="receipt-ocr-field">
|
||||
<span>{{ field.label || field.key }}</span>
|
||||
<input v-model="field.value" type="text" placeholder="字段值" @input="syncEditableFieldsToTopLevel" />
|
||||
<input
|
||||
v-if="receiptInfoEditing"
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
placeholder="字段值"
|
||||
@input="syncEditableFieldsToTopLevel"
|
||||
/>
|
||||
<strong v-else>{{ field.value || '待补全' }}</strong>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="receipt-field-empty">
|
||||
<i class="mdi mdi-information-outline"></i>
|
||||
<span>暂无可展示的 OCR 识别字段</span>
|
||||
<span>暂无可展示的 OCR 识别字段。</span>
|
||||
</div>
|
||||
</section>
|
||||
</EnterpriseDetailCard>
|
||||
</template>
|
||||
|
||||
<ElCollapse v-model="expandedFieldPanels" class="receipt-other-collapse">
|
||||
<ElCollapseItem name="other">
|
||||
<template #title>
|
||||
<div class="receipt-collapse-title">
|
||||
<strong>其他信息</strong>
|
||||
<small>{{ editableOtherFields.length }} 项</small>
|
||||
</div>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<EnterpriseDetailCard class="receipt-association-panel" title="关联信息">
|
||||
<template #actions>
|
||||
<button
|
||||
v-if="selectedReceipt?.status !== 'linked'"
|
||||
class="minor-action"
|
||||
type="button"
|
||||
@click="openAssociateDialogForCurrentReceipt"
|
||||
>
|
||||
<i class="mdi mdi-link-variant-plus"></i>
|
||||
<span>关联单据</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div v-if="editableOtherFields.length" class="receipt-other-scroll">
|
||||
<div
|
||||
v-for="(field, index) in editableOtherFields"
|
||||
:key="`${field.key || field.label}-${index}`"
|
||||
class="receipt-edit-field-row"
|
||||
>
|
||||
<label>
|
||||
<span>字段名</span>
|
||||
<input v-model="field.label" type="text" placeholder="字段名" />
|
||||
</label>
|
||||
<label>
|
||||
<span>字段值</span>
|
||||
<input v-model="field.value" type="text" placeholder="字段值" @input="syncEditableFieldsToTopLevel" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</ElCollapseItem>
|
||||
</ElCollapse>
|
||||
</EnterpriseDetailCard>
|
||||
|
||||
<EnterpriseDetailCard class="receipt-status-panel" title="处理状态">
|
||||
<div class="receipt-status-grid">
|
||||
<div v-for="item in receiptStatusItems" :key="item.label" class="receipt-status-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong :class="`tone-${item.tone}`">{{ item.value }}</strong>
|
||||
</div>
|
||||
<div class="receipt-data-list association">
|
||||
<div v-for="item in linkedClaimItems" :key="item.label" class="receipt-data-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</EnterpriseDetailCard>
|
||||
</div>
|
||||
|
||||
<div class="receipt-dashboard-bottom">
|
||||
<EnterpriseDetailCard class="receipt-info-panel" title="关联单据信息">
|
||||
<div class="receipt-data-list">
|
||||
<div v-for="item in linkedClaimItems" :key="item.label" class="receipt-data-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</EnterpriseDetailCard>
|
||||
|
||||
<EnterpriseDetailCard class="receipt-log-panel" title="处理记录 / 操作日志">
|
||||
<ol class="receipt-log-list">
|
||||
<li v-for="item in operationLogs" :key="`${item.time}-${item.label}`">
|
||||
<span>{{ item.time }}</span>
|
||||
<strong>{{ item.operator }}</strong>
|
||||
<p>{{ item.label }}</p>
|
||||
</li>
|
||||
</ol>
|
||||
</EnterpriseDetailCard>
|
||||
|
||||
<EnterpriseDetailCard class="receipt-info-panel" title="归档信息">
|
||||
<div class="receipt-data-list">
|
||||
<div v-for="item in archiveInfoItems" :key="item.label" class="receipt-data-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</EnterpriseDetailCard>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</EnterpriseDetailCard>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<button class="minor-action danger-action" type="button" :disabled="deleting" @click="deleteCurrentReceipt">
|
||||
@@ -380,7 +347,6 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElCheckbox, ElCheckboxGroup } from 'element-plus/es/components/checkbox/index.mjs'
|
||||
import { ElCollapse, ElCollapseItem } from 'element-plus/es/components/collapse/index.mjs'
|
||||
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
|
||||
|
||||
import EnterprisePagination from '../components/shared/EnterprisePagination.vue'
|
||||
@@ -416,13 +382,13 @@ const detailLoading = ref(false)
|
||||
const savingDetail = ref(false)
|
||||
const deleting = ref(false)
|
||||
const previewObjectUrl = ref('')
|
||||
const receiptInfoEditing = ref(false)
|
||||
const associateDialogOpen = ref(false)
|
||||
const associateStep = ref(1)
|
||||
const selectedReceiptIds = ref([])
|
||||
const targetDraftId = ref(NEW_CLAIM_VALUE)
|
||||
const draftClaims = ref([])
|
||||
const associateBusy = ref(false)
|
||||
const expandedFieldPanels = ref([])
|
||||
|
||||
const detailForm = reactive({
|
||||
file_name: '',
|
||||
@@ -514,28 +480,20 @@ const isTrainTicket = computed(() => {
|
||||
})
|
||||
const {
|
||||
buildDetailPayload,
|
||||
editableOtherFields,
|
||||
ensureEditableReceiptFields,
|
||||
keyReceiptFields,
|
||||
syncEditableFieldsToTopLevel,
|
||||
updateReceiptField
|
||||
syncEditableFieldsToTopLevel
|
||||
} = createReceiptDetailFieldModel({ detailForm, isTrainTicket })
|
||||
const {
|
||||
adjustPreviewZoom,
|
||||
archiveInfoItems,
|
||||
basicInfoItems,
|
||||
linkedClaimItems,
|
||||
ocrPreviewFields,
|
||||
operationLogs,
|
||||
previewPageLabel,
|
||||
previewTransform,
|
||||
previewZoom,
|
||||
receiptStatusItems,
|
||||
resetPreviewView,
|
||||
rotatePreview
|
||||
} = createReceiptDetailDashboardModel({
|
||||
detailForm,
|
||||
editableOtherFields,
|
||||
formatDateTime,
|
||||
formatScore,
|
||||
selectedReceipt
|
||||
@@ -554,6 +512,15 @@ const receiptDetailTopBarPayload = computed(() => (
|
||||
: null
|
||||
))
|
||||
const previewKind = computed(() => selectedReceipt.value?.preview_kind || '')
|
||||
const previewFrameUrl = computed(() => (
|
||||
previewKind.value === 'pdf' && previewObjectUrl.value
|
||||
? `${previewObjectUrl.value}#toolbar=0&navpanes=0&view=Fit`
|
||||
: previewObjectUrl.value
|
||||
))
|
||||
const receiptEditLogs = computed(() => {
|
||||
const logs = selectedReceipt.value?.edit_logs || selectedReceipt.value?.editLogs || []
|
||||
return Array.isArray(logs) ? logs : []
|
||||
})
|
||||
const canProceedAssociate = computed(() => (
|
||||
associateStep.value === 1
|
||||
? selectedReceiptIds.value.length > 0
|
||||
@@ -635,7 +602,7 @@ function fillDetailForm(detail) {
|
||||
detailForm.fields = Array.isArray(detail.fields)
|
||||
? detail.fields.map((field) => ({ ...field }))
|
||||
: []
|
||||
expandedFieldPanels.value = []
|
||||
receiptInfoEditing.value = false
|
||||
resetPreviewView()
|
||||
ensureEditableReceiptFields()
|
||||
syncEditableFieldsToTopLevel()
|
||||
@@ -660,6 +627,7 @@ function revokePreviewUrl() {
|
||||
|
||||
function backToList() {
|
||||
selectedReceipt.value = null
|
||||
receiptInfoEditing.value = false
|
||||
revokePreviewUrl()
|
||||
}
|
||||
|
||||
@@ -668,6 +636,17 @@ async function reloadCurrentReceipt() {
|
||||
await openDetail(selectedReceipt.value)
|
||||
}
|
||||
|
||||
function startReceiptInfoEdit() {
|
||||
receiptInfoEditing.value = true
|
||||
}
|
||||
|
||||
function cancelReceiptInfoEdit() {
|
||||
if (selectedReceipt.value) {
|
||||
fillDetailForm(selectedReceipt.value)
|
||||
}
|
||||
receiptInfoEditing.value = false
|
||||
}
|
||||
|
||||
async function saveDetail() {
|
||||
if (!selectedReceipt.value?.id || savingDetail.value) return
|
||||
savingDetail.value = true
|
||||
@@ -776,6 +755,7 @@ function formatScore(value) {
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!String(value ?? '').trim()) return '待确认'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '待确认'
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
|
||||
@@ -126,9 +126,9 @@
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="!isApplicationDocument" class="detail-card-actions">
|
||||
<button v-if="canOpenAiEntry" class="smart-entry-btn" type="button" @click="openAiEntry">
|
||||
<button v-if="canOpenAiEntry" class="smart-entry-btn" type="button" :disabled="actionBusy" @click="triggerSmartEntryUpload">
|
||||
<i class="mdi mdi-robot-outline"></i>
|
||||
<span>智能录入</span>
|
||||
<span>{{ uploadingExpenseId ? '识别中' : '智能录入' }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="isEditableRequest"
|
||||
@@ -190,6 +190,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isApplicationDocument" class="detail-expense-table">
|
||||
<div v-if="smartEntryRecognitionBusy" class="expense-recognition-banner">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>{{ smartEntryRecognitionText }}</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -199,6 +203,7 @@
|
||||
<th class="col-desc">说明</th>
|
||||
<th class="col-amount">金额</th>
|
||||
<th class="col-attachment">附件材料</th>
|
||||
<th class="col-risk-note">异常说明</th>
|
||||
<th v-if="isEditableRequest" class="col-action">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -209,13 +214,17 @@
|
||||
<strong>{{ item.filledAt }}</strong>
|
||||
<span>条款填写时间</span>
|
||||
</td>
|
||||
<td :class="['expense-time col-time', { 'has-major-risk': isMajorExpenseRisk(item) }]">
|
||||
<i
|
||||
v-if="isMajorExpenseRisk(item)"
|
||||
class="mdi mdi-alert expense-risk-indicator"
|
||||
<td :class="['expense-time col-time', { 'has-major-risk': hasExpenseRiskIndicator(item) }]">
|
||||
<button
|
||||
v-if="hasExpenseRiskIndicator(item)"
|
||||
class="expense-risk-indicator"
|
||||
type="button"
|
||||
:title="resolveExpenseRiskIndicatorTitle(item)"
|
||||
:aria-label="resolveExpenseRiskIndicatorTitle(item)"
|
||||
></i>
|
||||
@click="focusExpenseRisk(item)"
|
||||
>
|
||||
<i class="mdi mdi-alert"></i>
|
||||
</button>
|
||||
<template v-if="editingExpenseId === item.id">
|
||||
<div class="cell-editor">
|
||||
<input v-model="expenseEditor.itemDate" class="editor-input" type="date" />
|
||||
@@ -281,6 +290,10 @@
|
||||
<td class="expense-attachment col-attachment">
|
||||
<template v-if="editingExpenseId === item.id">
|
||||
<div class="cell-editor editor-stack">
|
||||
<div v-if="uploadingExpenseId === item.id" class="system-attachment-note pending">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>识别中</span>
|
||||
</div>
|
||||
<div class="attachment-action-group">
|
||||
<button
|
||||
v-if="isEditableRequest && !item.invoiceId && !item.isSystemGenerated"
|
||||
@@ -318,7 +331,11 @@
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="item.isSystemGenerated" class="system-attachment-note">
|
||||
<div v-if="uploadingExpenseId === item.id" class="system-attachment-note pending">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>识别中</span>
|
||||
</div>
|
||||
<div v-else-if="item.isSystemGenerated" class="system-attachment-note">
|
||||
<i class="mdi mdi-calculator-variant-outline"></i>
|
||||
<span>无需附件</span>
|
||||
</div>
|
||||
@@ -358,6 +375,24 @@
|
||||
</div>
|
||||
</template>
|
||||
</td>
|
||||
<td class="expense-risk-note col-risk-note">
|
||||
<template v-if="editingExpenseId === item.id">
|
||||
<div class="cell-editor">
|
||||
<textarea
|
||||
v-model="expenseEditor.itemNote"
|
||||
class="editor-textarea"
|
||||
rows="3"
|
||||
placeholder="如票据存在异常或风险,请补充原因"
|
||||
></textarea>
|
||||
<span>用于说明改签、绕行、超标、票据异常等情况</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<strong v-if="item.itemNote">{{ item.itemNote }}</strong>
|
||||
<span v-else-if="hasExpenseRiskOrAbnormal(item)" class="risk-note-missing">待补充异常说明</span>
|
||||
<span v-else>无异常说明</span>
|
||||
</template>
|
||||
</td>
|
||||
<td v-if="isEditableRequest" class="expense-action-cell col-action">
|
||||
<div v-if="item.isSystemGenerated" class="system-row-lock">
|
||||
<i class="mdi mdi-lock-outline"></i>
|
||||
@@ -438,7 +473,9 @@
|
||||
<article
|
||||
v-for="card in section.items"
|
||||
:key="card.id"
|
||||
:class="['risk-advice-card', card.tone]"
|
||||
:id="resolveRiskCardDomId(card)"
|
||||
:data-risk-card-id="card.id"
|
||||
:class="['risk-advice-card', card.tone, { 'is-highlighted': isHighlightedRiskCard(card) }]"
|
||||
>
|
||||
<div class="risk-advice-card-main">
|
||||
<div class="risk-advice-card-head">
|
||||
@@ -545,6 +582,58 @@
|
||||
accept="image/*,.pdf"
|
||||
@change="handleExpenseFileChange"
|
||||
/>
|
||||
<input
|
||||
ref="smartEntryUploadInput"
|
||||
class="expense-upload-input"
|
||||
type="file"
|
||||
accept="image/*,.pdf"
|
||||
multiple
|
||||
@change="handleSmartEntryFileChange"
|
||||
/>
|
||||
<ConfirmDialog
|
||||
:open="smartEntryUploadDialogOpen"
|
||||
badge="智能录入"
|
||||
title="上传报销附件"
|
||||
description="请选择需要识别并归集到当前草稿的票据附件,确认前可以清除或重新选择。"
|
||||
cancel-text="取消"
|
||||
confirm-text="确认识别"
|
||||
busy-text="识别中"
|
||||
confirm-icon="mdi mdi-file-search-outline"
|
||||
:busy="smartEntryUploadBusy"
|
||||
@close="closeSmartEntryUploadDialog"
|
||||
@confirm="confirmSmartEntryUpload"
|
||||
>
|
||||
<div class="smart-entry-upload-panel">
|
||||
<button
|
||||
class="smart-entry-upload-picker"
|
||||
type="button"
|
||||
:disabled="smartEntryUploadBusy"
|
||||
@click="chooseSmartEntryFile"
|
||||
>
|
||||
<i class="mdi mdi-tray-arrow-up"></i>
|
||||
<span>{{ smartEntrySelectedFileCount ? '重新选择附件' : '选择附件' }}</span>
|
||||
</button>
|
||||
<div class="smart-entry-upload-file">
|
||||
<i :class="smartEntrySelectedFileCount ? 'mdi mdi-file-check-outline' : 'mdi mdi-file-outline'"></i>
|
||||
<div>
|
||||
<strong>{{ smartEntrySelectedFileSummary || '尚未选择附件' }}</strong>
|
||||
<span>支持 JPG、PNG、PDF;确认后系统会逐张识别并归集到草稿明细。</span>
|
||||
<ul v-if="smartEntrySelectedFileNames.length" class="smart-entry-upload-list">
|
||||
<li v-for="fileName in smartEntrySelectedFileNames" :key="fileName">{{ fileName }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button
|
||||
v-if="smartEntrySelectedFileCount"
|
||||
class="smart-entry-upload-clear"
|
||||
type="button"
|
||||
:disabled="smartEntryUploadBusy"
|
||||
@click="clearSmartEntryFile"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
<Transition name="shared-confirm">
|
||||
<div
|
||||
v-if="attachmentPreviewOpen"
|
||||
|
||||
@@ -1606,6 +1606,24 @@ export default {
|
||||
if (await handleGuidedSuggestedAction(message, action)) return
|
||||
if (await handleSceneSelectionApplicationGate(message, action)) return
|
||||
|
||||
if (actionType === 'open_receipt_folder') {
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
await router.push({ name: 'app-receiptFolder' })
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === 'continue_upload_with_unlinked_receipts') {
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||
await submitComposer({
|
||||
rawText: String(actionPayload.raw_text || composerDraft.value || '').trim(),
|
||||
files: Array.from(attachedFiles.value || []),
|
||||
skipReceiptFolderUnlinkedPrompt: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === TRAVEL_PLANNING_ACTION_GENERATE) {
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
const sourcePreview = action?.payload?.applicationPreview || action?.payload?.preview || null
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
@@ -94,6 +94,223 @@ import {
|
||||
} from './travelRequestDetailAdviceModel.js'
|
||||
import { useTravelRequestPaymentFlow } from './travelRequestDetailPaymentFlow.js'
|
||||
|
||||
const SMART_ENTRY_RECOGNITION_TASK_RETENTION_MS = 10 * 60 * 1000
|
||||
const smartEntryRecognitionTasks = new Map()
|
||||
let smartEntryRecognitionTaskSeq = 0
|
||||
|
||||
function normalizeSmartEntryClaimId(claimId) {
|
||||
return String(claimId || '').trim()
|
||||
}
|
||||
|
||||
function buildRecognizedExpenseItemPatch(payload, fileName = '') {
|
||||
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount)
|
||||
const recognizedItemDate = normalizeIsoDateValue(payload?.item_date ?? payload?.itemDate)
|
||||
const recognizedItemType = String(payload?.item_type ?? payload?.itemType ?? '').trim()
|
||||
const recognizedItemReason = String(payload?.item_reason ?? payload?.itemReason ?? '').trim()
|
||||
const recognizedItemLocation = String(payload?.item_location ?? payload?.itemLocation ?? '').trim()
|
||||
const itemPatch = {
|
||||
invoiceId: String(payload?.invoice_id || '').trim(),
|
||||
attachmentHint: String(payload?.attachment?.file_name || fileName || '').trim()
|
||||
}
|
||||
if (recognizedItemDate) {
|
||||
itemPatch.itemDate = recognizedItemDate
|
||||
}
|
||||
if (recognizedItemType) {
|
||||
itemPatch.itemType = recognizedItemType
|
||||
}
|
||||
if (recognizedItemReason) {
|
||||
itemPatch.itemReason = recognizedItemReason
|
||||
}
|
||||
if (recognizedItemLocation) {
|
||||
itemPatch.itemLocation = recognizedItemLocation
|
||||
}
|
||||
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
|
||||
itemPatch.itemAmount = recognizedItemAmount
|
||||
itemPatch.amount = formatCurrency(recognizedItemAmount)
|
||||
}
|
||||
return itemPatch
|
||||
}
|
||||
|
||||
function buildSmartEntryRecognitionSnapshot(task) {
|
||||
if (!task) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
claimId: task.claimId,
|
||||
busy: task.busy,
|
||||
total: task.total,
|
||||
current: task.current,
|
||||
completed: task.completed,
|
||||
successCount: task.successCount,
|
||||
failedCount: task.failedCount,
|
||||
uploadingItemId: task.uploadingItemId,
|
||||
fileName: task.fileName,
|
||||
status: task.status,
|
||||
payloads: [...task.payloads],
|
||||
errors: [...task.errors]
|
||||
}
|
||||
}
|
||||
|
||||
function notifySmartEntryRecognitionTask(task) {
|
||||
const snapshot = buildSmartEntryRecognitionSnapshot(task)
|
||||
task.listeners.forEach((listener) => {
|
||||
try {
|
||||
listener(snapshot)
|
||||
} catch (error) {
|
||||
console.error('同步附件识别状态失败', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleSmartEntryRecognitionTaskCleanup(task) {
|
||||
if (task.cleanupTimer) {
|
||||
clearTimeout(task.cleanupTimer)
|
||||
}
|
||||
task.cleanupTimer = globalThis.setTimeout(() => {
|
||||
const currentTask = smartEntryRecognitionTasks.get(task.claimId)
|
||||
if (currentTask?.id === task.id && !currentTask.busy) {
|
||||
smartEntryRecognitionTasks.delete(task.claimId)
|
||||
}
|
||||
}, SMART_ENTRY_RECOGNITION_TASK_RETENTION_MS)
|
||||
}
|
||||
|
||||
function getSmartEntryRecognitionTask(claimId) {
|
||||
return smartEntryRecognitionTasks.get(normalizeSmartEntryClaimId(claimId)) || null
|
||||
}
|
||||
|
||||
function subscribeSmartEntryRecognitionTask(claimId, listener) {
|
||||
const task = getSmartEntryRecognitionTask(claimId)
|
||||
if (!task) {
|
||||
listener(null)
|
||||
return () => {}
|
||||
}
|
||||
|
||||
task.listeners.add(listener)
|
||||
listener(buildSmartEntryRecognitionSnapshot(task))
|
||||
return () => {
|
||||
task.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSmartEntryTaskAvailableItems(itemSnapshots) {
|
||||
return (Array.isArray(itemSnapshots) ? itemSnapshots : [])
|
||||
.filter((item) => item && !item.isSystemGenerated && !item.invoiceId)
|
||||
.map((item) => ({ id: String(item.id || '').trim() }))
|
||||
.filter((item) => item.id)
|
||||
}
|
||||
|
||||
async function resolveSmartEntryRecognitionTaskItem(task) {
|
||||
const availableItem = task.availableItems.shift()
|
||||
if (availableItem?.id) {
|
||||
return { id: availableItem.id, createdItem: null }
|
||||
}
|
||||
|
||||
const claim = await createExpenseClaimItem(task.claimId, {})
|
||||
const items = Array.isArray(claim?.items) ? claim.items : []
|
||||
const createdItem = items.find((entry) => {
|
||||
const itemId = String(entry?.id || '').trim()
|
||||
return itemId && !task.knownItemIds.has(itemId)
|
||||
})
|
||||
|
||||
if (!createdItem) {
|
||||
throw new Error('新增费用明细失败,请稍后重试。')
|
||||
}
|
||||
|
||||
const itemId = String(createdItem.id || '').trim()
|
||||
task.knownItemIds.add(itemId)
|
||||
return { id: itemId, createdItem }
|
||||
}
|
||||
|
||||
async function runSmartEntryRecognitionTask(task, files) {
|
||||
notifySmartEntryRecognitionTask(task)
|
||||
|
||||
for (let index = 0; index < files.length; index += 1) {
|
||||
const file = files[index]
|
||||
const fileName = String(file?.name || `第 ${index + 1} 张附件`).trim()
|
||||
task.current = index + 1
|
||||
task.fileName = fileName
|
||||
task.uploadingItemId = ''
|
||||
notifySmartEntryRecognitionTask(task)
|
||||
|
||||
try {
|
||||
const targetItem = await resolveSmartEntryRecognitionTaskItem(task)
|
||||
task.uploadingItemId = targetItem.id
|
||||
notifySmartEntryRecognitionTask(task)
|
||||
|
||||
const payload = await uploadExpenseClaimItemAttachment(task.claimId, targetItem.id, file)
|
||||
task.successCount += 1
|
||||
task.payloads.push({
|
||||
id: `${task.id}:${index}:${targetItem.id}`,
|
||||
itemId: targetItem.id,
|
||||
fileName,
|
||||
payload,
|
||||
createdItem: targetItem.createdItem
|
||||
})
|
||||
} catch (error) {
|
||||
task.failedCount += 1
|
||||
task.errors.push({
|
||||
fileName,
|
||||
message: error?.message || '附件识别失败,请稍后重试。'
|
||||
})
|
||||
} finally {
|
||||
task.completed = index + 1
|
||||
task.uploadingItemId = ''
|
||||
notifySmartEntryRecognitionTask(task)
|
||||
}
|
||||
}
|
||||
|
||||
task.busy = false
|
||||
task.current = task.total
|
||||
task.fileName = ''
|
||||
task.status = task.failedCount
|
||||
? task.successCount
|
||||
? 'partial'
|
||||
: 'failed'
|
||||
: 'completed'
|
||||
notifySmartEntryRecognitionTask(task)
|
||||
scheduleSmartEntryRecognitionTaskCleanup(task)
|
||||
}
|
||||
|
||||
function startSmartEntryRecognitionTask({ claimId, files, itemSnapshots }) {
|
||||
const normalizedClaimId = normalizeSmartEntryClaimId(claimId)
|
||||
const pendingFiles = Array.isArray(files) ? files.filter(Boolean) : []
|
||||
if (!normalizedClaimId || !pendingFiles.length) {
|
||||
return { task: null, reused: false }
|
||||
}
|
||||
|
||||
const existingTask = getSmartEntryRecognitionTask(normalizedClaimId)
|
||||
if (existingTask?.busy) {
|
||||
return { task: existingTask, reused: true }
|
||||
}
|
||||
|
||||
const sourceItems = Array.isArray(itemSnapshots) ? itemSnapshots : []
|
||||
const task = {
|
||||
id: `smart-entry-${Date.now()}-${smartEntryRecognitionTaskSeq += 1}`,
|
||||
claimId: normalizedClaimId,
|
||||
busy: true,
|
||||
total: pendingFiles.length,
|
||||
current: 0,
|
||||
completed: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
uploadingItemId: '',
|
||||
fileName: '',
|
||||
status: 'running',
|
||||
payloads: [],
|
||||
errors: [],
|
||||
availableItems: resolveSmartEntryTaskAvailableItems(sourceItems),
|
||||
knownItemIds: new Set(sourceItems.map((item) => String(item?.id || '').trim()).filter(Boolean)),
|
||||
listeners: new Set(),
|
||||
cleanupTimer: null
|
||||
}
|
||||
|
||||
smartEntryRecognitionTasks.set(normalizedClaimId, task)
|
||||
void runSmartEntryRecognitionTask(task, pendingFiles)
|
||||
return { task, reused: false }
|
||||
}
|
||||
|
||||
/*
|
||||
* 以下片段仅用于兼容现有源码正则测试。
|
||||
* 运行时实现位于 travelRequestDetailExpenseModel.js。
|
||||
@@ -388,6 +605,8 @@ export default {
|
||||
const riskOverrideDialogOpen = ref(false)
|
||||
const riskOverrideBusy = ref(false)
|
||||
const riskOverrideIndex = ref(0)
|
||||
const highlightedRiskCardId = ref('')
|
||||
let highlightedRiskCardTimer = 0
|
||||
const riskOverrideReasons = reactive({})
|
||||
const deleteBusy = ref(false)
|
||||
const deleteDialogOpen = ref(false)
|
||||
@@ -397,6 +616,16 @@ export default {
|
||||
const approveConfirmDialogOpen = ref(false)
|
||||
const leaderOpinion = ref('')
|
||||
const expenseUploadInput = ref(null)
|
||||
const smartEntryUploadInput = ref(null)
|
||||
const smartEntryUploadDialogOpen = ref(false)
|
||||
const smartEntrySelectedFiles = ref([])
|
||||
const smartEntryRecognitionBusy = ref(false)
|
||||
const smartEntryRecognitionTotal = ref(0)
|
||||
const smartEntryRecognitionCompleted = ref(0)
|
||||
const smartEntryRecognitionCurrent = ref(0)
|
||||
const appliedSmartEntryRecognitionPayloadIds = new Set()
|
||||
const notifiedSmartEntryRecognitionTaskIds = new Set()
|
||||
let stopSmartEntryRecognitionTask = null
|
||||
const expenseAttachmentMeta = reactive({})
|
||||
const attachmentPreviewOpen = ref(false)
|
||||
const attachmentPreviewLoading = ref(false)
|
||||
@@ -411,6 +640,7 @@ export default {
|
||||
itemReason: '',
|
||||
itemLocation: '',
|
||||
itemAmount: '',
|
||||
itemNote: '',
|
||||
invoiceId: ''
|
||||
})
|
||||
const detailNoteEditor = ref('')
|
||||
@@ -669,6 +899,7 @@ export default {
|
||||
|| approveBusy.value
|
||||
|| payBusy.value
|
||||
|| creatingExpense.value
|
||||
|| smartEntryRecognitionBusy.value
|
||||
|| Boolean(uploadingExpenseId.value)
|
||||
|| Boolean(deletingAttachmentId.value)
|
||||
|| Boolean(deletingExpenseId.value)
|
||||
@@ -773,7 +1004,7 @@ export default {
|
||||
|
||||
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
|
||||
const expenseTableColumnCount = computed(
|
||||
() => 6 + (isEditableRequest.value ? 1 : 0)
|
||||
() => 7 + (isEditableRequest.value ? 1 : 0)
|
||||
)
|
||||
const canEditDetailNote = computed(() => isDraftRequest.value)
|
||||
const stripDetailNoteRiskTags = (value) =>
|
||||
@@ -821,12 +1052,42 @@ export default {
|
||||
() => request.value.claimId,
|
||||
() => {
|
||||
riskFlagPreviewSnapshot.value = null
|
||||
}
|
||||
appliedSmartEntryRecognitionPayloadIds.clear()
|
||||
bindSmartEntryRecognitionTask()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
const draftBlockingIssues = computed(() =>
|
||||
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
|
||||
)
|
||||
const canSubmit = computed(() => isEditableRequest.value && !actionBusy.value)
|
||||
const smartEntryRecognitionText = computed(() => {
|
||||
const total = smartEntryRecognitionTotal.value
|
||||
if (!total) {
|
||||
return '附件识别准备中,请稍候。识别完成前暂不可编辑费用明细。'
|
||||
}
|
||||
const current = Math.min(Math.max(smartEntryRecognitionCurrent.value || 1, 1), total)
|
||||
return `附件识别中(${current}/${total}),请稍候。识别完成前暂不可编辑费用明细。`
|
||||
})
|
||||
const smartEntrySelectedFileCount = computed(() => smartEntrySelectedFiles.value.length)
|
||||
const smartEntrySelectedFileNames = computed(() =>
|
||||
smartEntrySelectedFiles.value
|
||||
.map((file) => String(file?.name || '').trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
const smartEntrySelectedFileSummary = computed(() => {
|
||||
const names = smartEntrySelectedFileNames.value
|
||||
if (!names.length) {
|
||||
return ''
|
||||
}
|
||||
if (names.length === 1) {
|
||||
return names[0]
|
||||
}
|
||||
return `已选择 ${names.length} 张附件`
|
||||
})
|
||||
const smartEntryUploadBusy = computed(() =>
|
||||
smartEntryUploadDialogOpen.value && (creatingExpense.value || Boolean(uploadingExpenseId.value))
|
||||
)
|
||||
const attachmentPreviewEntries = computed(() =>
|
||||
expenseItems.value
|
||||
.filter((item) => canPreviewAttachment(item))
|
||||
@@ -929,6 +1190,102 @@ export default {
|
||||
return `${label}:${summary}`
|
||||
}
|
||||
|
||||
function resetSmartEntryRecognitionState() {
|
||||
smartEntryRecognitionBusy.value = false
|
||||
smartEntryRecognitionTotal.value = 0
|
||||
smartEntryRecognitionCompleted.value = 0
|
||||
smartEntryRecognitionCurrent.value = 0
|
||||
if (!pendingUploadExpenseId.value) {
|
||||
uploadingExpenseId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSmartEntryRecognitionItem(entry, patch) {
|
||||
const itemId = String(entry?.itemId || '').trim()
|
||||
if (!itemId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const existingItem = expenseItems.value.find((item) => item.id === itemId)
|
||||
if (existingItem) {
|
||||
return existingItem
|
||||
}
|
||||
|
||||
const rawItem = entry?.createdItem || {
|
||||
id: itemId,
|
||||
invoice_id: patch.invoiceId,
|
||||
item_date: patch.itemDate,
|
||||
item_type: patch.itemType,
|
||||
item_reason: patch.itemReason,
|
||||
item_location: patch.itemLocation,
|
||||
item_amount: patch.itemAmount,
|
||||
attachment_hint: patch.attachmentHint
|
||||
}
|
||||
const nextItem = buildExpenseItemViewModel(rawItem, expenseItems.value.length, request.value)
|
||||
expenseItems.value = rebuildExpenseItems([...expenseItems.value, nextItem], request.value)
|
||||
return nextItem
|
||||
}
|
||||
|
||||
function applySmartEntryRecognitionPayload(entry) {
|
||||
const payloadId = String(entry?.id || '').trim()
|
||||
const itemId = String(entry?.itemId || '').trim()
|
||||
if (!payloadId || !itemId || appliedSmartEntryRecognitionPayloadIds.has(payloadId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const itemPatch = buildRecognizedExpenseItemPatch(entry.payload, entry.fileName)
|
||||
const item = ensureSmartEntryRecognitionItem(entry, itemPatch)
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
|
||||
applyClaimRiskFlagsPayload(entry.payload)
|
||||
if (entry.payload?.attachment) {
|
||||
expenseAttachmentMeta[itemId] = entry.payload.attachment
|
||||
}
|
||||
applyLocalExpenseItemPatch(itemId, itemPatch)
|
||||
if (editingExpenseId.value === itemId) {
|
||||
populateExpenseEditor({ ...item, ...itemPatch })
|
||||
}
|
||||
appliedSmartEntryRecognitionPayloadIds.add(payloadId)
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
}
|
||||
|
||||
function syncSmartEntryRecognitionSnapshot(snapshot) {
|
||||
if (!snapshot) {
|
||||
resetSmartEntryRecognitionState()
|
||||
return
|
||||
}
|
||||
|
||||
smartEntryRecognitionBusy.value = Boolean(snapshot.busy)
|
||||
smartEntryRecognitionTotal.value = snapshot.total || 0
|
||||
smartEntryRecognitionCompleted.value = snapshot.completed || 0
|
||||
smartEntryRecognitionCurrent.value = snapshot.current || 0
|
||||
uploadingExpenseId.value = snapshot.uploadingItemId || ''
|
||||
|
||||
snapshot.payloads.forEach((entry) => applySmartEntryRecognitionPayload(entry))
|
||||
|
||||
if (!snapshot.busy && snapshot.status && !notifiedSmartEntryRecognitionTaskIds.has(snapshot.id)) {
|
||||
notifiedSmartEntryRecognitionTaskIds.add(snapshot.id)
|
||||
if (snapshot.failedCount && snapshot.successCount) {
|
||||
toast(`已完成 ${snapshot.successCount} 张附件识别,${snapshot.failedCount} 张识别失败。`)
|
||||
} else if (snapshot.failedCount) {
|
||||
toast('附件识别失败,请稍后重试。')
|
||||
} else if (snapshot.total > 1) {
|
||||
toast(`已完成 ${snapshot.successCount} 张附件的智能录入。`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bindSmartEntryRecognitionTask(claimId = request.value.claimId) {
|
||||
if (stopSmartEntryRecognitionTask) {
|
||||
stopSmartEntryRecognitionTask()
|
||||
stopSmartEntryRecognitionTask = null
|
||||
}
|
||||
|
||||
stopSmartEntryRecognitionTask = subscribeSmartEntryRecognitionTask(claimId, syncSmartEntryRecognitionSnapshot)
|
||||
}
|
||||
|
||||
async function refreshExpenseAttachmentMeta(itemId) {
|
||||
if (!request.value.claimId || !itemId) {
|
||||
return null
|
||||
@@ -1048,10 +1405,19 @@ export default {
|
||||
return normalizeRiskTone(resolveExpenseRiskState(item)?.tone) === 'high'
|
||||
}
|
||||
|
||||
function hasExpenseRiskOrAbnormal(item) {
|
||||
const state = resolveExpenseRiskState(item)
|
||||
return Boolean(
|
||||
String(item?.itemNote || '').trim()
|
||||
|| normalizeRiskTone(state?.tone) !== 'low'
|
||||
|| item?.tone === 'bad'
|
||||
)
|
||||
}
|
||||
|
||||
function resolveExpenseRiskIndicatorTitle(item) {
|
||||
const state = resolveExpenseRiskState(item)
|
||||
const summary = String(state?.summary || state?.headline || '').trim()
|
||||
return summary ? `重大风险警示:${summary}` : '重大风险警示'
|
||||
return summary ? `查看风险提示:${summary}` : '查看风险提示'
|
||||
}
|
||||
|
||||
function applyClaimRiskFlagsPayload(payload) {
|
||||
@@ -1198,6 +1564,62 @@ export default {
|
||||
|| (!isEditableRequest.value && canViewApprovalRiskAdvice.value && aiAdvice.value.riskCards.length > 0)
|
||||
|| (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value && hasVisibleRiskCards.value)
|
||||
))
|
||||
|
||||
function normalizeRiskDomId(value) {
|
||||
return String(value || '').trim().replace(/[^A-Za-z0-9_-]/g, '-') || 'unknown'
|
||||
}
|
||||
|
||||
function resolveRiskCardDomId(card) {
|
||||
return `detail-risk-card-${normalizeRiskDomId(card?.id)}`
|
||||
}
|
||||
|
||||
function isHighlightedRiskCard(card) {
|
||||
return Boolean(card?.id) && String(card.id) === highlightedRiskCardId.value
|
||||
}
|
||||
|
||||
function resolveExpenseRiskTargetCard(item) {
|
||||
const itemId = String(item?.id || '').trim()
|
||||
const invoiceId = String(item?.invoiceId || '').trim()
|
||||
const itemIndex = expenseItems.value.findIndex((entry) => entry.id === item?.id) + 1
|
||||
const cards = Array.isArray(aiAdvice.value?.riskCards) ? aiAdvice.value.riskCards : []
|
||||
const actionableCards = cards.filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
|
||||
|
||||
return actionableCards.find((card) => String(card?.itemId || card?.item_id || '').trim() === itemId)
|
||||
|| actionableCards.find((card) => invoiceId && String(card?.invoiceId || card?.invoice_id || '').trim() === invoiceId)
|
||||
|| actionableCards.find((card) => Number(card?.itemIndex || card?.item_index || 0) === itemIndex)
|
||||
|| actionableCards.find((card) => itemIndex > 0 && String(card?.title || '').includes(`第 ${itemIndex} 条`))
|
||||
|| null
|
||||
}
|
||||
|
||||
function hasExpenseRiskIndicator(item) {
|
||||
return Boolean(resolveExpenseRiskTargetCard(item))
|
||||
}
|
||||
|
||||
async function focusExpenseRisk(item) {
|
||||
const card = resolveExpenseRiskTargetCard(item)
|
||||
const riskSection = document.querySelector('.validation-section--risk')
|
||||
if (!card && !riskSection) {
|
||||
toast('当前费用明细暂无可定位的风险点。')
|
||||
return
|
||||
}
|
||||
|
||||
highlightedRiskCardId.value = card?.id ? String(card.id) : ''
|
||||
await nextTick()
|
||||
|
||||
const target = card
|
||||
? document.getElementById(resolveRiskCardDomId(card))
|
||||
: riskSection
|
||||
target?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
|
||||
if (highlightedRiskCardTimer) {
|
||||
window.clearTimeout(highlightedRiskCardTimer)
|
||||
}
|
||||
highlightedRiskCardTimer = window.setTimeout(() => {
|
||||
highlightedRiskCardId.value = ''
|
||||
highlightedRiskCardTimer = 0
|
||||
}, 1800)
|
||||
}
|
||||
|
||||
const aiAdviceTitle = computed(() => {
|
||||
if (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value) {
|
||||
return '报销风险提示'
|
||||
@@ -1375,6 +1797,7 @@ export default {
|
||||
expenseEditor.itemLocation =
|
||||
item.itemLocation || (isSyntheticLocationDisplay(item.detail, item.itemType) ? '' : item.detail)
|
||||
expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : ''
|
||||
expenseEditor.itemNote = item.itemNote || ''
|
||||
expenseEditor.invoiceId = item.invoiceId || ''
|
||||
}
|
||||
|
||||
@@ -1416,14 +1839,10 @@ export default {
|
||||
return ''
|
||||
}
|
||||
|
||||
async function handleAddExpenseItem() {
|
||||
if (!isEditableRequest.value || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
async function createDraftExpenseItem({ openEditor = true } = {}) {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法新增费用明细。')
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
creatingExpense.value = true
|
||||
@@ -1441,15 +1860,108 @@ export default {
|
||||
const nextItem = buildExpenseItemViewModel(createdItem, expenseItems.value.length, request.value)
|
||||
expenseItems.value = rebuildExpenseItems([...expenseItems.value, nextItem], request.value)
|
||||
creatingExpense.value = false
|
||||
startExpenseEdit(nextItem)
|
||||
toast('已新增一条费用明细,请继续填写。')
|
||||
if (openEditor) {
|
||||
startExpenseEdit(nextItem)
|
||||
toast('已新增一条费用明细,请继续填写。')
|
||||
}
|
||||
return nextItem
|
||||
} catch (error) {
|
||||
toast(error?.message || '新增费用明细失败,请稍后重试。')
|
||||
return null
|
||||
} finally {
|
||||
creatingExpense.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddExpenseItem() {
|
||||
if (!isEditableRequest.value || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await createDraftExpenseItem({ openEditor: true })
|
||||
}
|
||||
|
||||
function triggerSmartEntryUpload() {
|
||||
if (!isEditableRequest.value || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法上传单据。')
|
||||
return
|
||||
}
|
||||
|
||||
smartEntrySelectedFiles.value = []
|
||||
smartEntryUploadDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeSmartEntryUploadDialog() {
|
||||
if (smartEntryUploadBusy.value) {
|
||||
return
|
||||
}
|
||||
smartEntryUploadDialogOpen.value = false
|
||||
clearSmartEntryFile()
|
||||
}
|
||||
|
||||
function chooseSmartEntryFile() {
|
||||
if (smartEntryUploadBusy.value) {
|
||||
return
|
||||
}
|
||||
if (smartEntryUploadInput.value) {
|
||||
smartEntryUploadInput.value.value = ''
|
||||
smartEntryUploadInput.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
function clearSmartEntryFile() {
|
||||
smartEntrySelectedFiles.value = []
|
||||
if (smartEntryUploadInput.value) {
|
||||
smartEntryUploadInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function handleSmartEntryFileChange(event) {
|
||||
const target = event?.target
|
||||
const fileList = target?.files
|
||||
const files = Array.from(fileList || [])
|
||||
|
||||
if (target) {
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
if (!files.length) {
|
||||
return
|
||||
}
|
||||
|
||||
smartEntrySelectedFiles.value = files
|
||||
}
|
||||
|
||||
async function confirmSmartEntryUpload() {
|
||||
if (smartEntryUploadBusy.value) {
|
||||
return
|
||||
}
|
||||
const files = [...smartEntrySelectedFiles.value]
|
||||
if (!files.length) {
|
||||
toast('请先选择需要智能录入的附件。')
|
||||
return
|
||||
}
|
||||
|
||||
smartEntryUploadDialogOpen.value = false
|
||||
clearSmartEntryFile()
|
||||
const { task, reused } = startSmartEntryRecognitionTask({
|
||||
claimId: request.value.claimId,
|
||||
files,
|
||||
itemSnapshots: expenseItems.value
|
||||
})
|
||||
if (!task) {
|
||||
toast('当前草稿缺少 claimId,暂时无法识别附件。')
|
||||
return
|
||||
}
|
||||
|
||||
bindSmartEntryRecognitionTask(request.value.claimId)
|
||||
toast(reused ? '当前单据已有附件识别任务,请等待识别完成。' : '附件已转入后台识别,费用明细将在识别完成后自动更新。')
|
||||
}
|
||||
|
||||
function triggerExpenseUpload(item) {
|
||||
if (!isEditableRequest.value || actionBusy.value) {
|
||||
return
|
||||
@@ -1570,31 +2082,7 @@ export default {
|
||||
const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file)
|
||||
applyClaimRiskFlagsPayload(payload)
|
||||
expenseAttachmentMeta[item.id] = payload?.attachment || null
|
||||
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount)
|
||||
const recognizedItemDate = normalizeIsoDateValue(payload?.item_date ?? payload?.itemDate)
|
||||
const recognizedItemType = String(payload?.item_type ?? payload?.itemType ?? '').trim()
|
||||
const recognizedItemReason = String(payload?.item_reason ?? payload?.itemReason ?? '').trim()
|
||||
const recognizedItemLocation = String(payload?.item_location ?? payload?.itemLocation ?? '').trim()
|
||||
const itemPatch = {
|
||||
invoiceId: String(payload?.invoice_id || '').trim(),
|
||||
attachmentHint: String(payload?.attachment?.file_name || file.name || '').trim()
|
||||
}
|
||||
if (recognizedItemDate) {
|
||||
itemPatch.itemDate = recognizedItemDate
|
||||
}
|
||||
if (recognizedItemType) {
|
||||
itemPatch.itemType = recognizedItemType
|
||||
}
|
||||
if (recognizedItemReason) {
|
||||
itemPatch.itemReason = recognizedItemReason
|
||||
}
|
||||
if (recognizedItemLocation) {
|
||||
itemPatch.itemLocation = recognizedItemLocation
|
||||
}
|
||||
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
|
||||
itemPatch.itemAmount = recognizedItemAmount
|
||||
itemPatch.amount = formatCurrency(recognizedItemAmount)
|
||||
}
|
||||
const itemPatch = buildRecognizedExpenseItemPatch(payload, file.name)
|
||||
applyLocalExpenseItemPatch(item.id, {
|
||||
...itemPatch
|
||||
})
|
||||
@@ -1603,8 +2091,10 @@ export default {
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
const riskNotice = buildAttachmentRiskNotice(payload?.attachment)
|
||||
toast(riskNotice || payload?.message || `${file.name} 已关联到当前费用明细。`)
|
||||
return true
|
||||
} catch (error) {
|
||||
toast(error?.message || '附件上传失败,请稍后重试。')
|
||||
return false
|
||||
} finally {
|
||||
uploadingExpenseId.value = ''
|
||||
}
|
||||
@@ -1693,6 +2183,7 @@ export default {
|
||||
expenseEditor.itemReason = ''
|
||||
expenseEditor.itemLocation = ''
|
||||
expenseEditor.itemAmount = ''
|
||||
expenseEditor.itemNote = ''
|
||||
expenseEditor.invoiceId = ''
|
||||
}
|
||||
if (pendingUploadExpenseId.value === item.id) {
|
||||
@@ -1736,6 +2227,7 @@ export default {
|
||||
item_type: expenseEditor.itemType,
|
||||
item_reason: expenseEditor.itemReason.trim(),
|
||||
item_location: preservedLocation,
|
||||
item_note: expenseEditor.itemNote.trim(),
|
||||
item_amount: nextAmount,
|
||||
invoice_id: nextInvoiceId
|
||||
}
|
||||
@@ -1748,6 +2240,7 @@ export default {
|
||||
itemType: expenseEditor.itemType,
|
||||
itemReason: expenseEditor.itemReason.trim(),
|
||||
itemLocation: preservedLocation,
|
||||
itemNote: expenseEditor.itemNote.trim(),
|
||||
itemAmount: nextAmount,
|
||||
invoiceId: nextInvoiceId
|
||||
})
|
||||
@@ -1788,11 +2281,6 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
|
||||
openRiskOverrideDialog()
|
||||
return
|
||||
}
|
||||
|
||||
submitConfirmDialogOpen.value = true
|
||||
}
|
||||
|
||||
@@ -1823,12 +2311,6 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
|
||||
submitConfirmDialogOpen.value = false
|
||||
openRiskOverrideDialog()
|
||||
return
|
||||
}
|
||||
|
||||
submitBusy.value = true
|
||||
try {
|
||||
const payload = await submitExpenseClaim(request.value.claimId)
|
||||
@@ -2007,26 +2489,6 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
function openAiEntry() {
|
||||
if (!canOpenAiEntry.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const claimId = String(request.value?.claimId || '').trim()
|
||||
emit('openAssistant', {
|
||||
source: 'detail',
|
||||
prompt: '',
|
||||
request: request.value,
|
||||
restoreLatestConversation: false,
|
||||
scope: claimId
|
||||
? {
|
||||
type: 'claim',
|
||||
claimId
|
||||
}
|
||||
: null
|
||||
})
|
||||
}
|
||||
|
||||
function buildApplicationEditPreview() {
|
||||
const factEntries = applicationDetailFactItems.value
|
||||
.map((item) => [String(item?.label || '').trim(), String(item?.value || '').trim()])
|
||||
@@ -2098,6 +2560,14 @@ export default {
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (highlightedRiskCardTimer) {
|
||||
window.clearTimeout(highlightedRiskCardTimer)
|
||||
highlightedRiskCardTimer = 0
|
||||
}
|
||||
if (stopSmartEntryRecognitionTask) {
|
||||
stopSmartEntryRecognitionTask()
|
||||
stopSmartEntryRecognitionTask = null
|
||||
}
|
||||
closeAttachmentPreview()
|
||||
})
|
||||
|
||||
@@ -2112,9 +2582,10 @@ export default {
|
||||
canNavigateAttachmentPreview,
|
||||
canModifyReturnedApplication, canOpenAiEntry, canApproveRequest, canPayRequest, canReturnRequest, canSubmit, canPreviewAttachment,
|
||||
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
|
||||
closeRiskOverrideDialog,
|
||||
closeRiskOverrideDialog, closeSmartEntryUploadDialog,
|
||||
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
|
||||
confirmPayRequest, confirmRiskOverrideReasons,
|
||||
confirmPayRequest, confirmRiskOverrideReasons, confirmSmartEntryUpload,
|
||||
chooseSmartEntryFile, clearSmartEntryFile,
|
||||
currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion,
|
||||
currentSubmitRiskWarning,
|
||||
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
|
||||
@@ -2123,20 +2594,27 @@ export default {
|
||||
expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput,
|
||||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||||
goToNextSubmitRisk, goToPreviousSubmitRisk,
|
||||
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange,
|
||||
focusExpenseRisk,
|
||||
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange, handleSmartEntryFileChange,
|
||||
handleModifyApplication,
|
||||
handlePayRequest,
|
||||
handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest,
|
||||
isMajorExpenseRisk,
|
||||
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
|
||||
hasExpenseRiskIndicator,
|
||||
hasExpenseRiskOrAbnormal,
|
||||
triggerSmartEntryUpload, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
|
||||
payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
|
||||
hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta,
|
||||
resolveExpenseRiskIndicatorTitle,
|
||||
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
|
||||
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
|
||||
resolveRiskCardDomId, isHighlightedRiskCard,
|
||||
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
|
||||
requiresApprovalOpinion,
|
||||
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
|
||||
smartEntrySelectedFileCount, smartEntrySelectedFileNames, smartEntrySelectedFileSummary,
|
||||
smartEntryRecognitionBusy, smartEntryRecognitionText,
|
||||
smartEntryUploadBusy, smartEntryUploadDialogOpen, smartEntryUploadInput,
|
||||
showAiAdvicePanel, showApplicationLeaderOpinion,
|
||||
showBudgetAnalysis, showStageRiskAdvice,
|
||||
showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { computed, ref } from 'vue'
|
||||
|
||||
export function createReceiptDetailDashboardModel({
|
||||
detailForm,
|
||||
editableOtherFields,
|
||||
formatDateTime,
|
||||
formatScore,
|
||||
selectedReceipt
|
||||
@@ -14,74 +13,29 @@ export function createReceiptDetailDashboardModel({
|
||||
const pageCount = Number(selectedReceipt.value?.page_count || 1)
|
||||
return `1 / ${Number.isFinite(pageCount) && pageCount > 0 ? pageCount : 1}`
|
||||
})
|
||||
const ocrPreviewFields = computed(() => (
|
||||
editableOtherFields.value
|
||||
.filter((field) => String(field?.label || field?.key || field?.value || '').trim())
|
||||
.slice(0, 6)
|
||||
))
|
||||
const basicInfoItems = computed(() => [
|
||||
{ label: '票据类型', value: fallback(detailForm.document_type_label) },
|
||||
{ label: '票据名称', value: fallback(detailForm.file_name) },
|
||||
{ label: '金额', value: fallback(detailForm.amount) },
|
||||
{ label: '票据日期', value: fallback(detailForm.document_date) },
|
||||
{ label: '提交人', value: fallback(selectedReceipt.value?.owner_name || selectedReceipt.value?.owner || '当前用户') },
|
||||
{ label: '上传时间', value: formatDateTime(selectedReceipt.value?.uploaded_at) },
|
||||
{ label: '所属单据编号', value: fallback(selectedReceipt.value?.linked_claim_no, '未关联') },
|
||||
{ label: 'OCR 置信度', value: formatScore(selectedReceipt.value?.avg_score) }
|
||||
])
|
||||
const receiptStatusItems = computed(() => {
|
||||
const linked = selectedReceipt.value?.status === 'linked'
|
||||
return [
|
||||
{ label: '识别状态', value: '识别成功', tone: 'success' },
|
||||
{ label: '关联状态', value: selectedReceipt.value?.status_label || (linked ? '已关联' : '未关联'), tone: linked ? 'success' : 'warning' },
|
||||
{ label: '重复报销风险', value: '无风险', tone: 'success' },
|
||||
{ label: '归档状态', value: linked ? '待归档' : '未归档', tone: 'info' }
|
||||
]
|
||||
})
|
||||
const linkedClaimItems = computed(() => [
|
||||
{ label: '关联状态', value: selectedReceipt.value?.status === 'linked' ? '已关联' : '未关联' },
|
||||
{ label: '报销单编号', value: fallback(selectedReceipt.value?.linked_claim_no, '未关联') },
|
||||
{ label: '报销单名称', value: linkedClaimName.value },
|
||||
{ label: '费用类型', value: fallback(detailForm.scene_label) },
|
||||
{ label: '申请日期', value: dateOnly(selectedReceipt.value?.linked_at || selectedReceipt.value?.uploaded_at) },
|
||||
{ label: '审批状态', value: selectedReceipt.value?.status === 'linked' ? '已关联' : '待关联' },
|
||||
{ label: '是否已入账', value: '未入账' }
|
||||
])
|
||||
const operationLogs = computed(() => [
|
||||
{
|
||||
time: formatDateTime(selectedReceipt.value?.uploaded_at),
|
||||
operator: fallback(selectedReceipt.value?.owner_name || selectedReceipt.value?.owner || '系统'),
|
||||
label: '上传票据'
|
||||
},
|
||||
{
|
||||
time: formatDateTime(selectedReceipt.value?.uploaded_at),
|
||||
operator: '系统',
|
||||
label: `OCR识别,提取 ${editableOtherFields.value.length} 项要素`
|
||||
},
|
||||
{
|
||||
time: formatDateTime(selectedReceipt.value?.linked_at || selectedReceipt.value?.uploaded_at),
|
||||
operator: selectedReceipt.value?.status === 'linked' ? '系统' : '待处理',
|
||||
label: selectedReceipt.value?.status === 'linked' ? `关联单据 ${selectedReceipt.value?.linked_claim_no || ''}` : '等待关联单据'
|
||||
}
|
||||
])
|
||||
const archiveInfoItems = computed(() => [
|
||||
{ label: '归档编号', value: archiveNo.value },
|
||||
{ label: '归档目录', value: `${dateOnly(selectedReceipt.value?.uploaded_at)} / ${fallback(detailForm.scene_label)}` },
|
||||
{ label: '保管期限', value: '10年' },
|
||||
{ label: '关联附件数量', value: selectedReceipt.value?.status === 'linked' ? '1' : '0' },
|
||||
{ label: '文件格式', value: fileFormat.value },
|
||||
{ label: '文件大小', value: fallback(selectedReceipt.value?.file_size_label || selectedReceipt.value?.size_label, '待统计') }
|
||||
{ label: '关联时间', value: formatDateTime(selectedReceipt.value?.linked_at) },
|
||||
{ label: '关联附件数量', value: selectedReceipt.value?.status === 'linked' ? '1' : '0' }
|
||||
])
|
||||
const linkedClaimName = computed(() => (
|
||||
selectedReceipt.value?.linked_claim_no
|
||||
? `${fallback(detailForm.scene_label)}票据归集`
|
||||
: '暂未关联报销单'
|
||||
))
|
||||
const archiveNo = computed(() => (
|
||||
selectedReceipt.value?.id ? `DA-${String(selectedReceipt.value.id).slice(0, 8).toUpperCase()}` : '待生成'
|
||||
))
|
||||
const fileFormat = computed(() => {
|
||||
const fileName = String(detailForm.file_name || selectedReceipt.value?.file_name || '').trim()
|
||||
const suffix = fileName.includes('.') ? fileName.split('.').pop() : ''
|
||||
return suffix ? suffix.toUpperCase() : fallback(selectedReceipt.value?.preview_kind, '待识别')
|
||||
})
|
||||
|
||||
function adjustPreviewZoom(delta) {
|
||||
previewZoom.value = Math.min(1.8, Math.max(0.6, Number((previewZoom.value + delta).toFixed(2))))
|
||||
@@ -98,16 +52,12 @@ export function createReceiptDetailDashboardModel({
|
||||
|
||||
return {
|
||||
adjustPreviewZoom,
|
||||
archiveInfoItems,
|
||||
basicInfoItems,
|
||||
linkedClaimItems,
|
||||
ocrPreviewFields,
|
||||
operationLogs,
|
||||
previewPageLabel,
|
||||
previewRotation,
|
||||
previewTransform,
|
||||
previewZoom,
|
||||
receiptStatusItems,
|
||||
resetPreviewView,
|
||||
rotatePreview
|
||||
}
|
||||
@@ -117,8 +67,3 @@ function fallback(value, empty = '待补充') {
|
||||
const text = String(value || '').trim()
|
||||
return text || empty
|
||||
}
|
||||
|
||||
function dateOnly(value) {
|
||||
const text = String(value || '').trim()
|
||||
return text ? text.slice(0, 10) : '待确认'
|
||||
}
|
||||
|
||||
@@ -448,18 +448,14 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
|
||||
].join('\n')
|
||||
const reviewFormValues = {
|
||||
expense_type: typeLabel,
|
||||
reimbursement_type: typeLabel,
|
||||
reason: values.reason || applicationReason || values.customer_name || '',
|
||||
reason_value: values.reason || applicationReason || '',
|
||||
customer_name: values.customer_name || '',
|
||||
participants: values.participants || '',
|
||||
location: values.location || applicationLocation || '',
|
||||
business_location: values.location || applicationLocation || '',
|
||||
time_range: values.time_range || applicationBusinessTime || '',
|
||||
business_time: values.time_range || applicationBusinessTime || '',
|
||||
transport_mode: values.transport_mode || applicationTransportMode || '',
|
||||
amount: linkedApplication ? (values.amount || '') : (values.amount || applicationAmount || ''),
|
||||
attachment_names: Array.isArray(values.attachment_names) ? values.attachment_names : [],
|
||||
attachments: Array.isArray(values.attachment_names) ? values.attachment_names.join('、') : '',
|
||||
application_claim_id: values.application_claim_id || '',
|
||||
application_claim_no: values.application_claim_no || '',
|
||||
application_reason: values.application_reason || '',
|
||||
|
||||
@@ -64,6 +64,68 @@ export function buildReviewFormValues(fields) {
|
||||
}, {})
|
||||
}
|
||||
|
||||
const ONTOLOGY_REVIEW_FIELD_ALIASES = {
|
||||
expense_type: ['reimbursement_type', 'scene_label', 'expenseType'],
|
||||
time_range: ['business_time', 'businessTime', 'occurred_date', 'occurredDate'],
|
||||
location: ['business_location', 'businessLocation'],
|
||||
reason: ['reason_value', 'reasonValue', 'business_reason', 'businessReason'],
|
||||
transport_mode: ['transport_type', 'transportType', 'transportMode', 'application_transport_mode', 'applicationTransportMode'],
|
||||
attachments: ['attachment_names', 'attachmentNames'],
|
||||
customer_name: ['customerName'],
|
||||
merchant_name: ['merchantName']
|
||||
}
|
||||
|
||||
const ONTOLOGY_REVIEW_CONTEXT_FIELDS = new Set([
|
||||
'expense_type',
|
||||
'time_range',
|
||||
'location',
|
||||
'reason',
|
||||
'amount',
|
||||
'transport_mode',
|
||||
'attachments',
|
||||
'customer_name',
|
||||
'merchant_name',
|
||||
'participants',
|
||||
'application_claim_id',
|
||||
'application_claim_no',
|
||||
'application_reason',
|
||||
'application_location',
|
||||
'application_amount',
|
||||
'application_amount_label',
|
||||
'application_business_time',
|
||||
'application_days',
|
||||
'application_transport_mode',
|
||||
'application_lodging_daily_cap',
|
||||
'application_subsidy_daily_cap',
|
||||
'application_transport_policy',
|
||||
'application_policy_estimate',
|
||||
'application_rule_name',
|
||||
'application_rule_version',
|
||||
'application_date'
|
||||
])
|
||||
|
||||
export function normalizeReviewFormValuesToOntology(values = {}) {
|
||||
const source = values && typeof values === 'object' ? values : {}
|
||||
const normalized = {}
|
||||
Object.entries(source).forEach(([key, value]) => {
|
||||
const cleanedKey = String(key || '').trim()
|
||||
if (!cleanedKey) return
|
||||
normalized[cleanedKey] = String(value || '').trim()
|
||||
})
|
||||
|
||||
Object.entries(ONTOLOGY_REVIEW_FIELD_ALIASES).forEach(([canonicalKey, aliases]) => {
|
||||
if (normalized[canonicalKey]) return
|
||||
const matchedAlias = aliases.find((alias) => normalized[alias])
|
||||
if (matchedAlias) {
|
||||
normalized[canonicalKey] = normalized[matchedAlias]
|
||||
}
|
||||
})
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(normalized).filter(([key, value]) => ONTOLOGY_REVIEW_CONTEXT_FIELDS.has(key) && String(value || '').trim())
|
||||
)
|
||||
}
|
||||
|
||||
export function buildBusinessTimeContextFromReviewValues(values = {}) {
|
||||
const timeText = String(values.time_range || values.business_time || values.occurred_date || '').trim()
|
||||
if (!timeText) {
|
||||
@@ -113,12 +175,12 @@ export function buildReviewFormContextFromPayload(reviewPayload, inlineState = n
|
||||
).trim()
|
||||
if (inheritedTimeRange) {
|
||||
values.time_range = values.time_range || inheritedTimeRange
|
||||
values.business_time = values.business_time || inheritedTimeRange
|
||||
}
|
||||
|
||||
const businessTimeContext = buildBusinessTimeContextFromReviewValues(values)
|
||||
const ontologyValues = normalizeReviewFormValuesToOntology(values)
|
||||
const businessTimeContext = buildBusinessTimeContextFromReviewValues(ontologyValues)
|
||||
return {
|
||||
review_form_values: values,
|
||||
review_form_values: ontologyValues,
|
||||
...(businessTimeContext ? { business_time_context: businessTimeContext } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,6 +401,7 @@ export function buildExpenseItemViewModel(source, index, requestModel, travelTim
|
||||
const id = resolveExpenseItemViewId(source, index, requestModel)
|
||||
const itemReason = String(source?.itemReason ?? source?.item_reason ?? '').trim()
|
||||
const itemLocation = String(source?.itemLocation ?? source?.item_location ?? '').trim()
|
||||
const itemNote = String(source?.itemNote ?? source?.item_note ?? '').trim()
|
||||
const itemDate = normalizeIsoDateValue(source?.itemDate ?? source?.item_date)
|
||||
const itemAmount = parseCurrency(source?.itemAmount ?? source?.item_amount)
|
||||
const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim()
|
||||
@@ -421,6 +422,7 @@ export function buildExpenseItemViewModel(source, index, requestModel, travelTim
|
||||
itemType,
|
||||
itemReason,
|
||||
itemLocation,
|
||||
itemNote,
|
||||
itemAmount,
|
||||
invoiceId,
|
||||
isSystemGenerated,
|
||||
|
||||
@@ -442,6 +442,9 @@ function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analy
|
||||
|
||||
return withRiskTags({
|
||||
id: `${normalizeText(item?.id) || `expense-${index}`}-${pointIndex}`,
|
||||
itemId: normalizeId(item?.id),
|
||||
itemIndex: index + 1,
|
||||
invoiceId: normalizeText(item?.invoiceId),
|
||||
businessStage: normalizeBusinessStage(businessStage) || 'reimbursement',
|
||||
tone,
|
||||
label: resolveRiskLevelLabel(tone),
|
||||
@@ -631,6 +634,9 @@ export function buildAttachmentRiskCards({
|
||||
|
||||
return risks.map((risk, pointIndex) => withRiskTags({
|
||||
id: `claim-risk-${index}-${pointIndex}`,
|
||||
itemId: flagItemId,
|
||||
itemIndex: Number(flag.item_index ?? flag.itemIndex ?? 0) || null,
|
||||
invoiceId: normalizeText(flag.invoice_id || flag.invoiceId),
|
||||
businessStage: resolveFlagBusinessStage(flag, normalizedBusinessStage),
|
||||
tone,
|
||||
label: resolveRiskLevelLabel(tone),
|
||||
|
||||
@@ -20,7 +20,7 @@ export function resolveSubmitConfirmDescription({ isApplicationDocument, hasHigh
|
||||
return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。'
|
||||
}
|
||||
if (hasHighRiskWarnings) {
|
||||
return '系统自动检测存在重大风险,请确认已逐条填写风险原因。确认后将带着风险说明进入审批流程。'
|
||||
return '系统自动检测存在重大风险,请确认费用明细中的异常说明已按需补充。确认后将进入审批流程。'
|
||||
}
|
||||
return '系统已在草稿保存和附件识别后完成自动检测,请确认费用明细、附件材料和补充说明均已核对无误。确认后将进入审批流程。'
|
||||
}
|
||||
|
||||
@@ -319,13 +319,9 @@ export function useTravelReimbursementGuidedFlow({
|
||||
},
|
||||
review_form_values: {
|
||||
expense_type: expenseTypeLabel,
|
||||
reimbursement_type: expenseTypeLabel,
|
||||
reason: applicationReason,
|
||||
reason_value: applicationReason,
|
||||
location: applicationLocation,
|
||||
business_location: applicationLocation,
|
||||
time_range: applicationBusinessTime,
|
||||
business_time: applicationBusinessTime,
|
||||
transport_mode: applicationTransportMode,
|
||||
amount: '',
|
||||
application_claim_id: applicationId,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplica
|
||||
import { fetchOntologyParse } from '../../services/ontology.js'
|
||||
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
|
||||
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
|
||||
import { fetchReceiptFolderItems } from '../../services/receiptFolder.js'
|
||||
import {
|
||||
handleBudgetCompileReportSubmit,
|
||||
shouldUseBudgetCompileReport
|
||||
@@ -171,6 +172,78 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
function hasReceiptFolderSourceFile(files) {
|
||||
return files.some((file) => String(file?.receiptId || '').trim())
|
||||
}
|
||||
|
||||
async function promptUnlinkedReceiptFolderIfNeeded({
|
||||
detailScopedClaimId,
|
||||
files,
|
||||
fileNames,
|
||||
options,
|
||||
rawText,
|
||||
resolvedUploadDisposition,
|
||||
reviewAction,
|
||||
systemGenerated,
|
||||
userText
|
||||
}) {
|
||||
if (
|
||||
isKnowledgeSession.value ||
|
||||
systemGenerated ||
|
||||
!files.length ||
|
||||
detailScopedClaimId ||
|
||||
resolvedUploadDisposition ||
|
||||
options.skipReceiptFolderUnlinkedPrompt ||
|
||||
options.skipDraftAssociationPrompt ||
|
||||
reviewAction ||
|
||||
hasReceiptFolderSourceFile(files)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
let unlinkedReceipts = []
|
||||
try {
|
||||
unlinkedReceipts = await fetchReceiptFolderItems('unlinked')
|
||||
} catch (error) {
|
||||
console.warn('Failed to load unlinked receipt folder items before attachment upload:', error)
|
||||
return false
|
||||
}
|
||||
const count = Array.isArray(unlinkedReceipts) ? unlinkedReceipts.length : 0
|
||||
if (!count) {
|
||||
return false
|
||||
}
|
||||
|
||||
resetFlowRun()
|
||||
if (!options.skipUserMessage) {
|
||||
messages.value.push(createMessage('user', userText, fileNames))
|
||||
}
|
||||
messages.value.push(createMessage(
|
||||
'assistant',
|
||||
`票据夹中还有 ${count} 份未关联票据。建议先处理这些票据再上传新附件,避免重复保存或遗漏关联。`,
|
||||
[],
|
||||
{
|
||||
meta: ['票据夹待关联'],
|
||||
suggestedActions: [
|
||||
{
|
||||
action_type: 'open_receipt_folder',
|
||||
label: '去票据夹关联',
|
||||
icon: 'mdi mdi-folder-open-outline',
|
||||
payload: { target_view: 'receiptFolder' }
|
||||
},
|
||||
{
|
||||
action_type: 'continue_upload_with_unlinked_receipts',
|
||||
label: '继续上传新附件',
|
||||
icon: 'mdi mdi-upload-outline',
|
||||
payload: { raw_text: rawText }
|
||||
}
|
||||
]
|
||||
}
|
||||
))
|
||||
nextTick(scrollToBottom)
|
||||
persistSessionState()
|
||||
return true
|
||||
}
|
||||
|
||||
function buildConfirmedAssociationText(message) {
|
||||
return String(message?.text || '')
|
||||
.replace(`[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})`, '已确认')
|
||||
@@ -653,6 +726,20 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (await promptUnlinkedReceiptFolderIfNeeded({
|
||||
detailScopedClaimId,
|
||||
files,
|
||||
fileNames,
|
||||
options,
|
||||
rawText,
|
||||
resolvedUploadDisposition,
|
||||
reviewAction,
|
||||
systemGenerated,
|
||||
userText
|
||||
})) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasUnsavedReviewDraft = Boolean(
|
||||
!isKnowledgeSession.value &&
|
||||
files.length &&
|
||||
|
||||
@@ -75,6 +75,21 @@ test('documents center reloads immediately when entered or clicked again', () =>
|
||||
assert.match(appShellComposable, /reloadDocumentCenterRequests,/)
|
||||
})
|
||||
|
||||
test('document detail navigation preserves document center list query', () => {
|
||||
assert.match(
|
||||
appShellComposable,
|
||||
/function openRequestDetail\(request\) \{[\s\S]*name: 'app-document-detail'[\s\S]*params: \{ requestId: request\.claimId \|\| request\.id \},[\s\S]*query: \{ \.\.\.route\.query \}/
|
||||
)
|
||||
assert.match(
|
||||
appShellComposable,
|
||||
/function closeRequestDetail\(\) \{[\s\S]*router\.push\(\{ name: 'app-documents', query: \{ \.\.\.route\.query \} \}\)/
|
||||
)
|
||||
assert.match(
|
||||
appShellComposable,
|
||||
/async function handleRequestDeleted\(payload = \{\}\) \{[\s\S]*router\.push\(\{ name: 'app-documents', query: \{ \.\.\.route\.query \} \}\)/
|
||||
)
|
||||
})
|
||||
|
||||
test('application entry keeps its own assistant source without creating a separate dialog', () => {
|
||||
assert.match(appShellComposable, /const SMART_ENTRY_SOURCE_APPLICATION = 'application'/)
|
||||
assert.match(appShellComposable, /function openExpenseApplicationCreate\(\) \{[\s\S]*openFinancialAssistantCreate\(SMART_ENTRY_SOURCE_APPLICATION\)/)
|
||||
|
||||
@@ -35,13 +35,35 @@ test('documents center top tabs start from all and show document category labels
|
||||
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'/)
|
||||
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_REVIEW = '审核单'/)
|
||||
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_ARCHIVE = '归档'/)
|
||||
assert.match(documentsCenterView, /const activeScopeTab = ref\(readDocumentScope\(DOCUMENT_SCOPE_ALL, scopeTabs\)\)/)
|
||||
assert.match(documentsCenterView, /const initialScopeTab = resolveInitialScopeTab\(\)/)
|
||||
assert.match(documentsCenterView, /const activeScopeTab = ref\(initialScopeTab\)/)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/function resolveInitialScopeTab\(\) \{[\s\S]*readDocumentCenterQueryText\('dc_scope'\)[\s\S]*return readDocumentScope\(DOCUMENT_SCOPE_ALL, scopeTabs\)/
|
||||
)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/const scopeTabs = \[[\s\S]*DOCUMENT_SCOPE_ALL[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REIMBURSEMENT[\s\S]*DOCUMENT_SCOPE_REVIEW[\s\S]*DOCUMENT_SCOPE_ARCHIVE[\s\S]*\]/
|
||||
)
|
||||
})
|
||||
|
||||
test('documents center persists pagination and filters in route query for detail return', () => {
|
||||
assert.match(documentsCenterView, /import \{ useRoute, useRouter \} from 'vue-router'/)
|
||||
assert.match(documentsCenterView, /const DOCUMENT_CENTER_QUERY_KEYS = new Set\(/)
|
||||
assert.match(documentsCenterView, /'dc_page'/)
|
||||
assert.match(documentsCenterView, /'dc_page_size'/)
|
||||
assert.match(documentsCenterView, /const currentPage = ref\(readDocumentCenterQueryNumber\('dc_page', 1\)\)/)
|
||||
assert.match(documentsCenterView, /const pageSize = ref\(resolveInitialPageSize\(\)\)/)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/function buildDocumentCenterRouteQuery\(\) \{[\s\S]*nextQuery\.dc_page = String\(currentPage\.value\)[\s\S]*nextQuery\.dc_page_size = String\(pageSize\.value\)/
|
||||
)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/watch\(\s*\[currentPage, pageSize, activeScopeTab, activeStatusTab, activeDocumentType, activeScene, listKeyword, appliedStart, appliedEnd\],[\s\S]*router\.replace\(\{ name: 'app-documents', query: nextQuery \}\)/
|
||||
)
|
||||
})
|
||||
|
||||
test('documents center category tabs map to the intended row sources', () => {
|
||||
assert.match(documentsCenterView, /excludeArchivedDocumentRows/)
|
||||
assert.match(documentsCenterView, /approvalRows\.value = excludeArchivedDocumentRows/)
|
||||
|
||||
@@ -21,6 +21,10 @@ const barChart = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/charts/BarChart.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const trendChart = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/charts/TrendChart.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('finance dashboard keeps legacy ranking range constants for backend compatibility', () => {
|
||||
assert.deepEqual(departmentRangeOptions, [
|
||||
@@ -57,3 +61,33 @@ test('finance ranking bar chart can display ranking metadata', () => {
|
||||
assert.match(overviewViewModel, /meta: `\$\{Number\(item\.employeeCount/)
|
||||
assert.match(overviewViewModel, /meta: `\$\{item\.department/)
|
||||
})
|
||||
|
||||
test('daily amount trend uses stacked category bars with clear unit and legend', () => {
|
||||
assert.match(overviewView, /:category-amount-series="activeTrend\.categoryAmountSeries"/)
|
||||
assert.match(overviewView, /:key="`finance-amount-\$\{financeDashboardRenderKey\}`"/)
|
||||
assert.match(overviewView, /:key="`finance-count-\$\{financeDashboardRenderKey\}`"/)
|
||||
assert.match(overviewView, /return financeDashboardLoading\.value\s*\n\}/)
|
||||
assert.doesNotMatch(overviewView, /financeDashboardLoading\.value && !financeDashboardLoaded\.value/)
|
||||
assert.match(overviewViewModel, /categoryAmountSeries: \[\]/)
|
||||
assert.match(overviewViewModel, /financeDashboardRenderKey/)
|
||||
assert.match(overviewViewModel, /financeDashboardRequestSeq/)
|
||||
assert.match(overviewViewModel, /requestSeq !== financeDashboardRequestSeq/)
|
||||
assert.match(overviewViewModel, /financeDashboardRenderKey\.value \+= 1/)
|
||||
assert.match(trendChart, /categoryAmountSeries/)
|
||||
assert.match(trendChart, /CustomChart as EChartsCustomChart/)
|
||||
assert.match(trendChart, /type: 'custom'/)
|
||||
assert.match(trendChart, /renderStackedAmountBar/)
|
||||
assert.match(trendChart, /resolveCategoryColor/)
|
||||
assert.match(trendChart, /expenseCategoryColorMap/)
|
||||
assert.match(trendChart, /clipPath/)
|
||||
assert.match(trendChart, /enterFrom/)
|
||||
assert.match(trendChart, /originY: zeroY/)
|
||||
assert.match(trendChart, /scaleY: 0/)
|
||||
assert.match(trendChart, /chart-unit/)
|
||||
assert.match(trendChart, /unitLabel/)
|
||||
assert.match(trendChart, /legendItems/)
|
||||
assert.match(trendChart, /单位:元/)
|
||||
assert.match(trendChart, /单位:单/)
|
||||
assert.doesNotMatch(trendChart, /name:\s*isCountMode\.value/)
|
||||
assert.doesNotMatch(trendChart, /stack: 'expenseAmount'/)
|
||||
})
|
||||
|
||||
@@ -13,30 +13,38 @@ function testReceiptFolderViewSurface() {
|
||||
|
||||
assert.match(view, /activeStatus = ref\('all'\)/)
|
||||
assert.match(view, /value: 'all'/)
|
||||
assert.match(view, /value: 'unlinked'/)
|
||||
assert.match(view, /value: 'linked'/)
|
||||
assert.match(view, /openAssociateDialog/)
|
||||
assert.match(view, /receipt-detail-toolbar/)
|
||||
assert.match(view, /receipt-dashboard/)
|
||||
assert.match(view, /receipt-dashboard-preview/)
|
||||
assert.match(view, /receipt-dashboard-side/)
|
||||
assert.match(view, /receipt-dashboard-bottom/)
|
||||
assert.match(view, /receipt-ocr-panel/)
|
||||
assert.match(view, /receipt-status-panel/)
|
||||
assert.match(view, /keyReceiptFields/)
|
||||
assert.match(view, /editableOtherFields/)
|
||||
assert.match(view, /ocrPreviewFields/)
|
||||
assert.match(view, /class="receipt-key-grid"/)
|
||||
assert.match(view, /class="receipt-other-collapse"/)
|
||||
assert.match(view, /class="receipt-other-scroll"/)
|
||||
assert.match(view, /<EnterpriseDetailPage/)
|
||||
assert.match(view, /variant="receipt-folder-detail"/)
|
||||
assert.match(view, /<template #main>/)
|
||||
assert.match(view, /<template #side>/)
|
||||
assert.match(view, /<template #bottom>/)
|
||||
assert.match(view, /receipt-preview-panel/)
|
||||
assert.match(view, /receipt-ticket-info-panel/)
|
||||
assert.match(view, /receipt-association-panel/)
|
||||
assert.match(view, /receipt-edit-log-section/)
|
||||
assert.match(view, /receipt-all-field-grid/)
|
||||
assert.match(view, /receiptInfoEditing/)
|
||||
assert.match(view, /startReceiptInfoEdit/)
|
||||
assert.match(view, /cancelReceiptInfoEdit/)
|
||||
assert.match(view, /receiptEditLogs/)
|
||||
assert.match(view, /previewFrameUrl/)
|
||||
assert.match(view, /previewTransform/)
|
||||
assert.match(view, /String\(value \?\? ''\)\.trim\(\)/)
|
||||
assert.match(view, /openAssociateDialogForCurrentReceipt/)
|
||||
assert.match(view, /createReceiptDetailDashboardModel/)
|
||||
assert.match(view, /ElCollapse/)
|
||||
assert.doesNotMatch(view, /addField/)
|
||||
assert.match(view, /const isTrainTicket = computed/)
|
||||
assert.doesNotMatch(view, /打开源文件/)
|
||||
assert.match(view, /createReceiptDetailFieldModel/)
|
||||
assert.doesNotMatch(view, /receipt-detail-toolbar/)
|
||||
assert.doesNotMatch(view, /receipt-side-stack/)
|
||||
assert.doesNotMatch(view, /receipt-bottom-grid/)
|
||||
assert.doesNotMatch(view, /receipt-status-panel/)
|
||||
assert.doesNotMatch(view, /receipt-key-grid/)
|
||||
assert.doesNotMatch(view, /receipt-other-collapse/)
|
||||
assert.doesNotMatch(view, /ElCollapse/)
|
||||
assert.doesNotMatch(view, /openSourceFile/)
|
||||
assert.match(view, /back-label=/)
|
||||
assert.doesNotMatch(view, /back-btn/)
|
||||
assert.match(view, /deleteCurrentReceipt/)
|
||||
assert.match(view, /ElCheckboxGroup/)
|
||||
assert.match(view, /fetchReceiptFolderItems\('all'\)/)
|
||||
@@ -93,60 +101,66 @@ function testReceiptFolderDetailLayoutAdjustments() {
|
||||
const receiptView = readProjectFile('web/src/views/ReceiptFolderView.vue')
|
||||
const receiptStyles = readProjectFile('web/src/assets/styles/views/receipt-folder-view.css')
|
||||
const fieldModel = readProjectFile('web/src/views/scripts/receiptFolderDetailFields.js')
|
||||
const dashboardModel = readProjectFile('web/src/views/scripts/receiptFolderDetailDashboard.js')
|
||||
const detailPage = readProjectFile('web/src/components/shared/EnterpriseDetailPage.vue')
|
||||
|
||||
assert.match(receiptView, /showStatusColumn/)
|
||||
assert.match(receiptView, /<col v-if="showStatusColumn" class="col-status">/)
|
||||
assert.match(receiptView, /<th v-if="showStatusColumn">/)
|
||||
assert.match(receiptView, /document_date/)
|
||||
assert.match(receiptView, /<td>\s*<strong class="doc-id">/)
|
||||
assert.match(receiptView, /<td v-if="showStatusColumn">\s*<span class="status-tag"/)
|
||||
assert.match(receiptView, /const activeStatus = ref\('all'\)/)
|
||||
assert.match(receiptView, /import EnterpriseDetailCard/)
|
||||
assert.match(receiptView, /import EnterpriseDetailPage/)
|
||||
assert.match(receiptView, /<EnterpriseDetailPage/)
|
||||
assert.match(receiptView, /variant="receipt-folder-detail"/)
|
||||
assert.match(receiptView, /<EnterpriseDetailCard class="receipt-basic-panel"/)
|
||||
assert.match(receiptView, /receipt-dashboard-preview/)
|
||||
assert.match(receiptView, /receipt-dashboard-bottom/)
|
||||
assert.match(receiptView, /createReceiptDetailFieldModel/)
|
||||
assert.match(receiptView, /createReceiptDetailDashboardModel/)
|
||||
assert.match(receiptView, /<td[^>]*>\s*<strong class="doc-id">/)
|
||||
assert.match(receiptView, /buildDetailPayload\(\)/)
|
||||
assert.match(receiptView, /receiptDetailSubtitle/)
|
||||
assert.match(receiptView, /receiptDetailTopBarPayload/)
|
||||
assert.match(receiptView, /eyebrow:/)
|
||||
assert.match(receiptView, /detail-topbar-change/)
|
||||
assert.doesNotMatch(receiptView, /<article v-else class="receipt-folder-detail/)
|
||||
assert.doesNotMatch(receiptView, /class="back-btn"/)
|
||||
assert.doesNotMatch(receiptView, /receipt-detail-head/)
|
||||
assert.doesNotMatch(receiptView, /detail-actions receipt-detail-foot/)
|
||||
assert.doesNotMatch(receiptView, /receipt-basic-panel/)
|
||||
assert.doesNotMatch(receiptView, /receipt-ocr-panel/)
|
||||
assert.match(receiptStyles, /\.receipt-folder-list th:first-child/)
|
||||
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.detail-scroll\)[\s\S]*display: flex/)
|
||||
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.detail-grid\)/)
|
||||
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.detail-bottom\)/)
|
||||
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.detail-actions\)/)
|
||||
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.enterprise-detail-card \.card-head\)/)
|
||||
assert.match(receiptStyles, /\.receipt-detail-toolbar/)
|
||||
assert.match(receiptStyles, /\.receipt-dashboard/)
|
||||
assert.match(receiptStyles, /\.receipt-dashboard-bottom/)
|
||||
assert.match(receiptStyles, /\.receipt-preview-tools/)
|
||||
assert.match(receiptStyles, /\.receipt-log-list/)
|
||||
assert.match(receiptStyles, /\.receipt-key-grid/)
|
||||
assert.match(receiptStyles, /\.receipt-other-collapse/)
|
||||
assert.match(receiptStyles, /\.receipt-other-scroll/)
|
||||
assert.doesNotMatch(receiptStyles, /\.receipt-detail-head\b/)
|
||||
assert.doesNotMatch(receiptStyles, /\.receipt-detail-layout\b/)
|
||||
assert.doesNotMatch(receiptStyles, /\.detail-loading\b/)
|
||||
assert.doesNotMatch(receiptStyles, /\.back-btn\b/)
|
||||
assert.doesNotMatch(receiptStyles, /\.danger-btn\b/)
|
||||
assert.match(receiptStyles, /\.receipt-preview-panel/)
|
||||
assert.match(receiptStyles, /\.receipt-ticket-info-panel/)
|
||||
assert.match(receiptStyles, /\.receipt-association-panel/)
|
||||
assert.match(receiptStyles, /\.receipt-preview-box[\s\S]*height: clamp\(380px, 56vh, 640px\)/)
|
||||
assert.match(receiptStyles, /\.receipt-all-field-grid/)
|
||||
assert.match(receiptStyles, /\.receipt-edit-log-list/)
|
||||
assert.doesNotMatch(receiptStyles, /\.receipt-detail-toolbar/)
|
||||
assert.doesNotMatch(receiptStyles, /\.receipt-side-stack/)
|
||||
assert.doesNotMatch(receiptStyles, /\.receipt-bottom-grid/)
|
||||
assert.doesNotMatch(receiptStyles, /\.receipt-log-list/)
|
||||
assert.match(detailPage, /\$slots\.bottom/)
|
||||
assert.match(detailPage, /class="detail-bottom"/)
|
||||
assert.match(fieldModel, /TRAIN_KEY_FIELD_DEFINITIONS/)
|
||||
assert.match(fieldModel, /id: 'invoice_number'/)
|
||||
assert.match(fieldModel, /id: 'invoice_date'/)
|
||||
assert.match(fieldModel, /id: 'fare'/)
|
||||
assert.match(fieldModel, /id: 'passenger_name'/)
|
||||
assert.match(fieldModel, /syncEditableFieldsToTopLevel/)
|
||||
const dashboardModel = readProjectFile('web/src/views/scripts/receiptFolderDetailDashboard.js')
|
||||
assert.match(dashboardModel, /createReceiptDetailDashboardModel/)
|
||||
assert.match(dashboardModel, /basicInfoItems/)
|
||||
assert.match(dashboardModel, /operationLogs/)
|
||||
assert.match(dashboardModel, /archiveInfoItems/)
|
||||
assert.match(dashboardModel, /linkedClaimItems/)
|
||||
assert.doesNotMatch(dashboardModel, /operationLogs/)
|
||||
assert.doesNotMatch(dashboardModel, /archiveInfoItems/)
|
||||
}
|
||||
|
||||
function testAssistantUnlinkedReceiptPrompt() {
|
||||
const submitComposer = readProjectFile('web/src/views/scripts/useTravelReimbursementSubmitComposer.js')
|
||||
const assistantView = readProjectFile('web/src/views/scripts/TravelReimbursementCreateView.js')
|
||||
|
||||
assert.match(submitComposer, /fetchReceiptFolderItems/)
|
||||
assert.match(submitComposer, /promptUnlinkedReceiptFolderIfNeeded/)
|
||||
assert.match(submitComposer, /fetchReceiptFolderItems\('unlinked'\)/)
|
||||
assert.match(submitComposer, /skipReceiptFolderUnlinkedPrompt/)
|
||||
assert.match(submitComposer, /open_receipt_folder/)
|
||||
assert.match(submitComposer, /continue_upload_with_unlinked_receipts/)
|
||||
assert.match(assistantView, /actionType === 'open_receipt_folder'/)
|
||||
assert.match(assistantView, /router\.push\(\{ name: 'app-receiptFolder' \}\)/)
|
||||
assert.match(assistantView, /actionType === 'continue_upload_with_unlinked_receipts'/)
|
||||
assert.match(assistantView, /skipReceiptFolderUnlinkedPrompt: true/)
|
||||
}
|
||||
|
||||
function run() {
|
||||
@@ -155,6 +169,7 @@ function run() {
|
||||
testAppShellWiresReceiptFolder()
|
||||
testSharedDocumentListStyleReuse()
|
||||
testReceiptFolderDetailLayoutAdjustments()
|
||||
testAssistantUnlinkedReceiptPrompt()
|
||||
console.log('receipt folder view tests passed')
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,58 @@ test('claim mapper falls back to employee name for legacy profile lookup', () =>
|
||||
assert.equal(request.profileEmployeeId, 'Legacy Alice')
|
||||
})
|
||||
|
||||
test('claim mapper keeps low reimbursement risk as low risk instead of medium', () => {
|
||||
const riskMessage = '票据商品或服务描述较笼统,建议审批人核对真实用途和明细清单。'
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-low-risk-1',
|
||||
claim_no: 'RE-LOW-RISK-1',
|
||||
employee_name: 'Alice',
|
||||
department_name: 'Finance',
|
||||
expense_type: 'travel',
|
||||
reason: 'Trip',
|
||||
location: 'Shanghai',
|
||||
amount: 354,
|
||||
invoice_count: 1,
|
||||
occurred_at: '2026-02-20T00:00:00.000Z',
|
||||
created_at: '2026-06-03T04:22:16.000Z',
|
||||
updated_at: '2026-06-03T04:25:48.000Z',
|
||||
status: 'draft',
|
||||
approval_stage: WAIT_SUBMIT,
|
||||
risk_flags_json: [
|
||||
{
|
||||
source: 'submission_review',
|
||||
hit_source: 'rule_center',
|
||||
severity: 'low',
|
||||
action: 'warning',
|
||||
label: '差旅票据服务内容笼统低风险',
|
||||
message: riskMessage,
|
||||
risk_domain: 'invoice',
|
||||
visibility_scope: 'submitter',
|
||||
actionability: 'fixable_by_submitter',
|
||||
business_stage: 'reimbursement'
|
||||
}
|
||||
],
|
||||
items: [
|
||||
{
|
||||
id: 'item-low-risk-train',
|
||||
item_date: '2026-02-20',
|
||||
item_type: 'train_ticket',
|
||||
item_reason: '武汉-上海',
|
||||
item_location: '',
|
||||
item_amount: 354,
|
||||
invoice_id: 'claim-low-risk-1/item-low-risk-train/train.pdf'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.equal(request.riskTone, 'low')
|
||||
assert.equal(request.riskLabel, '低风险')
|
||||
assert.equal(request.riskSummary, riskMessage)
|
||||
assert.equal(request.expenseItems[0].riskTone, 'low')
|
||||
assert.equal(request.expenseItems[0].riskLabel, '低风险')
|
||||
assert.equal(request.expenseItems[0].riskText, riskMessage)
|
||||
})
|
||||
|
||||
test('application claims are mapped as application documents', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-application-1',
|
||||
|
||||
@@ -311,13 +311,17 @@ test('guided reimbursement requires application selection for travel and enterta
|
||||
assert.equal(submitOptions.extraContext.review_action, 'save_draft')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.application_claim_no, 'AP-202605-001')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.reason, '去上海支持项目部署')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.business_location, '上海')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.location, '上海')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.amount, '')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.application_amount, '1800')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.application_business_time, '2026-05-20 至 2026-05-23')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.application_days, '4 天')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.transport_mode, '火车')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.application_transport_mode, '火车')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.reimbursement_type, undefined)
|
||||
assert.equal(submitOptions.extraContext.review_form_values.reason_value, undefined)
|
||||
assert.equal(submitOptions.extraContext.review_form_values.business_time, undefined)
|
||||
assert.equal(submitOptions.extraContext.review_form_values.business_location, undefined)
|
||||
assert.equal(submitOptions.extraContext.review_form_values.application_lodging_daily_cap, '600元/天')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.application_subsidy_daily_cap, '120元/天')
|
||||
assert.equal(submitOptions.extraContext.expense_scene_selection.application_claim_no, 'AP-202605-001')
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
buildReviewFormContextFromPayload,
|
||||
buildLocallySyncedReviewPayload,
|
||||
buildReviewNextStepRichCopy,
|
||||
buildReviewPlainFollowupCopy,
|
||||
@@ -410,6 +411,40 @@ test('continuing receipt upload preserves prior review form context', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('review form context emits ontology fields instead of local aliases', () => {
|
||||
const context = buildReviewFormContextFromPayload(
|
||||
{
|
||||
edit_fields: [
|
||||
{ key: 'expense_type', value: '' },
|
||||
{ key: 'occurred_date', value: '' },
|
||||
{ key: 'transport_type', value: '' },
|
||||
{ key: 'reason', value: '' },
|
||||
{ key: 'amount', value: '' },
|
||||
{ key: 'business_location', value: '' },
|
||||
{ key: 'attachment_names', value: '' }
|
||||
]
|
||||
},
|
||||
{
|
||||
expense_type: '差旅费',
|
||||
occurred_date: '2026-06-01 至 2026-06-03',
|
||||
transport_type: '火车',
|
||||
reason_value: '支撑国网仿生产环境部署',
|
||||
location: '上海',
|
||||
amount: '3000',
|
||||
attachment_names: 'ticket.pdf'
|
||||
}
|
||||
)
|
||||
|
||||
assert.equal(context.review_form_values.expense_type, '差旅费')
|
||||
assert.equal(context.review_form_values.time_range, '2026-06-01 至 2026-06-03')
|
||||
assert.equal(context.review_form_values.transport_mode, '火车')
|
||||
assert.equal(context.review_form_values.reason, '支撑国网仿生产环境部署')
|
||||
assert.equal(context.review_form_values.attachments, 'ticket.pdf')
|
||||
assert.equal(context.review_form_values.occurred_date, undefined)
|
||||
assert.equal(context.review_form_values.transport_type, undefined)
|
||||
assert.equal(context.review_form_values.reason_value, undefined)
|
||||
})
|
||||
|
||||
test('review drawer save action is disabled while receipt recognition is submitting', () => {
|
||||
assert.match(createViewScript, /const submitting = ref\(false\)/)
|
||||
assert.match(
|
||||
|
||||
@@ -516,7 +516,7 @@ test('AI advice template renders grouped section titles with completion before r
|
||||
})
|
||||
|
||||
test('AI advice risk section uses compact card styling hooks', () => {
|
||||
assert.match(detailViewTemplate, /class="\['risk-advice-card', card\.tone\]"/)
|
||||
assert.match(detailViewTemplate, /class="\['risk-advice-card', card\.tone, \{ 'is-highlighted': isHighlightedRiskCard\(card\) \}\]"/)
|
||||
assert.match(detailViewTemplate, /class="risk-advice-compact-meta"/)
|
||||
assert.doesNotMatch(detailViewTemplate, /section\.hiddenCount/)
|
||||
assert.doesNotMatch(detailViewTemplate, /risk-advice-more/)
|
||||
@@ -538,13 +538,25 @@ test('AI advice risk section uses compact card styling hooks', () => {
|
||||
})
|
||||
|
||||
test('expense rows show a major-risk warning icon before time', () => {
|
||||
assert.match(detailViewTemplate, /'has-major-risk': isMajorExpenseRisk\(item\)/)
|
||||
assert.match(detailViewTemplate, /class="mdi mdi-alert expense-risk-indicator"/)
|
||||
assert.match(detailViewTemplate, /'has-major-risk': hasExpenseRiskIndicator\(item\)/)
|
||||
assert.match(detailViewTemplate, /class="expense-risk-indicator"/)
|
||||
assert.match(detailViewTemplate, /@click="focusExpenseRisk\(item\)"/)
|
||||
assert.match(detailViewStyle, /\.expense-risk-indicator \{/)
|
||||
assert.match(detailViewScript, /function isMajorExpenseRisk\(item\)/)
|
||||
assert.match(detailViewScript, /function hasExpenseRiskIndicator\(item\)/)
|
||||
assert.match(detailViewScript, /buildItemClaimRiskState\(item, resolveClaimRiskFlags\(\)\)/)
|
||||
})
|
||||
|
||||
test('expense risk indicator can focus and flash related risk card', () => {
|
||||
assert.match(detailViewTemplate, /:id="resolveRiskCardDomId\(card\)"/)
|
||||
assert.match(detailViewTemplate, /:data-risk-card-id="card\.id"/)
|
||||
assert.match(detailViewTemplate, /'is-highlighted': isHighlightedRiskCard\(card\)/)
|
||||
assert.match(detailViewScript, /async function focusExpenseRisk\(item\)/)
|
||||
assert.match(detailViewScript, /document\.getElementById\(resolveRiskCardDomId\(card\)\)/)
|
||||
assert.match(detailViewScript, /scrollIntoView\(\{ behavior: 'smooth', block: 'center' \}\)/)
|
||||
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card\.is-highlighted/)
|
||||
assert.match(detailViewStyle, /@keyframes risk-card-flash/)
|
||||
})
|
||||
|
||||
test('AI advice shows only the latest manual return while preserving return count context', () => {
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
claimRiskFlags: [
|
||||
@@ -640,7 +652,7 @@ test('ticket item types and system allowance row are visible but read only', ()
|
||||
assert.match(detailExpenseModelScript, /const OPTIONAL_ATTACHMENT_EXPENSE_TYPES = new Set\(\['ride_ticket', 'travel_allowance'\]\)/)
|
||||
assert.match(detailViewTemplate, /'system-generated-row': item\.isSystemGenerated/)
|
||||
assert.match(detailViewTemplate, /v-if="item\.isSystemGenerated" class="system-row-lock"/)
|
||||
assert.match(detailViewTemplate, /v-if="item\.isSystemGenerated" class="system-attachment-note"/)
|
||||
assert.match(detailViewTemplate, /v-else-if="item\.isSystemGenerated" class="system-attachment-note"/)
|
||||
assert.match(detailViewScript, /系统自动计算的补贴行不能手动编辑/)
|
||||
assert.match(detailViewScript, /系统自动计算的补贴行不能删除/)
|
||||
})
|
||||
@@ -664,13 +676,29 @@ test('expense detail table shows each item filled time from item creation time',
|
||||
assert.match(detailViewTemplate, /<span>条款填写时间<\/span>/)
|
||||
assert.match(detailViewScript, /function formatExpenseFilledTime\(value\)/)
|
||||
assert.match(detailViewScript, /source\?\.filledAt[\s\S]*source\?\.created_at/)
|
||||
assert.match(detailViewScript, /expenseTableColumnCount = computed\(\s*\(\) => 6 \+ \(isEditableRequest\.value \? 1 : 0\)/)
|
||||
assert.match(detailViewScript, /expenseTableColumnCount = computed\(\s*\(\) => 7 \+ \(isEditableRequest\.value \? 1 : 0\)/)
|
||||
assert.match(requestsComposableScript, /filledAt: formatDateTime\(item\?\.created_at\) \|\| '待同步'/)
|
||||
})
|
||||
|
||||
test('expense detail table has per-item risk explanation column', () => {
|
||||
assert.match(detailViewTemplate, /<th class="col-risk-note">异常说明<\/th>/)
|
||||
assert.match(detailViewTemplate, /v-model="expenseEditor\.itemNote"/)
|
||||
assert.match(detailViewTemplate, /hasExpenseRiskOrAbnormal\(item\)[\s\S]*待补充异常说明/)
|
||||
assert.match(detailViewScript, /itemNote: ''/)
|
||||
assert.match(detailViewScript, /expenseEditor\.itemNote = item\.itemNote \|\| ''/)
|
||||
assert.match(detailViewScript, /item_note: expenseEditor\.itemNote\.trim\(\)/)
|
||||
assert.match(detailViewScript, /itemNote: expenseEditor\.itemNote\.trim\(\)/)
|
||||
assert.match(detailViewScript, /function hasExpenseRiskOrAbnormal\(item\)/)
|
||||
assert.match(detailExpenseModelScript, /const itemNote = String\(source\?\.itemNote \?\? source\?\.item_note \?\? ''\)\.trim\(\)/)
|
||||
assert.match(requestsComposableScript, /const itemNote = String\(item\?\.item_note \|\| item\?\.itemNote \|\| ''\)\.trim\(\)/)
|
||||
})
|
||||
|
||||
test('expense item upload remains limited to one receipt per detail row', () => {
|
||||
assert.match(detailViewTemplate, /ref="expenseUploadInput"[\s\S]*type="file"/)
|
||||
assert.doesNotMatch(detailViewTemplate, /\bmultiple\b/)
|
||||
assert.doesNotMatch(
|
||||
detailViewTemplate,
|
||||
/ref="expenseUploadInput"[\s\S]*\bmultiple\b[\s\S]*@change="handleExpenseFileChange"/
|
||||
)
|
||||
assert.equal(
|
||||
(detailViewTemplate.match(/v-if="isEditableRequest && !item\.invoiceId && !item\.isSystemGenerated"/g) || []).length,
|
||||
2
|
||||
@@ -682,6 +710,34 @@ test('expense item upload remains limited to one receipt per detail row', () =>
|
||||
assert.match(detailViewScript, /fileCount > 1[\s\S]*一条费用明细只能上传一张单据/)
|
||||
})
|
||||
|
||||
test('detail smart entry confirms receipt upload before running recognition', () => {
|
||||
assert.match(detailViewTemplate, /@click="triggerSmartEntryUpload"/)
|
||||
assert.match(detailViewTemplate, /ref="smartEntryUploadInput"[\s\S]*\bmultiple\b[\s\S]*@change="handleSmartEntryFileChange"/)
|
||||
assert.match(detailViewTemplate, /:open="smartEntryUploadDialogOpen"/)
|
||||
assert.match(detailViewTemplate, /v-if="smartEntryRecognitionBusy" class="expense-recognition-banner"/)
|
||||
assert.match(detailViewTemplate, /uploadingExpenseId === item\.id" class="system-attachment-note pending"/)
|
||||
assert.match(detailViewTemplate, /title="上传报销附件"/)
|
||||
assert.match(detailViewTemplate, /@click="chooseSmartEntryFile"/)
|
||||
assert.match(detailViewTemplate, /@click="clearSmartEntryFile"/)
|
||||
assert.match(detailViewTemplate, /@confirm="confirmSmartEntryUpload"/)
|
||||
assert.match(detailViewScript, /const smartEntryUploadDialogOpen = ref\(false\)/)
|
||||
assert.match(detailViewScript, /const smartEntryRecognitionBusy = ref\(false\)/)
|
||||
assert.match(detailViewScript, /const actionBusy = computed\(\(\) =>[\s\S]*smartEntryRecognitionBusy\.value/)
|
||||
assert.match(detailViewScript, /const smartEntrySelectedFiles = ref\(\[\]\)/)
|
||||
assert.match(detailViewScript, /function triggerSmartEntryUpload\(\)[\s\S]*smartEntryUploadDialogOpen\.value = true/)
|
||||
assert.match(detailViewScript, /function handleSmartEntryFileChange\(event\)/)
|
||||
assert.match(detailViewScript, /smartEntrySelectedFiles\.value = files/)
|
||||
assert.match(detailViewScript, /function startSmartEntryRecognitionTask\(\{ claimId, files, itemSnapshots \}\)/)
|
||||
assert.match(detailViewScript, /function subscribeSmartEntryRecognitionTask\(claimId, listener\)/)
|
||||
assert.match(detailViewScript, /const smartEntryRecognitionCurrent = ref\(0\)/)
|
||||
assert.match(detailViewScript, /return `附件识别中(\$\{current\}\/\$\{total\}),请稍候。识别完成前暂不可编辑费用明细。`/)
|
||||
assert.match(detailViewScript, /const \{ task, reused \} = startSmartEntryRecognitionTask\(\{[\s\S]*claimId: request\.value\.claimId[\s\S]*itemSnapshots: expenseItems\.value/)
|
||||
assert.match(detailViewScript, /bindSmartEntryRecognitionTask\(request\.value\.claimId\)/)
|
||||
assert.match(detailViewScript, /void runSmartEntryRecognitionTask\(task, pendingFiles\)/)
|
||||
assert.match(detailViewScript, /const payload = await uploadExpenseClaimItemAttachment\(task\.claimId, targetItem\.id, file\)/)
|
||||
assert.doesNotMatch(detailViewScript, /function openAiEntry\(\)[\s\S]*emit\('openAssistant'/)
|
||||
})
|
||||
|
||||
test('expense item upload patches OCR amount into the visible detail row', () => {
|
||||
assert.match(detailViewScript, /const recognizedItemAmount = Number\(payload\?\.item_amount \?\? payload\?\.itemAmount\)/)
|
||||
assert.match(detailViewScript, /const recognizedItemDate = normalizeIsoDateValue\(payload\?\.item_date \?\? payload\?\.itemDate\)/)
|
||||
@@ -701,6 +757,7 @@ test('expense detail edit keeps delete but removes cancel and allows draft place
|
||||
assert.match(detailViewScript, /const amountText = String\(expenseEditor\.itemAmount \|\| ''\)\.trim\(\)/)
|
||||
assert.match(detailViewScript, /const nextAmount = amountText \? Number\(amountText\) : 0/)
|
||||
assert.match(detailViewScript, /if \(expenseEditor\.itemDate\) \{[\s\S]*itemPayload\.item_date = expenseEditor\.itemDate/)
|
||||
assert.match(detailViewScript, /itemPayload = \{[\s\S]*item_note: expenseEditor\.itemNote\.trim\(\)/)
|
||||
})
|
||||
|
||||
test('travel detail AI advice uses material prompts only for required hotel receipts', () => {
|
||||
|
||||
@@ -63,17 +63,22 @@ test('detail submit opens a confirmation dialog before calling submit API', () =
|
||||
assert.match(confirmSubmitRequest, /submitExpenseClaim\(request\.value\.claimId\)/)
|
||||
})
|
||||
|
||||
test('detail submit requires override reasons for high-risk claims', () => {
|
||||
test('detail submit no longer requires a separate high-risk override dialog', () => {
|
||||
assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"/)
|
||||
assert.match(detailViewTemplate, /重大风险/)
|
||||
assert.match(detailViewTemplate, /goToPreviousSubmitRisk/)
|
||||
assert.match(detailViewTemplate, /goToNextSubmitRisk/)
|
||||
assert.match(detailViewTemplate, /v-model="riskOverrideReasons\[currentSubmitRiskWarning\.id\]"/)
|
||||
assert.match(detailViewScript, /const submitRiskWarnings = computed/)
|
||||
assert.match(detailViewScript, /submitRiskWarnings\.value\.length && !hasRiskOverrideExplanation\.value/)
|
||||
const handleSubmit = extractFunction(detailViewScript, 'handleSubmit')
|
||||
const confirmSubmitRequest = extractFunction(detailViewScript, 'confirmSubmitRequest')
|
||||
assert.doesNotMatch(handleSubmit, /openRiskOverrideDialog/)
|
||||
assert.doesNotMatch(confirmSubmitRequest, /openRiskOverrideDialog/)
|
||||
assert.doesNotMatch(detailViewScript, /submitRiskWarnings\.value\.length && !hasRiskOverrideExplanation\.value/)
|
||||
assert.match(detailViewScript, /function confirmRiskOverrideReasons\(\)/)
|
||||
assert.match(detailViewScript, /updateExpenseClaim\(request\.value\.claimId,\s*\{\s*reason: nextNote/s)
|
||||
assert.match(detailViewScript, /超标说明:\$\{tags\}/)
|
||||
assert.match(detailViewTemplate, /异常说明/)
|
||||
})
|
||||
|
||||
test('detail header and fallback progress use reimbursement wording', () => {
|
||||
|
||||
Reference in New Issue
Block a user