feat: 本体字段治理与风险规则模板执行器重构

- 新增本体字段注册表与字段治理审计脚本
- 重构风险规则模板执行器、DSL 验证与清单分类器
- 完善票据夹服务与差旅请求详情页交互
- 优化趋势图表与总览页数据展示
- 增强报销平台风险分级与模拟公司筛选
- 补充本体字段、风险规则生成与票据夹服务测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-03 15:46:56 +08:00
parent e12b140508
commit 34457f9c3e
81 changed files with 4858 additions and 1073 deletions

View 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` 对外输出本体字段,不输出页面别名字段。
- 后端测试覆盖别名归一到本体字段。
- 前端测试覆盖快速报销和核对抽屉只输出本体字段。

View File

@@ -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` 作为收口质量闸。

View 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` 字段进行检查。

View File

@@ -236,3 +236,31 @@ $$
- 本轮采用文件元数据而非数据库,适合先完成闭环;后续若需要审计、权限、跨用户协作和全文检索,应升级到资产表。 - 本轮采用文件元数据而非数据库,适合先完成闭环;后续若需要审计、权限、跨用户协作和全文检索,应升级到资产表。
- 已关联状态如何自动回写,需要在后续把票据夹 ID 与报销明细 `invoice_id` 建立更强绑定。 - 已关联状态如何自动回写,需要在后续把票据夹 ID 与报销明细 `invoice_id` 建立更强绑定。
- 多票据关联时,如果用户中途取消对话,本轮仍保留为未关联,避免误标。 - 多票据关联时,如果用户中途取消对话,本轮仍保留为未关联,避免误标。
## 2026-06-03 详情页与上传治理补充
本轮根据新的验收要求收敛为三块核心内容:
- 左侧为票据预览,使用共享详情页主区域承载,图片和 PDF 都以完整票据可见为优先目标,不再提供“打开源文件”按钮。
- 右侧为识别票据详情,展示当前票据所有 OCR 字段和基础字段;用户点击“编辑”后可直接修改识别内容,保存后写回票据夹元数据。
- 底部为关联信息;左侧预览卡底部同时展示用户编辑操作记录,用于后续财务判断人工修改痕迹。
编辑记录治理:
- `PATCH /receipt-folder/{receipt_id}` 在保存前后对可编辑票据信息做字段级 diff。
- 每条编辑日志记录操作者、操作时间、字段名称、修改前值、修改后值。
- 前端详情页只展示真实 `edit_logs`,不再用模拟操作日志替代。
重复上传治理:
- OCR 持久化票据时计算源文件 `sha256`
- 同一用户再次上传相同源文件时,不新建票据目录,返回已有 `receipt_id`,并在 OCR 文档 warnings 中提示“已上传过同样的单据,请不要重复上传。”
报销助手联动:
- 用户在报销助手上传新附件前,如果票据夹中存在未关联票据,先提示用户是否进入票据夹关联。
- 用户可以选择“去票据夹关联”,也可以选择“继续上传新附件”;继续上传时只跳过本次未关联提醒,不影响后续重复附件校验。
删除级联:
- 已关联票据对应的报销单被删除时,票据夹中关联该报销单的票据源文件、预览文件和元数据一并删除。

View File

@@ -99,3 +99,16 @@
- [x] 更新本 TODO 的完成状态和验证记录。[CONCEPT: 测试方案] - [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` 删除后不可再读取票据。

View File

@@ -155,9 +155,9 @@
"action": "continue" "action": "continue"
}, },
"fail": { "fail": {
"severity": "high", "severity": "medium",
"action": "manual_review", "action": "manual_review",
"risk_score": 84 "risk_score": 60
} }
}, },
"metadata": { "metadata": {
@@ -166,8 +166,8 @@
"source_ref": "费用管控 Demo 风险规则库", "source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.785760+00:00", "created_at": "2026-05-31T00:10:41.785760+00:00",
"created_by": "system", "created_by": "system",
"risk_score": 84, "risk_score": 60,
"risk_level": "high", "risk_level": "medium",
"rule_title": "项目预算与部门不匹配", "rule_title": "项目预算与部门不匹配",
"finance_rule_code": "budget.execution.policy", "finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则", "finance_rule_sheet": "预算执行规则",
@@ -179,9 +179,82 @@
"expense_types": [ "expense_types": [
"all" "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", "severity": "medium",
"risk_score": 84, "risk_score": 60,
"risk_level": "high" "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
}
}
} }

View File

@@ -45,12 +45,6 @@
"type": "text", "type": "text",
"source": "item" "source": "item"
}, },
{
"key": "employee.location",
"label": "员工常驻地",
"type": "text",
"source": "employee"
},
{ {
"key": "attachment.route_cities", "key": "attachment.route_cities",
"label": "交通票行程城市", "label": "交通票行程城市",
@@ -83,7 +77,6 @@
"field_keys": [ "field_keys": [
"claim.location", "claim.location",
"item.item_location", "item.item_location",
"employee.location",
"attachment.route_cities", "attachment.route_cities",
"attachment.hotel_city", "attachment.hotel_city",
"claim.reason", "claim.reason",
@@ -97,9 +90,7 @@
"attachment.route_cities", "attachment.route_cities",
"attachment.hotel_city" "attachment.hotel_city"
], ],
"home_city_fields": [ "home_city_fields": [],
"employee.location"
],
"exception_fields": [ "exception_fields": [
"claim.reason", "claim.reason",
"item.item_reason" "item.item_reason"
@@ -113,7 +104,7 @@
"客户拜访", "客户拜访",
"项目现场" "项目现场"
], ],
"condition_summary": "票据城市未覆盖申报目的地,或路线出现常驻地/目的地以外城市且无合理说明。", "condition_summary": "票据城市未覆盖申报目的地,或路线出现无法由本次票据起终点和申报目的地解释的额外城市且无合理说明。",
"message_template": "差旅票据城市与申报目的地不一致,请补充多地出差、改签或异地住宿说明。" "message_template": "差旅票据城市与申报目的地不一致,请补充多地出差、改签或异地住宿说明。"
}, },
"outcomes": { "outcomes": {

View File

@@ -2,7 +2,7 @@
"schema_version": "2.0", "schema_version": "2.0",
"rule_code": "risk.travel.low.vague_ticket_content", "rule_code": "risk.travel.low.vague_ticket_content",
"name": "差旅票据服务内容笼统低风险", "name": "差旅票据服务内容笼统低风险",
"description": "票据商品或服务名称过于笼统,例如仅写服务费、其他、详见清单等,提醒补充明细。", "description": "票据商品或服务名称过于笼统,例如仅写服务费、其他、详见清单等,提醒补充明细;已明确识别为火车、机票、酒店、出租车等差旅票据时不按 OCR 全文关键词误判。",
"enabled": true, "enabled": true,
"requires_attachment": true, "requires_attachment": true,
"risk_dimension": "travel_reimbursement_control", "risk_dimension": "travel_reimbursement_control",
@@ -41,14 +41,14 @@
}, },
{ {
"key": "attachment.ocr_text", "key": "attachment.ocr_text",
"label": "票据 OCR 全文", "label": "未识别明确票据类型时的 OCR 兜底文本",
"type": "text", "type": "text",
"source": "attachment" "source": "attachment"
} }
] ]
}, },
"params": { "params": {
"condition_summary": "票据商品或服务名称过于笼统,无法直接对应差旅事项。", "condition_summary": "票据未识别为明确的酒店、交通等差旅票据,且商品或服务名称过于笼统,无法直接对应差旅事项。",
"message_template": "差旅票据服务内容较笼统,请补充明细清单或业务说明。" "message_template": "差旅票据服务内容较笼统,请补充明细清单或业务说明。"
}, },
"outcomes": { "outcomes": {

View 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())

View File

@@ -86,6 +86,7 @@ class ExpenseClaimItem(Base):
item_type: Mapped[str] = mapped_column(String(50)) item_type: Mapped[str] = mapped_column(String(50))
item_reason: Mapped[str] = mapped_column(Text()) item_reason: Mapped[str] = mapped_column(Text())
item_location: Mapped[str] = mapped_column(String(100)) 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)) item_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2))
invoice_id: Mapped[str | None] = mapped_column(String(100), nullable=True) invoice_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -12,6 +12,19 @@ class ReceiptFolderFieldRead(BaseModel):
value: str = "" 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): class ReceiptFolderItemRead(BaseModel):
id: str id: str
file_name: str file_name: str
@@ -48,6 +61,7 @@ class ReceiptFolderDetailRead(ReceiptFolderItemRead):
classification_confidence: float = 0.0 classification_confidence: float = 0.0
classification_evidence: list[str] = Field(default_factory=list) classification_evidence: list[str] = Field(default_factory=list)
fields: list[ReceiptFolderFieldRead] = 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) raw_meta: dict[str, Any] = Field(default_factory=dict)

View File

@@ -39,6 +39,7 @@ class ExpenseClaimItemRead(BaseModel):
item_type: str item_type: str
item_reason: str item_reason: str
item_location: str item_location: str
item_note: str = ""
item_amount: Decimal item_amount: Decimal
invoice_id: str | None invoice_id: str | None
is_system_generated: bool = False is_system_generated: bool = False
@@ -101,6 +102,7 @@ class ExpenseClaimItemUpdate(BaseModel):
item_type: str | None = None item_type: str | None = None
item_reason: str | None = None item_reason: str | None = None
item_location: str | None = None item_location: str | None = None
item_note: str | None = None
item_amount: Decimal | None = None item_amount: Decimal | None = None
invoice_id: str | None = None invoice_id: str | None = None
@@ -110,6 +112,7 @@ class ExpenseClaimItemCreate(BaseModel):
item_type: str | None = None item_type: str | None = None
item_reason: str | None = None item_reason: str | None = None
item_location: str | None = None item_location: str | None = None
item_note: str | None = None
item_amount: Decimal | None = None item_amount: Decimal | None = None
invoice_id: str | None = None invoice_id: str | None = None
@@ -203,6 +206,7 @@ class ExpenseClaimAttachmentActionResponse(BaseModel):
item_type: str | None = None item_type: str | None = None
item_reason: str | None = None item_reason: str | None = None
item_location: str | None = None item_location: str | None = None
item_note: str | None = None
item_amount: Decimal | None = None item_amount: Decimal | None = None
claim_amount: Decimal | None = None claim_amount: Decimal | None = None
claim_risk_flags: list[Any] = Field(default_factory=list) claim_risk_flags: list[Any] = Field(default_factory=list)

View File

@@ -216,7 +216,7 @@ class AgentAssetRiskRuleSimulationMixin:
if field_key == "item.item_location": if field_key == "item.item_location":
return self._extract_labeled_city(corpus, city_mentions, ("明细地点", "发生地点")) return self._extract_labeled_city(corpus, city_mentions, ("明细地点", "发生地点"))
if field_key == "employee.location": 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 "city" in field_key or "location" in field_key:
if any( if any(
token in key_text token in key_text
@@ -387,7 +387,6 @@ class AgentAssetRiskRuleSimulationMixin:
for group_name in ( for group_name in (
"attachment_city_fields", "attachment_city_fields",
"reference_city_fields", "reference_city_fields",
"home_city_fields",
"exception_fields", "exception_fields",
): ):
for key in self._read_string_list(params.get(group_name)): for key in self._read_string_list(params.get(group_name)):

View File

@@ -622,7 +622,7 @@ class AgentAssetRiskRuleTestingMixin:
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {} params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
if template_key == "field_compare_v1": if template_key == "field_compare_v1":
if str(params.get("semantic_type") or "").strip() in {"travel_city_consistency", "travel_route_city_consistency"}: 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 return values
condition = next( condition = next(
(item for item in params.get("conditions", []) if isinstance(item, dict)), (item for item in params.get("conditions", []) if isinstance(item, dict)),

View File

@@ -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_asset_timeline import AgentAssetTimelineMixin
from app.services.agent_foundation import AgentFoundationService from app.services.agent_foundation import AgentFoundationService
from app.services.audit import AuditLogService 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 from app.services.risk_rule_score_backfill import backfill_missing_risk_rule_score
logger = get_logger("app.services.agent_assets") logger = get_logger("app.services.agent_assets")
@@ -77,6 +78,7 @@ class AgentAssetService(
assets = self.repository.list( assets = self.repository.list(
asset_type=asset_type, status=status, domain=domain, keyword=keyword asset_type=asset_type, status=status, domain=domain, keyword=keyword
) )
assets = self._filter_excluded_risk_assets(assets)
version_stats = self._collect_version_stats(assets) version_stats = self._collect_version_stats(assets)
return [self._serialize_list_item(asset, version_stats.get(asset.id)) for asset in 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() self._ensure_ready()
if asset_type in {None, "", AgentAssetType.RULE.value}: if asset_type in {None, "", AgentAssetType.RULE.value}:
self.sync_platform_risk_rules_from_library() self.sync_platform_risk_rules_from_library()
result = self.repository.list_page( assets = self.repository.list(
asset_type=asset_type, asset_type=asset_type,
status=status, status=status,
domain=domain, domain=domain,
keyword=keyword, keyword=keyword,
page=page,
page_size=page_size,
) )
version_stats = self._collect_version_stats(result.items) assets = self._filter_excluded_risk_assets(assets)
return result.map( page_params = normalize_page_params(page, page_size)
lambda asset: self._serialize_list_item(asset, version_stats.get(asset.id)) 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: def get_asset(self, asset_id: str) -> AgentAssetRead | None:
@@ -151,6 +160,26 @@ class AgentAssetService(
else None, 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( def create_asset(
self, self,
payload: AgentAssetCreate, payload: AgentAssetCreate,

View File

@@ -124,6 +124,12 @@ class AgentFoundationService(
"ON expense_claims (hermes_risk_flag)" "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() self.db.flush()
def _sync_demo_financial_records(self) -> None: def _sync_demo_financial_records(self) -> None:

View File

@@ -20,6 +20,7 @@ from app.services.agent_asset_spreadsheet import (
from app.services.agent_foundation_constants import ( from app.services.agent_foundation_constants import (
PLATFORM_DESTINATION_LOCATION_RULE_FILENAME, PLATFORM_DESTINATION_LOCATION_RULE_FILENAME,
) )
from app.services.risk_rule_manifest_classifier import is_budget_risk_manifest
logger = get_logger("app.services.agent_foundation") logger = get_logger("app.services.agent_foundation")
@@ -63,6 +64,10 @@ class AgentFoundationRiskRuleMixin:
continue continue
if is_budget_risk_manifest(payload):
continue
manifests.append((file_name, payload)) manifests.append((file_name, payload))
return manifests return manifests

View File

@@ -21,8 +21,8 @@ APPLICATION_EXPENSE_TYPES = {
"preapproval", "preapproval",
} }
APPLICATION_CLAIM_PREFIXES = ("AP-", "APP-", "TA-") APPLICATION_CLAIM_PREFIXES = ("AP-", "APP-", "TA-")
RECENT_VISIBLE_CLAIM_START = 501 RECENT_VISIBLE_CLAIM_START = 401
RECENT_VISIBLE_CLAIM_END = 817 RECENT_VISIBLE_CLAIM_END = 424
def is_admin_identity(*values: Any) -> bool: def is_admin_identity(*values: Any) -> bool:
@@ -99,7 +99,8 @@ def simulation_claim_day(
) )
if visible_day is not None: if visible_day is not None:
return visible_day 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) _, max_day = calendar.monthrange(month.year, month.month)
if month.year == period_end.year and month.month == period_end.month: if month.year == period_end.year and month.month == period_end.month:
max_day = min(max_day, period_end.day) 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: 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 "") department_code = str(getattr(getattr(employee, "department", None), "unit_code", "") or "")
grade = str(getattr(employee, "grade", "") or "") grade = str(getattr(employee, "grade", "") or "")
if department_code in {"MARKET-DEPT", "TECH-DEPT"}: if department_code in {"MARKET-DEPT", "TECH-DEPT"}:
base += 3 base += 1
elif department_code in {"PRODUCTION-DEPT", "PRESIDENT-OFFICE"}: elif department_code in {"PRODUCTION-DEPT", "PRESIDENT-OFFICE"}:
base += 2 base += 1
if grade in {"P7", "P8"}: if grade in {"P7", "P8"}:
base += 2 base += 1
return max(6, min(base, 16)) 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]: def next_simulation_number(prefix: str, used_numbers: set[str], cursor: int) -> tuple[str, int]:

View 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
)

View File

@@ -275,6 +275,7 @@ class ExpenseClaimAttachmentOperationsMixin:
"item_type": item.item_type, "item_type": item.item_type,
"item_reason": item.item_reason, "item_reason": item.item_reason,
"item_location": item.item_location, "item_location": item.item_location,
"item_note": item.item_note,
"item_amount": item.item_amount, "item_amount": item.item_amount,
"claim_amount": claim.amount, "claim_amount": claim.amount,
"claim_risk_flags": list(claim.risk_flags_json or []), "claim_risk_flags": list(claim.risk_flags_json or []),

View File

@@ -107,6 +107,7 @@ from app.services.expense_rule_runtime import (
build_default_expense_rule_catalog, build_default_expense_rule_catalog,
resolve_document_type_label, resolve_document_type_label,
) )
from app.services.ontology_field_registry import normalize_ontology_form_values
from app.services.ocr import OcrService from app.services.ocr import OcrService
@@ -344,10 +345,10 @@ class ExpenseClaimDocumentItemBuilderMixin:
review_form_values = context_json.get("review_form_values") review_form_values = context_json.get("review_form_values")
if isinstance(review_form_values, dict): if isinstance(review_form_values, dict):
review_form_values = normalize_ontology_form_values(review_form_values)
review_type = str( review_type = str(
review_form_values.get("expense_type") review_form_values.get("expense_type")
or review_form_values.get("scene_label") or review_form_values.get("reason")
or review_form_values.get("reason_value")
or "" or ""
) )
if any(keyword in review_type for keyword in ("差旅", "出差")): if any(keyword in review_type for keyword in ("差旅", "出差")):
@@ -377,12 +378,8 @@ class ExpenseClaimDocumentItemBuilderMixin:
else: else:
review_form_values = context_json.get("review_form_values") review_form_values = context_json.get("review_form_values")
if isinstance(review_form_values, dict): if isinstance(review_form_values, dict):
time_text = str( review_form_values = normalize_ontology_form_values(review_form_values)
review_form_values.get("time_range") time_text = str(review_form_values.get("time_range") or "").strip()
or review_form_values.get("business_time")
or review_form_values.get("occurred_date")
or ""
).strip()
matched_dates = re.findall(r"\d{4}-\d{2}-\d{2}", time_text) matched_dates = re.findall(r"\d{4}-\d{2}-\d{2}", time_text)
if matched_dates: if matched_dates:
start_date = self._parse_iso_date_or_default(matched_dates[0], start_date) 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") review_form_values = context_json.get("review_form_values")
text_parts: list[str] = [] text_parts: list[str] = []
if isinstance(review_form_values, dict): if isinstance(review_form_values, dict):
review_form_values = normalize_ontology_form_values(review_form_values)
text_parts.extend( text_parts.extend(
str(review_form_values.get(key) or "") str(review_form_values.get(key) or "")
for key in ( for key in (
"reason", "reason",
"business_reason",
"reason_value",
"scene_label",
"time_range", "time_range",
"business_time", "expense_type",
) )
) )
text_parts.extend( text_parts.extend(

View File

@@ -108,6 +108,7 @@ from app.services.expense_rule_runtime import (
build_default_expense_rule_catalog, build_default_expense_rule_catalog,
resolve_document_type_label, resolve_document_type_label,
) )
from app.services.ontology_field_registry import normalize_ontology_form_values
from app.services.ocr import OcrService 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: def _resolve_explicit_review_expense_type(context_json: dict[str, Any]) -> str | None:
review_form_values = context_json.get("review_form_values") review_form_values = context_json.get("review_form_values")
if isinstance(review_form_values, dict): if isinstance(review_form_values, dict):
compact = str( review_form_values = normalize_ontology_form_values(review_form_values)
review_form_values.get("expense_type") compact = str(review_form_values.get("expense_type") or "").replace(" ", "")
or review_form_values.get("reimbursement_type")
or ""
).replace(" ", "")
if compact: if compact:
return resolve_expense_type_code_from_text(compact) return resolve_expense_type_code_from_text(compact)
return None return None
@@ -238,10 +236,10 @@ class ExpenseClaimOntologyResolverMixin:
) -> str | None: ) -> str | None:
review_form_values = context_json.get("review_form_values") review_form_values = context_json.get("review_form_values")
if isinstance(review_form_values, dict): if isinstance(review_form_values, dict):
for key in ("reason", "business_reason"): review_form_values = normalize_ontology_form_values(review_form_values)
value = str(review_form_values.get(key) or "").strip() value = str(review_form_values.get("reason") or "").strip()
if value: if value:
return ExpenseClaimOntologyResolverMixin._strip_leading_time_from_reason(value) return ExpenseClaimOntologyResolverMixin._strip_leading_time_from_reason(value)
explicit_text = context_json.get("user_input_text") explicit_text = context_json.get("user_input_text")
if isinstance(explicit_text, str): if isinstance(explicit_text, str):
@@ -281,10 +279,10 @@ class ExpenseClaimOntologyResolverMixin:
def _resolve_location(*, message: str, context_json: dict[str, Any]) -> str | None: def _resolve_location(*, message: str, context_json: dict[str, Any]) -> str | None:
review_form_values = context_json.get("review_form_values") review_form_values = context_json.get("review_form_values")
if isinstance(review_form_values, dict): if isinstance(review_form_values, dict):
for key in ("business_location", "location"): review_form_values = normalize_ontology_form_values(review_form_values)
value = str(review_form_values.get(key) or "").strip() value = str(review_form_values.get("location") or "").strip()
if value: if value:
return value return value
request_context = context_json.get("request_context") request_context = context_json.get("request_context")
if ( if (
@@ -314,16 +312,9 @@ class ExpenseClaimOntologyResolverMixin:
) -> datetime | None: ) -> datetime | None:
review_form_values = context_json.get("review_form_values") review_form_values = context_json.get("review_form_values")
if isinstance(review_form_values, dict): if isinstance(review_form_values, dict):
for key in ( review_form_values = normalize_ontology_form_values(review_form_values)
"occurred_date", value = str(review_form_values.get("time_range") or "").strip()
"time_range", if value:
"business_time",
"application_business_time",
"application_time",
):
value = str(review_form_values.get(key) or "").strip()
if not value:
continue
try: try:
parsed = date.fromisoformat(value) parsed = date.fromisoformat(value)
return datetime(parsed.year, parsed.month, parsed.day, tzinfo=UTC) return datetime(parsed.year, parsed.month, parsed.day, tzinfo=UTC)

View File

@@ -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_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.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_manifest_normalizer import normalize_risk_rule_manifest
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
@@ -23,6 +24,44 @@ from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
class ExpenseClaimPlatformRiskMixin: class ExpenseClaimPlatformRiskMixin:
_DEFAULT_RISK_BUSINESS_STAGE = "reimbursement" _DEFAULT_RISK_BUSINESS_STAGE = "reimbursement"
_SUPPORTED_RISK_BUSINESS_STAGES = {"expense_application", "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( def evaluate_platform_risk_rules(
self, self,
@@ -127,6 +166,8 @@ class ExpenseClaimPlatformRiskMixin:
manifest_code = str(payload.get("rule_code") or rule_code).strip() 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): if not manifest_code or (code_filter and manifest_code not in code_filter):
continue continue
if is_budget_risk_manifest(payload):
continue
if payload.get("enabled") is False or not self._risk_manifest_matches_business_stage( if payload.get("enabled") is False or not self._risk_manifest_matches_business_stage(
payload, payload,
business_stage=business_stage, business_stage=business_stage,
@@ -162,6 +203,8 @@ class ExpenseClaimPlatformRiskMixin:
continue continue
if code_filter and rule_code not in missing_codes: if code_filter and rule_code not in missing_codes:
continue continue
if is_budget_risk_manifest(payload):
continue
if payload.get("enabled") is False or not self._risk_manifest_matches_business_stage( if payload.get("enabled") is False or not self._risk_manifest_matches_business_stage(
payload, payload,
business_stage=business_stage, business_stage=business_stage,
@@ -364,7 +407,7 @@ class ExpenseClaimPlatformRiskMixin:
fallback_message="票据文本中出现作废、红冲或红字发票相关信息,建议退回补充或人工复核。", fallback_message="票据文本中出现作废、红冲或红字发票相关信息,建议退回补充或人工复核。",
) )
if evaluator == "vague_goods_description": if evaluator == "vague_goods_description":
return self._evaluate_text_keyword_risk( return self._evaluate_vague_goods_description_risk(
manifest, manifest,
contexts=contexts, contexts=contexts,
keywords=["详见清单", "服务费", "咨询费", "其他", "办公用品"], keywords=["详见清单", "服务费", "咨询费", "其他", "办公用品"],
@@ -663,6 +706,107 @@ class ExpenseClaimPlatformRiskMixin:
evidence={"matched_keywords": matched}, 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( def _evaluate_multi_city_reason_required_risk(
self, self,
manifest: dict[str, Any], manifest: dict[str, Any],

View File

@@ -36,6 +36,7 @@ from app.services.agent_foundation import AgentFoundationService
from app.services.audit import AuditLogService from app.services.audit import AuditLogService
from app.services.document_intelligence import build_document_insight from app.services.document_intelligence import build_document_insight
from app.services.document_numbering import is_application_claim_no 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_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin
from app.services.expense_claim_approval_routing import ExpenseClaimApprovalRoutingMixin 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_read_model import ExpenseClaimReadModelMixin
from app.services.expense_claim_risk_stage import with_risk_business_stage from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
from app.services.receipt_folder import ReceiptFolderService
from app.services.expense_claim_constants import ( from app.services.expense_claim_constants import (
EXPENSE_TYPE_LABELS, EXPENSE_TYPE_LABELS,
MAX_DRAFT_CLAIMS_PER_USER, MAX_DRAFT_CLAIMS_PER_USER,
@@ -320,6 +322,8 @@ class ExpenseClaimService(
item.item_location = ( item.item_location = (
self._normalize_optional_text(payload.item_location, allow_empty=True) or "" 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: if payload.item_amount is not None:
amount = payload.item_amount.quantize(Decimal("0.01")) amount = payload.item_amount.quantize(Decimal("0.01"))
if amount < Decimal("0.00"): if amount < Decimal("0.00"):
@@ -376,6 +380,7 @@ class ExpenseClaimService(
or "other", or "other",
item_reason=self._normalize_optional_text(payload.item_reason, fallback="") or "", item_reason=self._normalize_optional_text(payload.item_reason, fallback="") or "",
item_location=self._normalize_optional_text(payload.item_location, 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, item_amount=item_amount,
invoice_id=self._normalize_optional_text(payload.invoice_id, allow_empty=True), invoice_id=self._normalize_optional_text(payload.invoice_id, allow_empty=True),
) )
@@ -462,11 +467,16 @@ class ExpenseClaimService(
if missing_fields: if missing_fields:
raise ExpenseClaimSubmissionBlockedError(missing_fields) raise ExpenseClaimSubmissionBlockedError(missing_fields)
budget_flags = self._reserve_budget_for_submission( try:
claim, budget_flags = self._reserve_budget_for_submission(
current_user, claim,
is_application_claim=is_application_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) before_json = self._serialize_claim(claim)
if is_application_claim: if is_application_claim:
submitted_at = datetime.now(UTC) submitted_at = datetime.now(UTC)
@@ -576,6 +586,7 @@ class ExpenseClaimService(
self._release_budget_for_delete(claim, current_user) self._release_budget_for_delete(claim, current_user)
self._delete_claim_analysis_records(resource_id) self._delete_claim_analysis_records(resource_id)
self._attachment_storage.delete_claim_files(claim) self._attachment_storage.delete_claim_files(claim)
ReceiptFolderService().delete_receipts_for_claim(resource_id)
self.db.delete(claim) self.db.delete(claim)
self.db.commit() self.db.commit()
@@ -747,11 +758,6 @@ class ExpenseClaimService(

View File

@@ -49,8 +49,18 @@ class FinanceDashboardService(BudgetSupportMixin):
now=now, now=now,
) )
previous_start = start - (end - start) previous_start = start - (end - start)
trend_start, trend_end, trend_labels = self._resolve_trend_scope(trend_range, now) trend_start, trend_end, trend_labels = self._resolve_trend_scope(
ranking_start, ranking_end = self._resolve_ranking_scope(department_range, now) 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 = [ claims = [
claim for claim in self._fetch_claims() if is_finance_reimbursement_claim(claim) claim for claim in self._fetch_claims() if is_finance_reimbursement_claim(claim)
@@ -127,10 +137,31 @@ class FinanceDashboardService(BudgetSupportMixin):
self, self,
trend_range: str, trend_range: str,
now: datetime, now: datetime,
*,
fallback_start: datetime | None = None,
fallback_end: datetime | None = None,
) -> tuple[datetime, datetime, list[str]]: ) -> tuple[datetime, datetime, list[str]]:
days = self._days_from_label(trend_range, default=12) today = now.date()
end_day = now.date() key = str(trend_range or "").strip()
start_day = end_day - timedelta(days=days - 1) 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)] 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 return self._day_start(start_day), self._day_after(end_day), labels
@@ -138,9 +169,32 @@ class FinanceDashboardService(BudgetSupportMixin):
self, self,
department_range: str, department_range: str,
now: datetime, now: datetime,
*,
fallback_start: datetime | None = None,
fallback_end: datetime | None = None,
) -> tuple[datetime, datetime]: ) -> tuple[datetime, datetime]:
today = now.date() today = now.date()
key = str(department_range or "").strip() 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 == "全部": if key == "全部":
return datetime(1970, 1, 1, tzinfo=UTC), self._day_after(today) return datetime(1970, 1, 1, tzinfo=UTC), self._day_after(today)
if key == "本季度": if key == "本季度":
@@ -227,6 +281,8 @@ class FinanceDashboardService(BudgetSupportMixin):
claim_count = [0 for _ in labels] claim_count = [0 for _ in labels]
claim_amount = [Decimal("0.00") for _ in labels] claim_amount = [Decimal("0.00") for _ in labels]
success_count = [0 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] hours: list[list[Decimal]] = [[] for _ in labels]
index = {label: idx for idx, label in enumerate(labels)} index = {label: idx for idx, label in enumerate(labels)}
@@ -237,8 +293,12 @@ class FinanceDashboardService(BudgetSupportMixin):
if label not in index: if label not in index:
continue continue
bucket = index[label] bucket = index[label]
amount = self._claim_amount(claim)
category = self._expense_type_label(claim.expense_type)
claim_count[bucket] += 1 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: if self._status(claim) in SUCCESS_STATUSES:
success_count[bucket] += 1 success_count[bucket] += 1
if claim.submitted_at: if claim.submitted_at:
@@ -248,6 +308,17 @@ class FinanceDashboardService(BudgetSupportMixin):
"labels": labels, "labels": labels,
"claimCount": claim_count, "claimCount": claim_count,
"claimAmount": [self._decimal_number(value) for value in claim_amount], "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, "successCount": success_count,
# 兼容旧前端字段;新财务看板不再使用审批趋势语义。 # 兼容旧前端字段;新财务看板不再使用审批趋势语义。
"applications": claim_count, "applications": claim_count,

View File

@@ -29,6 +29,7 @@ from app.services.agent_foundation import AgentFoundationService
from app.services.agent_runs import AgentRunService from app.services.agent_runs import AgentRunService
from app.services.ontology_detection import OntologyDetectionMixin from app.services.ontology_detection import OntologyDetectionMixin
from app.services.ontology_extraction import OntologyExtractionMixin from app.services.ontology_extraction import OntologyExtractionMixin
from app.services.ontology_field_registry import normalize_ontology_context_json
from app.services.ontology_rules import ( from app.services.ontology_rules import (
CONTEXTUAL_SCENARIOS, CONTEXTUAL_SCENARIOS,
EXPENSE_REVIEW_ACTIONS, EXPENSE_REVIEW_ACTIONS,
@@ -103,7 +104,8 @@ class SemanticOntologyService(
raise ValueError("query 不能为空。") raise ValueError("query 不能为空。")
AgentFoundationService(self.db).ensure_foundation_ready() 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() reference = self._load_reference_catalog()
compact_query = self._compact(query) compact_query = self._compact(query)
entities = self._extract_entities(query, compact_query, reference, context_json=context_json) entities = self._extract_entities(query, compact_query, reference, context_json=context_json)

View File

@@ -14,6 +14,7 @@ from app.schemas.ontology import (
OntologyTimeRange, OntologyTimeRange,
) )
from app.services.document_numbering import DOCUMENT_NUMBER_EXTRACT_PATTERN 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_budget import BudgetOntologyMixin
from app.services.ontology_rules import ( from app.services.ontology_rules import (
AMOUNT_PATTERN, AMOUNT_PATTERN,
@@ -82,9 +83,7 @@ class OntologyExtractionMixin(BudgetOntologyMixin):
) )
if application_mode: if application_mode:
form_values = context_json.get("review_form_values") form_values = normalize_ontology_form_values(context_json.get("review_form_values"))
if not isinstance(form_values, dict):
form_values = {}
expense_type_codes = { expense_type_codes = {
str(item.normalized_value or item.value or "").strip() str(item.normalized_value or item.value or "").strip()
for item in entities for item in entities
@@ -95,17 +94,10 @@ class OntologyExtractionMixin(BudgetOntologyMixin):
missing_slots.append("expense_type") missing_slots.append("expense_type")
if "amount" not in entity_types and not str(form_values.get("amount") or "").strip(): if "amount" not in entity_types and not str(form_values.get("amount") or "").strip():
missing_slots.append("amount") missing_slots.append("amount")
if not time_range.start_date and not ( if not time_range.start_date and not str(form_values.get("time_range") or "").strip():
str(form_values.get("time_range") or form_values.get("business_time") or "").strip()
):
missing_slots.append("time_range") missing_slots.append("time_range")
reason_value = str( reason_text = str(form_values.get("reason") or "").strip()
form_values.get("reason") if not reason_text and compact_query in GENERIC_EXPENSE_APPLICATION_PROMPTS:
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:
missing_slots.append("reason") missing_slots.append("reason")
if ( if (
attachment_count <= 0 attachment_count <= 0
@@ -171,12 +163,33 @@ class OntologyExtractionMixin(BudgetOntologyMixin):
) -> list[OntologyEntity]: ) -> list[OntologyEntity]:
entities: dict[tuple[str, str], OntologyEntity] = {} entities: dict[tuple[str, str], OntologyEntity] = {}
context_json = context_json or {} context_json = context_json or {}
form_values = normalize_ontology_form_values(context_json.get("review_form_values"))
def upsert(entity: OntologyEntity) -> None: def upsert(entity: OntologyEntity) -> None:
key = (entity.type, entity.normalized_value) key = (entity.type, entity.normalized_value)
if key not in entities: if key not in entities:
entities[key] = entity 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 ( if (
self._is_expense_application_context_value(context_json) self._is_expense_application_context_value(context_json)
or self._has_expense_application_signal(compact_query) or self._has_expense_application_signal(compact_query)

View 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

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import hashlib
import mimetypes import mimetypes
import re import re
import shutil import shutil
@@ -85,6 +86,26 @@ class ReceiptFolderService:
if not self._should_persist_source(filename, content): if not self._should_persist_source(filename, content):
enriched.append(document) enriched.append(document)
continue 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( receipt = self.save_receipt(
filename=filename, filename=filename,
content=content, content=content,
@@ -140,6 +161,7 @@ class ReceiptFolderService:
"source_file_name": normalized_name, "source_file_name": normalized_name,
"media_type": resolved_media_type, "media_type": resolved_media_type,
"size_bytes": len(content), "size_bytes": len(content),
"file_sha256": self._content_hash(content),
"uploaded_at": now.isoformat(), "uploaded_at": now.isoformat(),
"status": "linked" if linked else "unlinked", "status": "linked" if linked else "unlinked",
"linked_claim_id": str(linked_claim_id or "").strip(), "linked_claim_id": str(linked_claim_id or "").strip(),
@@ -243,8 +265,24 @@ class ReceiptFolderService:
], ],
fields=self._resolve_fields(meta), fields=self._resolve_fields(meta),
raw_meta=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( def update_receipt(
self, self,
*, *,
@@ -255,6 +293,7 @@ class ReceiptFolderService:
owner_key = self._owner_key(current_user) owner_key = self._owner_key(current_user)
receipt_dir = self._receipt_dir(owner_key, receipt_id) receipt_dir = self._receipt_dir(owner_key, receipt_id)
meta = self._read_meta(receipt_dir) meta = self._read_meta(receipt_dir)
before_meta = json.loads(json.dumps(meta, ensure_ascii=False))
updates = payload.model_dump(exclude_unset=True) updates = payload.model_dump(exclude_unset=True)
for key in ("document_type", "document_type_label", "scene_code", "scene_label", "summary"): for key in ("document_type", "document_type_label", "scene_code", "scene_label", "summary"):
if key in updates and updates[key] is not None: if key in updates and updates[key] is not None:
@@ -270,6 +309,18 @@ class ReceiptFolderService:
for field in payload.fields or [] for field in payload.fields or []
] ]
meta["editable_fields"] = editable 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() meta["updated_at"] = datetime.now(UTC).isoformat()
self._write_meta(receipt_dir, meta) self._write_meta(receipt_dir, meta)
return self.get_receipt(receipt_id, current_user) return self.get_receipt(receipt_id, current_user)
@@ -285,6 +336,23 @@ class ReceiptFolderService:
shutil.rmtree(receipt_dir) shutil.rmtree(receipt_dir)
return ReceiptFolderDeleteResponse(message="票据已删除。", receipt_id=receipt_id) 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]: def resolve_source(self, receipt_id: str, current_user: CurrentUserContext) -> tuple[Path, str, str]:
meta = self._read_receipt_meta(receipt_id, current_user) meta = self._read_receipt_meta(receipt_id, current_user)
receipt_dir = self._receipt_dir(self._owner_key(current_user), receipt_id) receipt_dir = self._receipt_dir(self._owner_key(current_user), receipt_id)
@@ -501,6 +569,14 @@ class ReceiptFolderService:
encoding="utf-8", 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 @staticmethod
def _matches_status(meta: dict[str, Any], status_filter: str) -> bool: def _matches_status(meta: dict[str, Any], status_filter: str) -> bool:
if status_filter in {"", "all"}: if status_filter in {"", "all"}:
@@ -557,6 +633,97 @@ class ReceiptFolderService:
] ]
return fields 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: def _resolve_receipt_document_date(self, meta: dict[str, Any]) -> str:
editable = meta.get("editable_fields") editable = meta.get("editable_fields")
if isinstance(editable, dict): if isinstance(editable, dict):

View File

@@ -202,7 +202,7 @@ def _build_structured_conditions(text: str, fields: list[RiskRuleField]) -> list
field_keys = [field.key for field in fields] field_keys = [field.key for field in fields]
attachment_fields = [key for key in field_keys if key.startswith("attachment.")] 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_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.")] 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_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"}] range_end = [key for key in field_keys if key in {"claim.trip_end_date", "item.item_date"}]

View File

@@ -65,9 +65,10 @@ def _build_condition_steps(manifest: dict[str, Any], evidence: dict[str, Any]) -
), ),
"operator": "route_city_consistency", "operator": "route_city_consistency",
"inputs": { "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 [], "attachment_values": city_consistency.get("attachment_values") or [],
"reference_values": city_consistency.get("reference_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 [], "unexpected_route_cities": city_consistency.get("unexpected_route_cities") or [],
"explanation_hits": city_consistency.get("explanation_hits") or [], "explanation_hits": city_consistency.get("explanation_hits") or [],
}, },

View File

@@ -621,7 +621,6 @@ class RiskRuleGenerationService:
in { in {
"claim.reason", "claim.reason",
"claim.location", "claim.location",
"employee.location",
"item.item_date", "item.item_date",
"item.item_reason", "item.item_reason",
"item.item_location", "item.item_location",

View File

@@ -111,7 +111,7 @@ FIELD_ONTOLOGY: tuple[RiskRuleField, ...] = (
"员工常驻地", "员工常驻地",
"text", "text",
"employee", "employee",
("常驻地", "办公地", "员工所在地", "出发地", "所在城市"), ("常驻地", "办公地", "员工所在地", "所在城市"),
), ),
RiskRuleField("item.item_type", "费用类型", "enum", "item", ("费用类型", "科目", "类型")), RiskRuleField("item.item_type", "费用类型", "enum", "item", ("费用类型", "科目", "类型")),
RiskRuleField("item.item_reason", "明细事由", "text", "item", ("明细事由", "明细说明")), RiskRuleField("item.item_reason", "明细事由", "text", "item", ("明细事由", "明细说明")),

View File

@@ -105,9 +105,10 @@ def build_risk_rule_compiler_messages(
"重复发票、同一票据号、重复报销等规则必须用 duplicate_value例如 attachment.invoice_no 在本次附件或明细中出现重复,不得写成重复风险关键词匹配。", "重复发票、同一票据号、重复报销等规则必须用 duplicate_value例如 attachment.invoice_no 在本次附件或明细中出现重复,不得写成重复风险关键词匹配。",
"差旅路线规则中,交通票行程城市和住宿发票城市属于附件城市集合。", "差旅路线规则中,交通票行程城市和住宿发票城市属于附件城市集合。",
"申报目的地和明细发生地点属于申报行程城市集合。", "申报目的地和明细发生地点属于申报行程城市集合。",
"员工常驻地/出发地如可用,属于合理起终点集合,不等同于申报目的地", "员工常驻地只能作为员工档案背景,不能作为本次出发地或返回地的硬依据",
"本次出发地和返回地应来自申请单明确字段或交通票路线本身。",
"绕行、跨城办事、临时改签是例外说明证据,不是风险命中关键词。", "绕行、跨城办事、临时改签是例外说明证据,不是风险命中关键词。",
"如果票据路线出现申报目的地和常驻地之外的额外城市,应描述为中途周转/绕行异常。", "如果票据路线出现无法由本次票据起终点和申报目的地解释的额外城市,应描述为中途周转/绕行异常。",
"keyword_match_v1 只用于品名、摘要、票据全文中出现明确风险词的规则。", "keyword_match_v1 只用于品名、摘要、票据全文中出现明确风险词的规则。",
"不要直接指定 risk_level 或 risk_score只输出 risk_scoring_evidence后端会按固定评分模型计算 0-100 分和风险等级。", "不要直接指定 risk_level 或 risk_score只输出 risk_scoring_evidence后端会按固定评分模型计算 0-100 分和风险等级。",
"评分证据必须围绕六个指标:业务影响、违规确定性、证据强度、例外/规避空间、处置强度、场景敏感度。", "评分证据必须围绕六个指标:业务影响、违规确定性、证据强度、例外/规避空间、处置强度、场景敏感度。",
@@ -128,13 +129,12 @@ def build_risk_rule_compiler_messages(
"attachment.hotel_city", "attachment.hotel_city",
"claim.location", "claim.location",
"item.item_location", "item.item_location",
"employee.location",
"claim.reason", "claim.reason",
"item.item_reason", "item.item_reason",
], ],
"condition_summary": ( "condition_summary": (
"A=交通票行程城市住宿发票城市B=申报目的地∪明细发生地点," "A=交通票行程城市住宿发票城市B=申报目的地∪明细发生地点,"
"C=员工常驻地/合理起终点;A与B无交集且无合理说明或A中出现BC之外城市时命中。" "A与B无交集且无合理说明或A中出现无法由本次票据起终点和申报目的地解释的额外城市时命中。"
), ),
"keywords": [], "keywords": [],
"exception_keywords": ["绕行", "跨城办事", "临时改签"], "exception_keywords": ["绕行", "跨城办事", "临时改签"],

View File

@@ -19,7 +19,7 @@ RISK_LEVEL_LABELS = {
CITY_ATTACHMENT_FIELDS = ("attachment.route_cities", "attachment.hotel_city") CITY_ATTACHMENT_FIELDS = ("attachment.route_cities", "attachment.hotel_city")
CITY_REFERENCE_FIELDS = ("claim.location", "item.item_location") 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_FIELDS = ("claim.reason", "item.item_reason")
CITY_EXCEPTION_KEYWORDS = ("绕行", "跨城办事", "跨城", "临时改签", "改签", "变更") CITY_EXCEPTION_KEYWORDS = ("绕行", "跨城办事", "跨城", "临时改签", "改签", "变更")
@@ -64,8 +64,9 @@ def build_city_consistency_draft(
risk_label = RISK_LEVEL_LABELS.get(risk_level, "风险") risk_label = RISK_LEVEL_LABELS.get(risk_level, "风险")
condition_summary = ( condition_summary = (
"判断公式A=交通票行程城市住宿发票城市B=申报目的地∪明细发生地点," "判断公式A=交通票行程城市住宿发票城市B=申报目的地∪明细发生地点,"
"C=员工常驻地/合理出发地。若A或B为空则要求补充识别若A与B无交集且无合理说明" "若A或B为空则要求补充识别若A与B无交集且无合理说明"
"或票据路线中存在不属于BC的额外城市则命中目的地不一致/中途周转异常风险。" "或票据路线中存在无法由本次票据起终点和申报目的地解释的额外城市,"
"则命中目的地不一致/中途周转异常风险。"
) )
flow = draft.get("flow") if isinstance(draft.get("flow"), dict) else {} flow = draft.get("flow") if isinstance(draft.get("flow"), dict) else {}
return { return {
@@ -79,9 +80,9 @@ def build_city_consistency_draft(
"flow": { "flow": {
**flow, **flow,
"start": "差旅报销单据提交,并上传交通票据、住宿票据或其他可识别城市的附件", "start": "差旅报销单据提交,并上传交通票据、住宿票据或其他可识别城市的附件",
"evidence": "读取员工常驻地、申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由", "evidence": "读取申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由",
"decision": "附件城市是否覆盖申报行程,且票据路线是否出现申报目的地和常驻地之外的中转城市", "decision": "附件城市是否覆盖申报行程,且票据路线是否出现无法由本次票据起终点和申报目的地解释的中转城市",
"pass": "票据城市覆盖申报行程,且未出现申报目的地和常驻地之外的额外城市", "pass": "票据城市覆盖申报行程,且未出现无法由本次票据起终点和申报目的地解释的额外城市",
"fail": f"票据路线存在目的地不一致或额外中转城市,命中{risk_label}并要求补充说明或退回修改", "fail": f"票据路线存在目的地不一致或额外中转城市,命中{risk_label}并要求补充说明或退回修改",
}, },
} }
@@ -102,16 +103,15 @@ def build_city_consistency_params(draft: dict[str, Any]) -> dict[str, Any]:
"formula": ( "formula": (
"A=UNION(attachment.route_cities, attachment.hotel_city); " "A=UNION(attachment.route_cities, attachment.hotel_city); "
"B=UNION(claim.location, item.item_location); " "B=UNION(claim.location, item.item_location); "
"C=UNION(employee.location); "
"HIT WHEN (A∩B=∅ AND NOT CONTAINS_ANY(exception_fields, exception_keywords)) " "HIT WHEN (A∩B=∅ AND NOT CONTAINS_ANY(exception_fields, exception_keywords)) "
"OR EXISTS(city IN A WHERE city NOT IN BC)" "OR EXISTS(city IN route_cities WHERE city NOT EXPLAINED BY B OR ROUTE_ENDPOINTS)"
), ),
"conditions": [ "conditions": [
{ {
"left_group": list(CITY_ATTACHMENT_FIELDS), "left_group": list(CITY_ATTACHMENT_FIELDS),
"operator": "route_city_consistency", "operator": "route_city_consistency",
"right_group": list(CITY_REFERENCE_FIELDS), "right_group": list(CITY_REFERENCE_FIELDS),
"home_group": list(CITY_HOME_FIELDS), "home_group": [],
"exception_fields": list(CITY_EXCEPTION_FIELDS), "exception_fields": list(CITY_EXCEPTION_FIELDS),
"exception_keywords": exception_keywords, "exception_keywords": exception_keywords,
} }

View 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()

View File

@@ -28,14 +28,15 @@ RISK_LEVEL_LABELS = {
CITY_ROUTE_CONDITION_SUMMARY = ( CITY_ROUTE_CONDITION_SUMMARY = (
"判断公式A=交通票行程城市住宿发票城市B=申报目的地∪明细发生地点," "判断公式A=交通票行程城市住宿发票城市B=申报目的地∪明细发生地点,"
"C=员工常驻地/合理出发地。若A或B为空则要求补充识别若A与B无交集且无合理说明" "若A或B为空则要求补充识别若A与B无交集且无合理说明"
"或票据路线中存在不属于BC的额外城市则命中目的地不一致/中途周转异常风险。" "或票据路线中存在无法由本次票据起终点和申报目的地解释的额外城市,"
"则命中目的地不一致/中途周转异常风险。"
) )
CITY_ROUTE_FLOW_DECISION = ( CITY_ROUTE_FLOW_DECISION = (
"附件城市是否覆盖申报行程,且票据路线是否出现申报目的地和常驻地之外的中转城市" "附件城市是否覆盖申报行程,且票据路线是否出现无法由本次票据起终点和申报目的地解释的中转城市"
) )
CITY_ROUTE_FLOW_EVIDENCE = ( CITY_ROUTE_FLOW_EVIDENCE = (
"读取员工常驻地、申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由" "读取申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由"
) )
@@ -82,7 +83,7 @@ def normalize_risk_rule_manifest(manifest: dict[str, Any]) -> dict[str, Any]:
) )
flow.setdefault( flow.setdefault(
"pass", "pass",
"票据城市覆盖申报行程,且未出现申报目的地和常驻地之外的额外城市", "票据城市覆盖申报行程,且未出现无法由本次票据起终点和申报目的地解释的额外城市",
) )
flow["fail"] = ( flow["fail"] = (
f"票据路线存在目的地不一致或额外中转城市,命中{severity_label}并要求补充说明或退回修改" f"票据路线存在目的地不一致或额外中转城市,命中{severity_label}并要求补充说明或退回修改"

View File

@@ -212,14 +212,13 @@ _TEMPLATE_DEFINITIONS: tuple[dict[str, Any], ...] = (
"requires_attachment": True, "requires_attachment": True,
"natural_language": ( "natural_language": (
"差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;" "差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;"
"再读取员工常驻地、申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由。" "再读取申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由。"
"若交通票或住宿票据中的城市无法与申报目的地、明细地点形成一致关系," "若交通票或住宿票据中的城市无法与申报目的地、明细地点形成一致关系,"
"或票据路线中出现申报目的地与员工常驻地之外的额外中转城市," "或票据路线中出现无法由本次票据起终点和申报目的地解释的额外中转城市,"
"且报销事由中没有说明绕行、跨城办事或临时改签原因," "且报销事由中没有说明绕行、跨城办事或临时改签原因,"
"则标记为高风险,要求补充行程说明或退回修改。" "则标记为高风险,要求补充行程说明或退回修改。"
), ),
"field_keys": [ "field_keys": [
"employee.location",
"claim.location", "claim.location",
"item.item_location", "item.item_location",
"attachment.route_cities", "attachment.route_cities",
@@ -236,7 +235,7 @@ _TEMPLATE_DEFINITIONS: tuple[dict[str, Any], ...] = (
"id": "city_outside_business_scope", "id": "city_outside_business_scope",
"operator": "not_in_scope", "operator": "not_in_scope",
"left_fields": ["attachment.route_cities", "attachment.hotel_city"], "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", "id": "missing_route_exception",

View File

@@ -198,25 +198,23 @@ class RiskRuleTemplateExecutor:
for key in field_keys for key in field_keys
if key in {"attachment.route_cities", "attachment.hotel_city"} if key in {"attachment.route_cities", "attachment.hotel_city"}
] or ["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] = [] reference_values: list[str] = []
application_reference_values: list[str] = []
attachment_values: list[str] = [] attachment_values: list[str] = []
home_values: list[str] = []
route_values: list[str] = [] route_values: list[str] = []
for key in reference_keys: for key in reference_keys:
reference_values.extend(self._resolve_values(key, claim=claim, contexts=contexts)) 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: for key in attachment_keys:
resolved = self._resolve_values(key, claim=claim, contexts=contexts) resolved = self._resolve_values(key, claim=claim, contexts=contexts)
attachment_values.extend(resolved) attachment_values.extend(resolved)
if key == "attachment.route_cities": if key == "attachment.route_cities":
route_values.extend(resolved) route_values.extend(resolved)
for key in home_keys: route_sequence_values = list(route_values)
home_values.extend(self._resolve_values(key, claim=claim, contexts=contexts))
reference_values = self._dedupe_values(reference_values) reference_values = self._dedupe_values(reference_values)
application_reference_values = self._dedupe_values(application_reference_values)
attachment_values = self._dedupe_values(attachment_values) attachment_values = self._dedupe_values(attachment_values)
home_values = self._dedupe_values(home_values)
route_values = self._dedupe_values(route_values) route_values = self._dedupe_values(route_values)
if not reference_values or not attachment_values: if not reference_values or not attachment_values:
return None return None
@@ -239,9 +237,8 @@ class RiskRuleTemplateExecutor:
if keyword and keyword in explanation_corpus if keyword and keyword in explanation_corpus
] ]
unexpected_route_cities = self._resolve_unexpected_route_cities( unexpected_route_cities = self._resolve_unexpected_route_cities(
route_values, route_sequence_values,
reference_values=reference_values, reference_values=reference_values,
home_values=home_values,
) )
has_destination_overlap = self._condition_passes( has_destination_overlap = self._condition_passes(
"overlap", "overlap",
@@ -252,7 +249,7 @@ class RiskRuleTemplateExecutor:
return None return None
reason = ( reason = (
"票据路线包含申报行程和常驻地之外的中转城市。" "票据路线包含无法由申请单、报销单或附件起终点解释的额外城市。"
if unexpected_route_cities if unexpected_route_cities
else "票据城市与申报目的地或明细地点不一致,且未说明绕行、跨城或改签原因。" else "票据城市与申报目的地或明细地点不一致,且未说明绕行、跨城或改签原因。"
) )
@@ -280,9 +277,15 @@ class RiskRuleTemplateExecutor:
"reasonable_exception": bool(keyword_hits), "reasonable_exception": bool(keyword_hits),
}, },
"city_consistency": { "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], "attachment_values": attachment_values[:8],
"reference_values": reference_values[:8], "reference_values": reference_values[:8],
"home_values": home_values[:8],
"route_values": route_values[:8], "route_values": route_values[:8],
"unexpected_route_cities": unexpected_route_cities[:8], "unexpected_route_cities": unexpected_route_cities[:8],
"explanation_keywords": explanation_keywords[:8], "explanation_keywords": explanation_keywords[:8],
@@ -609,14 +612,19 @@ class RiskRuleTemplateExecutor:
route_values: list[str], route_values: list[str],
*, *,
reference_values: list[str], reference_values: list[str],
home_values: list[str],
) -> list[str]: ) -> list[str]:
if len(route_values) < 2: if len(route_values) < 2:
return [] 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: if not allowed_values:
return [] 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] = [] unexpected: list[str] = []
for city in candidates: for city in candidates:
if RiskRuleTemplateExecutor._values_overlap([city], allowed_values): if RiskRuleTemplateExecutor._values_overlap([city], allowed_values):
@@ -625,6 +633,37 @@ class RiskRuleTemplateExecutor:
unexpected.append(city) unexpected.append(city)
return unexpected 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 @staticmethod
def _expand_route_city_values(values: list[Any]) -> list[Any]: def _expand_route_city_values(values: list[Any]) -> list[Any]:
expanded: list[Any] = [] expanded: list[Any] = []
@@ -750,6 +789,56 @@ class RiskRuleTemplateExecutor:
return parsed.year return parsed.year
return None 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 @staticmethod
def _iter_application_time_values(claim: ExpenseClaim) -> list[Any]: def _iter_application_time_values(claim: ExpenseClaim) -> list[Any]:
values: list[Any] = [] values: list[Any] = []

View File

@@ -35,6 +35,7 @@ from app.schemas.user_agent import (
from app.services.agent_assets import AgentAssetService from app.services.agent_assets import AgentAssetService
from app.services.expense_claims import ExpenseClaimService from app.services.expense_claims import ExpenseClaimService
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService, RuntimeTravelPolicy, resolve_document_type_label 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.risk_ontology_bridge import resolve_rule_codes_for_risk_check
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
from app.services.user_agent_constants import * from app.services.user_agent_constants import *
@@ -49,8 +50,8 @@ class UserAgentReviewCoreMixin:
return False return False
if str(payload.context_json.get("review_action") or "").strip(): if str(payload.context_json.get("review_action") or "").strip():
return False return False
review_form_values = self._resolve_review_form_values(payload) review_form_values = normalize_ontology_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(): if str(review_form_values.get("expense_type") or "").strip():
return False return False
if self._resolve_attachment_count(payload) > 0 or self._resolve_ocr_documents(payload): if self._resolve_attachment_count(payload) > 0 or self._resolve_ocr_documents(payload):
return False return False

View File

@@ -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.risk_ontology_bridge import resolve_rule_codes_for_risk_check
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
from app.services.expense_type_keywords import resolve_expense_type_label_from_text 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 * from app.services.user_agent_constants import *
@@ -151,10 +152,9 @@ class UserAgentReviewSlotMixin:
def _resolve_location_value(self, payload: UserAgentRequest) -> str: def _resolve_location_value(self, payload: UserAgentRequest) -> str:
review_form_values = self._resolve_review_form_values(payload) review_form_values = self._resolve_review_form_values(payload)
for key in ("business_location", "location"): value = str(review_form_values.get("location") or "").strip()
value = str(review_form_values.get(key) or "").strip() if value:
if value: return value
return value
if str(payload.context_json.get("entry_source") or "").strip() == "detail": if str(payload.context_json.get("entry_source") or "").strip() == "detail":
request_context = payload.context_json.get("request_context") request_context = payload.context_json.get("request_context")
@@ -181,21 +181,7 @@ class UserAgentReviewSlotMixin:
@staticmethod @staticmethod
def _resolve_review_form_values(payload: UserAgentRequest) -> dict[str, str]: def _resolve_review_form_values(payload: UserAgentRequest) -> dict[str, str]:
values = payload.context_json.get("review_form_values") return normalize_ontology_form_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
@staticmethod @staticmethod
@@ -220,12 +206,7 @@ class UserAgentReviewSlotMixin:
def _build_time_slot(self, payload: UserAgentRequest) -> dict[str, str | float]: def _build_time_slot(self, payload: UserAgentRequest) -> dict[str, str | float]:
review_form_values = self._resolve_review_form_values(payload) review_form_values = self._resolve_review_form_values(payload)
edited_value = str( edited_value = str(review_form_values.get("time_range") or "").strip()
review_form_values.get("time_range")
or review_form_values.get("business_time")
or review_form_values.get("occurred_date")
or ""
).strip()
if edited_value: if edited_value:
raw_value = str(review_form_values.get("time_range_raw") or edited_value).strip() raw_value = str(review_form_values.get("time_range_raw") or edited_value).strip()
return self._build_slot_value( return self._build_slot_value(
@@ -237,17 +218,6 @@ class UserAgentReviewSlotMixin:
evidence="来源于用户修改后的结构化表单。", 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 time_range = payload.ontology.time_range
if time_range.start_date and time_range.end_date: if time_range.start_date and time_range.end_date:
normalized_value = ( normalized_value = (
@@ -270,25 +240,14 @@ class UserAgentReviewSlotMixin:
def _build_location_slot(self, payload: UserAgentRequest) -> dict[str, str | float]: def _build_location_slot(self, payload: UserAgentRequest) -> dict[str, str | float]:
review_form_values = self._resolve_review_form_values(payload) review_form_values = self._resolve_review_form_values(payload)
for key in ("business_location", "location"): value = str(review_form_values.get("location") or "").strip()
value = str(review_form_values.get(key) or "").strip() if value:
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:
return self._build_slot_value( return self._build_slot_value(
value=application_location, value=value,
normalized_value=application_location, normalized_value=value,
source="detail_context", source="user_form",
confidence=0.86, confidence=1.0,
evidence="来源于已关联申请单,作为本次报销草稿的地点依据", evidence="来源于用户修改后的结构化表单",
) )
if str(payload.context_json.get("entry_source") or "").strip() == "detail": if str(payload.context_json.get("entry_source") or "").strip() == "detail":
@@ -396,17 +355,6 @@ class UserAgentReviewSlotMixin:
evidence="来源于用户修改后的结构化表单。", 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( inferred_reason = self._infer_reason_from_claim_groups(
claim_groups=claim_groups, claim_groups=claim_groups,
) )
@@ -457,22 +405,6 @@ class UserAgentReviewSlotMixin:
evidence="来源于用户修改后的结构化表单。", 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", "") amount_value = entity_map.get("amount", "")
if amount_value: if amount_value:
normalized = self._normalize_amount_text(amount_value) normalized = self._normalize_amount_text(amount_value)
@@ -506,7 +438,7 @@ class UserAgentReviewSlotMixin:
ocr_documents: list[dict[str, object]], ocr_documents: list[dict[str, object]],
) -> dict[str, str | float]: ) -> dict[str, str | float]:
review_form_values = self._resolve_review_form_values(payload) 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: if edited_value:
normalized_code, normalized_label = self._normalize_expense_type_input(edited_value) normalized_code, normalized_label = self._normalize_expense_type_input(edited_value)
return self._build_slot_value( return self._build_slot_value(
@@ -581,7 +513,7 @@ class UserAgentReviewSlotMixin:
def _build_attachment_slot(self, payload: UserAgentRequest) -> dict[str, str | float]: def _build_attachment_slot(self, payload: UserAgentRequest) -> dict[str, str | float]:
review_form_values = self._resolve_review_form_values(payload) 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: if attachment_names:
return self._build_slot_value( return self._build_slot_value(
value=attachment_names, value=attachment_names,

View File

@@ -35,6 +35,7 @@ from app.schemas.user_agent import (
from app.services.agent_assets import AgentAssetService from app.services.agent_assets import AgentAssetService
from app.services.expense_claims import ExpenseClaimService from app.services.expense_claims import ExpenseClaimService
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService, RuntimeTravelPolicy, resolve_document_type_label 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.risk_ontology_bridge import resolve_rule_codes_for_risk_check
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
from app.services.user_agent_constants import * from app.services.user_agent_constants import *
@@ -422,22 +423,19 @@ class UserAgentReviewTravelReceiptMixin:
def _build_review_reason_corpus(self, payload: UserAgentRequest) -> str: 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 = [ parts = [
str(payload.message or ""), str(payload.message or ""),
str(payload.context_json.get("user_input_text") or ""), str(payload.context_json.get("user_input_text") or ""),
str(review_form_values.get("reason") 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("location") or ""),
str(review_form_values.get("business_location") or ""),
] ]
return "\n".join(part.strip() for part in parts if part and part.strip()) 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: 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 = [ candidates = [
str(review_form_values.get("business_location") or ""),
str(review_form_values.get("location") or ""), str(review_form_values.get("location") or ""),
self._resolve_location_value(payload), self._resolve_location_value(payload),
str(payload.message or ""), str(payload.message or ""),

View File

@@ -202,7 +202,7 @@ def test_non_standard_finance_rule_spreadsheets_are_not_seeded() -> None:
assert asset is None or asset.config_json["tag"] == "废弃规则" 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: with build_session() as db:
service = AgentAssetService(db) service = AgentAssetService(db)
service.list_assets(asset_type=AgentAssetType.RULE.value) 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 is 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 communication_rule is not None assert communication_rule is not None
assert communication_rule.scenario_json == ["通信费"] 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 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: def test_agent_asset_service_can_activate_rule_after_review() -> None:
with build_session() as db: with build_session() as db:
service = AgentAssetService(db) service = AgentAssetService(db)

View File

@@ -14,11 +14,12 @@ from app.models.organization import OrganizationUnit
from app.models.risk_observation import RiskObservation from app.models.risk_observation import RiskObservation
from app.services.budget import BudgetService from app.services.budget import BudgetService
from app.services.demo_company_simulation_seed import ( from app.services.demo_company_simulation_seed import (
SIM_CLAIM_PREFIX,
SIM_EMPLOYEE_PREFIX, SIM_EMPLOYEE_PREFIX,
HalfYearExpenseSimulationSeeder, HalfYearExpenseSimulationSeeder,
SimulationConfig, 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: 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") summary = BudgetService(db).get_summary(fiscal_year=2026, period_key="2026Q2")
sim_claim_count = db.scalar( 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( sim_employee_count = db.scalar(
select(func.count()).select_from(Employee).where(Employee.employee_no.like(f"{SIM_EMPLOYEE_PREFIX}%")) 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( visible_claim_count = db.scalar(
select(func.count()) select(func.count())
.select_from(ExpenseClaim) .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, 1, tzinfo=UTC))
.where(ExpenseClaim.occurred_at < datetime(2026, 6, 3, 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( earliest_claim_day = db.scalar(
select(func.min(ExpenseClaim.occurred_at)).where( 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( latest_claim_day = db.scalar(
select(func.max(ExpenseClaim.occurred_at)).where( 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 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 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 earliest_claim_day is not None
assert latest_claim_day is not None assert latest_claim_day is not None
assert earliest_claim_day.date() >= date(2026, 1, 1) assert earliest_claim_day.date() >= date(2026, 1, 1)
assert latest_claim_day.date() <= date(2026, 6, 2) 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()

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json
from datetime import UTC, date, datetime from datetime import UTC, date, datetime
from decimal import Decimal from decimal import Decimal
from typing import Any 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.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY 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.expense_claims import ExpenseClaimService
from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY 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: def _build_claim(*, claim_no: str, expense_type: str, status: str = "draft") -> ExpenseClaim:
return ExpenseClaim( return ExpenseClaim(
claim_no=claim_no, claim_no=claim_no,
@@ -162,6 +221,13 @@ def test_platform_risk_rules_are_filtered_by_business_stage_and_category(
business_stage="reimbursement", business_stage="reimbursement",
message="报账环节规则命中", message="报账环节规则命中",
) )
_add_active_rule_asset(
db,
manager,
rule_code="risk.budget.sample.reimbursement.rule",
business_stage="reimbursement",
message="预算风险规则不应进入行为风险检测",
)
_add_active_rule_asset( _add_active_rule_asset(
db, db,
manager, 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]["business_stage"] == "reimbursement"
assert rule_flags[0]["visibility_scope"] == "submitter" assert rule_flags[0]["visibility_scope"] == "submitter"
assert rule_flags[0]["actionability"] == "fixable_by_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"] == ["服务费"]

View File

@@ -1375,6 +1375,7 @@ def test_update_claim_item_allows_placeholder_date_reason_and_amount() -> None:
payload=ExpenseClaimItemUpdate( payload=ExpenseClaimItemUpdate(
item_reason="", item_reason="",
item_location="", item_location="",
item_note="票据行程存在改签,已核对业务真实发生。",
item_amount=Decimal("0.00"), item_amount=Decimal("0.00"),
), ),
current_user=current_user, 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_date == date(2026, 5, 13)
assert claim.items[0].item_reason == "" assert claim.items[0].item_reason == ""
assert claim.items[0].item_location == "" assert claim.items[0].item_location == ""
assert claim.items[0].item_note == "票据行程存在改签,已核对业务真实发生。"
assert claim.items[0].item_amount == Decimal("0.00") 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) service = ExpenseClaimService(db)
updated = service.create_claim_item( updated = service.create_claim_item(
claim_id=claim.id, claim_id=claim.id,
payload=ExpenseClaimItemCreate(), payload=ExpenseClaimItemCreate(item_note="待上传异常票据说明"),
current_user=current_user, 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_type == "office"
assert new_item.item_reason == "" assert new_item.item_reason == ""
assert new_item.item_location == "" assert new_item.item_location == ""
assert new_item.item_note == "待上传异常票据说明"
assert new_item.item_amount == Decimal("0.00") assert new_item.item_amount == Decimal("0.00")
assert new_item.invoice_id is None 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( def test_submit_claim_routes_hotel_amount_over_travel_policy_to_approval_with_review_flag(
monkeypatch, monkeypatch,
tmp_path, tmp_path,
@@ -4051,6 +4202,44 @@ def test_application_submit_blocks_when_budget_insufficient_without_state_change
assert db.query(BudgetTransaction).count() == 0 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: def test_application_submit_skips_budget_for_non_demo_subject() -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
username="application-budget-skip@example.com", username="application-budget-skip@example.com",

View File

@@ -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 "budget pressure" not in str(dashboard.exception_mix).lower()
assert dashboard.trend["claimCount"][-1] == 1 assert dashboard.trend["claimCount"][-1] == 1
assert dashboard.trend["claimAmount"][-1] == 700.0 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.trend["applications"] == dashboard.trend["claimCount"]
assert dashboard.department_ranking[0]["name"] == "Market" assert dashboard.department_ranking[0]["name"] == "Market"
assert dashboard.department_ranking[0]["amount"] == 700.0 assert dashboard.department_ranking[0]["amount"] == 700.0

View File

@@ -123,6 +123,17 @@ def test_ocr_recognize_endpoint_returns_structured_payload(monkeypatch, tmp_path
repeated_document = repeated_response.json()["documents"][0] repeated_document = repeated_response.json()["documents"][0]
assert repeated_document["receipt_id"] == receipt_id 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) all_receipts_response = client.get("/api/v1/receipt-folder?status=all", headers=auth_headers)
assert all_receipts_response.status_code == 200 assert all_receipts_response.status_code == 200
assert len(all_receipts_response.json()) == 1 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 assert update_response.status_code == 200
updated_payload = update_response.json()
assert update_response.json()["document_type_label"] == "电子发票" assert update_response.json()["document_type_label"] == "电子发票"
assert update_response.json()["amount"] == "108元" 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) preview_response = client.get(f"/api/v1/receipt-folder/{receipt_id}/preview", headers=auth_headers)
assert preview_response.status_code == 200 assert preview_response.status_code == 200
assert preview_response.content == b"fake-image" assert preview_response.content == b"fake-image"

View File

@@ -13,6 +13,7 @@ from app.api.deps import get_db
from app.db.base import Base from app.db.base import Base
from app.schemas.ontology import OntologyParseRequest from app.schemas.ontology import OntologyParseRequest
from app.services.ontology import LlmOntologyParseResult, SemanticOntologyService 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 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 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: def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None:
session_factory = build_session_factory() session_factory = build_session_factory()
with session_factory() as db: with session_factory() as db:

View File

@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import pytest
from app.api.deps import CurrentUserContext from app.api.deps import CurrentUserContext
from app.core.config import get_settings from app.core.config import get_settings
from app.schemas.ocr import OcrRecognizeDocumentRead 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" assert fields["列车出发时间"] == "2026-02-20 08:30"
finally: finally:
get_settings.cache_clear() 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()

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import json import json
from datetime import UTC, date, datetime from datetime import UTC, date, datetime
from decimal import Decimal from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
import pytest 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_assets import AgentAssetService
from app.services.agent_foundation_risk_rules import AgentFoundationRiskRuleMixin from app.services.agent_foundation_risk_rules import AgentFoundationRiskRuleMixin
from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMixin 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 ( from app.services.risk_rule_flow_diagram import (
RiskRuleFlowDiagramRenderer, RiskRuleFlowDiagramRenderer,
RiskRuleFlowDiagramSpec, RiskRuleFlowDiagramSpec,
@@ -62,13 +64,12 @@ class TravelRouteSemanticRuntimeChatService:
"attachment.hotel_city", "attachment.hotel_city",
"claim.location", "claim.location",
"item.item_location", "item.item_location",
"employee.location",
"claim.reason", "claim.reason",
"item.item_reason", "item.item_reason",
], ],
"condition_summary": ( "condition_summary": (
"A=交通票行程城市住宿发票城市B=申报目的地∪明细发生地点," "A=交通票行程城市住宿发票城市B=申报目的地∪明细发生地点,"
"C=员工常驻地;A与B无交集且无合理说明或A出现BC之外城市时命中。" "A与B无交集且无合理说明或A出现无法由本次票据起终点和申报目的地解释的额外城市时命中。"
), ),
"keywords": [], "keywords": [],
"exception_keywords": ["绕行", "跨城办事", "临时改签"], "exception_keywords": ["绕行", "跨城办事", "临时改签"],
@@ -577,6 +578,39 @@ def test_risk_rule_flow_diagram_uses_risk_level_palette() -> None:
assert "#10a37f" not in high_svg 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: def test_risk_rule_simulation_extracts_ticket_route_cities() -> None:
with build_session() as db: with build_session() as db:
service = AgentAssetService(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 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: def test_generate_complex_travel_route_rule_uses_formula_not_keyword_match(tmp_path) -> None:
text = ( 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 payload["params"]["exception_keywords"][:3] == ["绕行", "跨城办事", "跨城"]
assert "A=交通票行程城市" in payload["params"]["condition_summary"] assert "A=交通票行程城市" in payload["params"]["condition_summary"]
assert "风险关键词" not 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"] 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 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 = { manifest = {
"template_key": "field_compare_v1", "template_key": "field_compare_v1",
"params": { "params": {
@@ -904,8 +1212,8 @@ def test_travel_route_rule_hits_unexpected_intermediate_city_even_when_returning
"exception_fields": ["claim.reason"], "exception_fields": ["claim.reason"],
"exception_keywords": ["绕行", "跨城办事", "临时改签"], "exception_keywords": ["绕行", "跨城办事", "临时改签"],
"condition_summary": ( "condition_summary": (
"A=票据路线城市B=申报城市,C=员工常驻地," "A=票据路线城市B=申报城市,"
"A中出现BC之外城市则命中。" "A中出现无法由本次票据起终点和申报目的地解释的额外城市则命中。"
), ),
}, },
"outcomes": {"fail": {"severity": "high"}}, "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 assert result is not None
evidence = result["evidence"]["city_consistency"] evidence = result["evidence"]["city_consistency"]
assert evidence["reference_values"] == ["上海"] 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: def test_simulation_uses_current_rule_manifest_for_ticket_city_mismatch(tmp_path) -> None:

View File

@@ -44,8 +44,6 @@
align-items: center; align-items: center;
} }
.receipt-key-grid input,
.receipt-edit-field-row input,
.receipt-ocr-field input { .receipt-ocr-field input {
width: 100%; width: 100%;
border: 1px solid #d7e0ea; border: 1px solid #d7e0ea;
@@ -56,15 +54,11 @@
transition: border-color 160ms ease, box-shadow 160ms ease; transition: border-color 160ms ease, box-shadow 160ms ease;
} }
.receipt-key-grid input,
.receipt-edit-field-row input,
.receipt-ocr-field input { .receipt-ocr-field input {
height: 36px; height: 36px;
padding: 0 10px; padding: 0 10px;
} }
.receipt-key-grid input:focus,
.receipt-edit-field-row input:focus,
.receipt-ocr-field input:focus { .receipt-ocr-field input:focus {
border-color: var(--theme-primary); border-color: var(--theme-primary);
box-shadow: 0 0 0 3px rgba(58, 124, 165, 0.14); box-shadow: 0 0 0 3px rgba(58, 124, 165, 0.14);
@@ -105,6 +99,7 @@
} }
.receipt-folder-detail { .receipt-folder-detail {
min-width: 0;
display: grid; display: grid;
grid-template-rows: minmax(0, 1fr) auto; grid-template-rows: minmax(0, 1fr) auto;
gap: 12px; gap: 12px;
@@ -112,99 +107,49 @@
} }
.receipt-folder-detail :deep(.detail-scroll) { .receipt-folder-detail :deep(.detail-scroll) {
min-width: 0;
min-height: 0; min-height: 0;
display: grid; display: flex;
align-content: start; flex-direction: column;
gap: 16px; gap: 16px;
padding-right: 4px; padding-right: 0;
overflow: auto; overflow: auto;
} }
.receipt-folder-detail :deep(.detail-scroll) > * {
min-width: 0;
flex: 0 0 auto;
}
.receipt-folder-detail :deep(.detail-actions) { .receipt-folder-detail :deep(.detail-actions) {
flex-wrap: wrap;
margin-top: 10px; margin-top: 10px;
padding-top: 10px; padding-top: 10px;
} }
.receipt-detail-toolbar { .receipt-folder-detail :deep(.detail-action-group) {
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 {
min-width: 0; 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; 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) { .receipt-folder-detail :deep(.detail-grid) {
min-height: 0; min-width: 0;
display: grid; 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; gap: 16px;
align-items: stretch; align-items: start;
overflow: visible; overflow: visible;
} }
.receipt-folder-detail :deep(.detail-bottom) {
min-width: 0;
display: block;
}
.receipt-folder-detail :deep(.detail-main), .receipt-folder-detail :deep(.detail-main),
.receipt-folder-detail :deep(.detail-side) { .receipt-folder-detail :deep(.detail-side) {
min-height: 0; min-width: 0;
display: grid; display: block;
} }
.receipt-folder-detail :deep(.enterprise-detail-card .card-head) { .receipt-folder-detail :deep(.enterprise-detail-card .card-head) {
@@ -228,60 +173,80 @@
font-size: 12px; font-size: 12px;
} }
.receipt-basic-panel,
.receipt-preview-panel, .receipt-preview-panel,
.receipt-ocr-panel, .receipt-ticket-info-panel,
.receipt-status-panel, .receipt-association-panel {
.receipt-info-panel, min-width: 0;
.receipt-log-panel {
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
border: 1px solid #dbe4ee; border: 1px solid #dbe4ee;
border-radius: 4px; border-radius: 4px;
background: #fff; background: #fff;
}
.receipt-basic-panel {
display: block;
padding: 14px; 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; justify-content: space-between;
gap: 12px; gap: 12px;
} }
.receipt-field-list-head strong { .receipt-section-head strong {
color: #0f172a; color: #0f172a;
font-size: 15px; font-size: 15px;
font-weight: 850;
} }
.receipt-field-list-head small { .receipt-field-list-head small,
.receipt-section-head small {
color: #64748b; color: #64748b;
font-size: 12px; font-size: 12px;
font-weight: 750; 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 { .receipt-ocr-field {
display: grid; display: grid;
gap: 6px; gap: 6px;
} }
.receipt-key-field span,
.receipt-edit-field-row label span,
.receipt-ocr-field span, .receipt-ocr-field span,
.receipt-static-item span, .receipt-static-item span,
.receipt-data-item span, .receipt-data-item span {
.receipt-status-item span {
color: #64748b; color: #64748b;
font-size: 12px; font-size: 12px;
font-weight: 750; font-weight: 750;
@@ -294,23 +259,20 @@
} }
.receipt-static-grid, .receipt-static-grid,
.receipt-ocr-grid,
.receipt-status-grid,
.receipt-data-list { .receipt-data-list {
display: grid; display: grid;
gap: 10px; gap: 10px;
} }
.receipt-static-grid { .receipt-static-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
margin-top: 14px; margin-top: 10px;
padding-top: 12px; padding-top: 10px;
border-top: 1px solid #edf2f7; border-top: 1px solid #edf2f7;
} }
.receipt-static-item, .receipt-static-item,
.receipt-data-item, .receipt-data-item {
.receipt-status-item {
min-width: 0; min-width: 0;
display: grid; display: grid;
gap: 4px; gap: 4px;
@@ -326,110 +288,34 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.receipt-ocr-grid { .receipt-all-field-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); max-height: clamp(360px, 60vh, 640px);
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;
display: grid; display: grid;
gap: 10px; gap: 10px;
overflow-y: auto;
padding-right: 4px; padding-right: 4px;
overflow-y: auto;
} }
.receipt-edit-field-row { .receipt-all-field-grid.editing {
display: grid; max-height: clamp(420px, 64vh, 680px);
grid-template-columns: minmax(120px, .72fr) minmax(180px, 1.28fr); }
gap: 10px;
padding: 10px; .receipt-ocr-field {
border: 1px solid #e1e8f0; padding: 8px 10px;
border: 1px solid #e5edf5;
border-radius: 4px; border-radius: 4px;
background: #f8fafc; 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 { .receipt-field-empty {
min-height: 64px; min-height: 64px;
display: inline-flex; display: inline-flex;
@@ -445,21 +331,25 @@
} }
.receipt-preview-panel { .receipt-preview-panel {
align-self: start;
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr) auto; gap: 12px;
padding: 14px;
} }
.receipt-preview-frame { .receipt-preview-frame {
min-width: 0;
min-height: 0; min-height: 0;
padding: 10px; padding: 10px;
border: 1px solid #e5edf5; border: 1px solid #e5edf5;
border-radius: 4px; border-radius: 4px;
background: #fff; background: #fff;
overflow: hidden;
} }
.receipt-preview-box { .receipt-preview-box {
min-height: 340px; width: 100%;
height: clamp(380px, 56vh, 640px);
min-height: 0;
display: grid; display: grid;
place-items: center; place-items: center;
overflow: auto; overflow: auto;
@@ -467,8 +357,8 @@
} }
.receipt-preview-box img { .receipt-preview-box img {
max-width: 100%; width: 100%;
max-height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
transform-origin: center center; transform-origin: center center;
transition: transform 180ms ease; transition: transform 180ms ease;
@@ -477,6 +367,7 @@
.receipt-preview-box iframe { .receipt-preview-box iframe {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 380px;
border: 0; border: 0;
background: #fff; background: #fff;
} }
@@ -495,10 +386,12 @@
} }
.receipt-preview-tools { .receipt-preview-tools {
min-width: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
flex-wrap: wrap;
padding-top: 12px; padding-top: 12px;
} }
@@ -546,59 +439,107 @@
font-weight: 800; font-weight: 800;
} }
.receipt-log-list { .receipt-edit-log-section {
position: relative;
display: grid; display: grid;
gap: 10px; gap: 10px;
margin: 0; padding-top: 12px;
padding: 0 0 0 16px; border-top: 1px solid #edf2f7;
list-style: none;
} }
.receipt-log-list::before { .receipt-edit-log-section header,
content: ""; .receipt-edit-log-meta {
position: absolute; display: flex;
left: 4px; align-items: center;
top: 6px; justify-content: space-between;
bottom: 6px; gap: 10px;
width: 1px;
background: #dbe4ee;
} }
.receipt-log-list li { .receipt-edit-log-section header strong {
position: relative; 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; display: grid;
grid-template-columns: 120px 54px minmax(0, 1fr); max-height: 180px;
gap: 8px; 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; color: #334155;
font-size: 12px; font-size: 12px;
} }
.receipt-log-list li::before { .receipt-edit-log-list p span {
content: "";
position: absolute;
left: -15px;
top: 5px;
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--theme-primary);
}
.receipt-log-list span {
color: #64748b; 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; color: #0f172a;
font-weight: 780; font-weight: 800;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.receipt-log-list p { .receipt-edit-log-empty {
margin: 0; min-height: 42px;
line-height: 1.45; 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 { .associate-step {
@@ -662,16 +603,9 @@
gap: 3px; gap: 3px;
} }
@media (max-width: 1120px) { @media (max-width: 1180px) {
.receipt-dashboard,
.receipt-dashboard-bottom,
.receipt-folder-detail :deep(.detail-grid) { .receipt-folder-detail :deep(.detail-grid) {
grid-template-columns: 1fr; grid-template-columns: minmax(0, 1fr);
overflow-y: auto;
}
.receipt-preview-panel {
min-height: 520px;
} }
} }
@@ -711,120 +645,32 @@
width: 100%; 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 { .receipt-folder-list td:first-child .doc-id {
white-space: normal; white-space: normal;
overflow: visible; overflow: visible;
text-overflow: clip; 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 { .receipt-preview-tools {
align-items: stretch; align-items: stretch;
flex-direction: column; flex-direction: column;
} }
.receipt-key-grid, .receipt-preview-tools > *,
.receipt-edit-field-row, .preview-tool-group {
width: 100%;
}
.preview-tool-group {
justify-content: center;
}
.receipt-static-grid, .receipt-static-grid,
.receipt-ocr-grid, .receipt-data-list.association {
.receipt-status-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.receipt-log-list li { .receipt-preview-box {
grid-template-columns: 1fr; height: clamp(320px, 60vh, 520px);
} }
} }

View File

@@ -865,6 +865,25 @@
overflow-x: auto; 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 { .detail-expense-table table {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
@@ -907,12 +926,13 @@
background: var(--success-soft); background: var(--success-soft);
} }
.detail-expense-table .col-time { width: 11%; } .detail-expense-table .col-time { width: 10%; }
.detail-expense-table .col-filled-at { width: 15%; } .detail-expense-table .col-filled-at { width: 13%; }
.detail-expense-table .col-type { width: 13%; } .detail-expense-table .col-type { width: 11%; }
.detail-expense-table .col-desc { width: 19%; } .detail-expense-table .col-desc { width: 15%; }
.detail-expense-table .col-amount { width: 11%; } .detail-expense-table .col-amount { width: 9%; }
.detail-expense-table .col-attachment { width: 22%; } .detail-expense-table .col-attachment { width: 18%; }
.detail-expense-table .col-risk-note { width: 15%; }
.detail-expense-table .col-action { width: 9%; } .detail-expense-table .col-action { width: 9%; }
.expense-time { .expense-time {
@@ -929,12 +949,25 @@
top: 50%; top: 50%;
width: 18px; width: 18px;
height: 18px; height: 18px;
padding: 0;
border: 0;
background: transparent;
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
transform: translateY(-50%); transform: translateY(-50%);
color: #dc2626; color: #dc2626;
font-size: 18px; font-size: 18px;
line-height: 1; 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 { .cell-editor {
@@ -948,7 +981,8 @@
} }
.editor-input, .editor-input,
.editor-select { .editor-select,
.editor-textarea {
width: 100%; width: 100%;
min-height: 34px; min-height: 34px;
padding: 0 10px; padding: 0 10px;
@@ -959,6 +993,13 @@
font-size: 12px; font-size: 12px;
} }
.editor-textarea {
min-height: 68px;
padding: 8px 10px;
resize: vertical;
line-height: 1.45;
}
.currency-editor { .currency-editor {
display: grid; display: grid;
grid-template-columns: 34px minmax(0, 1fr); grid-template-columns: 34px minmax(0, 1fr);
@@ -979,7 +1020,8 @@
} }
.editor-input:focus, .editor-input:focus,
.editor-select:focus { .editor-select:focus,
.editor-textarea:focus {
border-color: var(--theme-primary); border-color: var(--theme-primary);
box-shadow: 0 0 0 3px var(--theme-focus-ring); box-shadow: 0 0 0 3px var(--theme-focus-ring);
outline: none; outline: none;
@@ -1036,6 +1078,29 @@
text-align: left; 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 { .over-tag {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1339,6 +1404,12 @@
font-weight: 700; 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 { .empty-row-cell {
padding: 22px 16px; padding: 22px 16px;
color: #64748b; color: #64748b;
@@ -1352,6 +1423,105 @@
display: none; 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 { .attachment-preview-mask {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -1813,6 +1983,30 @@
border-radius: 2px; border-radius: 2px;
background: #ffffff; background: #ffffff;
box-shadow: 0 1px 1px rgba(15, 23, 42, 0.03); 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 { .validation-section--risk .risk-advice-card::before {

View File

@@ -1,7 +1,17 @@
<template> <template>
<div class="trend-chart"> <div class="trend-chart">
<div class="chart-legend"> <div class="chart-toolbar">
<span><i :style="{ background: activeColor }"></i>{{ legendLabel }}</span> <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>
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div> <div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
</div> </div>
@@ -9,34 +19,59 @@
<script setup> <script setup>
import { computed, shallowRef } from 'vue' 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 { GridComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core' import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers' import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js' 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({ const props = defineProps({
labels: { type: Array, required: true }, labels: { type: Array, required: true },
mode: { type: String, default: 'amount' }, mode: { type: String, default: 'amount' },
claimCount: { type: Array, default: () => [] }, claimCount: { type: Array, default: () => [] },
claimAmount: { type: Array, default: () => [] }, claimAmount: { type: Array, default: () => [] },
categoryAmountSeries: { type: Array, default: () => [] },
applications: { type: Array, default: () => [] }, applications: { type: Array, default: () => [] },
approved: { type: Array, default: () => [] }, approved: { type: Array, default: () => [] }
avgHours: { type: Array, default: () => [] }
}) })
const chartElement = shallowRef(null) const chartElement = shallowRef(null)
const themeColors = useThemeColors() const themeColors = useThemeColors()
const isCountMode = computed(() => props.mode === 'count')
const chartColors = computed(() => ({ const chartColors = computed(() => ({
primary: themeColors.value.chartPrimary, 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(() => ( const claimCountSeries = computed(() => (
props.claimCount.length ? props.claimCount : props.applications props.claimCount.length ? props.claimCount : props.applications
)) ))
@@ -46,22 +81,108 @@ const claimAmountSeries = computed(() => (
const activeSeries = computed(() => ( const activeSeries = computed(() => (
isCountMode.value ? claimCountSeries.value : claimAmountSeries.value 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(() => ( const activeColor = computed(() => (
isCountMode.value ? chartColors.value.primary : chartColors.value.blue isCountMode.value ? chartColors.value.primary : chartColors.value.blue
)) ))
const legendLabel = computed(() => ( 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 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(() => const ariaLabel = computed(() =>
props.labels.map((label, index) => ( props.labels.map((label, index) => (
isCountMode.value isCountMode.value
? `${label}报销${claimCountSeries.value[index] || 0}` ? `${label}报销${claimCountSeries.value[index] || 0}`
: `${label}报销金额${formatCurrency(claimAmountSeries.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(() => ({ const chartOptions = computed(() => ({
backgroundColor: 'transparent', backgroundColor: 'transparent',
animation: true, animation: true,
@@ -70,7 +191,7 @@ const chartOptions = computed(() => ({
animationEasing: 'linear', animationEasing: 'linear',
animationEasingUpdate: 'linear', animationEasingUpdate: 'linear',
grid: { grid: {
top: 18, top: 12,
right: 24, right: 24,
bottom: 22, bottom: 22,
left: 36, left: 36,
@@ -89,7 +210,8 @@ const chartOptions = computed(() => ({
fontSize: 12, fontSize: 12,
fontWeight: 700 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: { xAxis: {
type: 'category', type: 'category',
@@ -106,14 +228,9 @@ const chartOptions = computed(() => ({
yAxis: { yAxis: {
type: 'value', type: 'value',
min: 0, min: 0,
max: Math.ceil(maxValue.value * 1.2), max: Math.ceil(stackedMaxValue.value * 1.18),
splitNumber: 5, splitNumber: 5,
name: isCountMode.value ? '单' : '元', name: '',
nameTextStyle: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
},
axisLabel: { axisLabel: {
color: '#64748b', color: '#64748b',
fontSize: 11, fontSize: 11,
@@ -122,46 +239,7 @@ const chartOptions = computed(() => ({
}, },
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } } splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } }
}, },
series: [ series: chartSeries.value
{
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)
)
}
}
]
})) }))
useEcharts(chartElement, chartOptions) useEcharts(chartElement, chartOptions)
@@ -178,6 +256,134 @@ function toRgba(color, alpha) {
return `rgba(58, 124, 165, ${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) { function formatCurrency(value) {
const number = Number(value || 0) const number = Number(value || 0)
if (number >= 1000000) return `¥${(number / 1000000).toFixed(1)}M` if (number >= 1000000) return `¥${(number / 1000000).toFixed(1)}M`
@@ -200,24 +406,61 @@ function formatAxisCurrency(value) {
flex-direction: column; 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 { .chart-legend {
flex: 1;
min-width: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; flex-wrap: wrap;
gap: 6px 12px;
color: #475569; color: #475569;
font-size: 12px; 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 { .chart-legend i {
flex: 0 0 auto;
display: inline-block; display: inline-block;
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 2px; border-radius: 2px;
margin-right: 4px; margin-right: 5px;
vertical-align: middle; 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 { .chart-body {
flex: 1; flex: 1;
min-height: 0; min-height: 0;

View File

@@ -37,6 +37,10 @@
<slot name="side"></slot> <slot name="side"></slot>
</aside> </aside>
</div> </div>
<section v-if="$slots.bottom" class="detail-bottom">
<slot name="bottom"></slot>
</section>
</template> </template>
</div> </div>

View File

@@ -51,6 +51,7 @@ const emptyFinanceTrend = {
labels: [], labels: [],
claimCount: [], claimCount: [],
claimAmount: [], claimAmount: [],
categoryAmountSeries: [],
applications: [], applications: [],
approved: [], approved: [],
avgHours: [] avgHours: []
@@ -130,6 +131,9 @@ function resolveTopRangeKey(range, customRange = {}) {
if (key === '\u672c\u5468' || key === '\u4eca\u65e5') { if (key === '\u672c\u5468' || key === '\u4eca\u65e5') {
return `recent-${resolveTopRangeDays(key, customRange)}-days` return `recent-${resolveTopRangeDays(key, customRange)}-days`
} }
if (/\d+/.test(key)) {
return `recent-${resolveTopRangeDays(key, customRange)}-days`
}
return key || DEFAULT_OVERVIEW_RANGE return key || DEFAULT_OVERVIEW_RANGE
} }
@@ -155,7 +159,9 @@ export function useOverviewView(options = {}) {
const financeDashboardPayload = ref(null) const financeDashboardPayload = ref(null)
const financeDashboardLoading = ref(false) const financeDashboardLoading = ref(false)
const financeDashboardError = ref(null) const financeDashboardError = ref(null)
const financeDashboardRenderKey = ref(0)
const financeDashboardLoaded = computed(() => Boolean(financeDashboardPayload.value)) const financeDashboardLoaded = computed(() => Boolean(financeDashboardPayload.value))
let financeDashboardRequestSeq = 0
const systemDashboardPayload = ref(null) const systemDashboardPayload = ref(null)
const systemDashboardLoading = ref(false) const systemDashboardLoading = ref(false)
const systemDashboardError = ref(null) const systemDashboardError = ref(null)
@@ -226,16 +232,27 @@ export function useOverviewView(options = {}) {
} }
const loadFinanceDashboard = async () => { const loadFinanceDashboard = async () => {
const requestSeq = ++financeDashboardRequestSeq
financeDashboardLoading.value = true financeDashboardLoading.value = true
financeDashboardError.value = null financeDashboardError.value = null
try { try {
financeDashboardPayload.value = await fetchFinanceDashboard(getFinanceRangeParams()) const payload = await fetchFinanceDashboard(getFinanceRangeParams())
if (requestSeq !== financeDashboardRequestSeq) {
return
}
financeDashboardPayload.value = payload
financeDashboardRenderKey.value += 1
} catch (error) { } catch (error) {
if (requestSeq !== financeDashboardRequestSeq) {
return
}
financeDashboardPayload.value = null financeDashboardPayload.value = null
financeDashboardError.value = error financeDashboardError.value = error
} finally { } finally {
financeDashboardLoading.value = false if (requestSeq === financeDashboardRequestSeq) {
financeDashboardLoading.value = false
}
} }
} }
@@ -889,6 +906,7 @@ export function useOverviewView(options = {}) {
financeDashboardError, financeDashboardError,
financeDashboardLoaded, financeDashboardLoaded,
financeDashboardLoading, financeDashboardLoading,
financeDashboardRenderKey,
formatCompact, formatCompact,
formatCurrency, formatCurrency,
formatMetricValue, formatMetricValue,

View File

@@ -1,7 +1,7 @@
import { computed, reactive, ref } from 'vue' import { computed, reactive, ref } from 'vue'
import { fetchExpenseClaims } from '../services/reimbursements.js' import { fetchExpenseClaims } from '../services/reimbursements.js'
import { filterActionableRiskFlags } from '../utils/riskFlags.js' import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../utils/riskFlags.js'
const EXPENSE_TYPE_LABELS = { const EXPENSE_TYPE_LABELS = {
travel: '差旅费', travel: '差旅费',
@@ -429,13 +429,47 @@ function stringifyRiskFlag(value) {
return '' 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) { if (!Array.isArray(riskFlags) || !riskFlags.length) {
return '无' return { summary: '无', tone: 'low', label: '无' }
} }
const items = filterActionableRiskFlags(riskFlags).map((item) => stringifyRiskFlag(item)).filter(Boolean) const actionableFlags = filterActionableRiskFlags(riskFlags)
return items.length ? items.join('') : '无' 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) { 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)) { if (!Array.isArray(claim?.items)) {
return [] 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 visibleItems = filterVisibleExpenseRawItems(claim.items, claim)
const sortedItems = [...visibleItems].sort((left, right) => { const sortedItems = [...visibleItems].sort((left, right) => {
const leftType = normalizeExpenseType(left?.item_type) const leftType = normalizeExpenseType(left?.item_type)
@@ -1241,6 +1283,7 @@ function buildExpenseItems(claim, riskSummary) {
const itemTypeLabel = resolveTypeLabel(itemType) const itemTypeLabel = resolveTypeLabel(itemType)
const itemLocation = String(item?.item_location || '').trim() const itemLocation = String(item?.item_location || '').trim()
const itemReason = String(item?.item_reason || '').trim() const itemReason = String(item?.item_reason || '').trim()
const itemNote = String(item?.item_note || item?.itemNote || '').trim()
const itemAmount = parseNumber(item?.item_amount) const itemAmount = parseNumber(item?.item_amount)
const itemAmountDisplay = itemAmount > 0 ? formatAmount(itemAmount) : '待补充' const itemAmountDisplay = itemAmount > 0 ? formatAmount(itemAmount) : '待补充'
@@ -1252,6 +1295,7 @@ function buildExpenseItems(claim, riskSummary) {
itemType, itemType,
itemReason, itemReason,
itemLocation, itemLocation,
itemNote,
itemAmount, itemAmount,
invoiceId, invoiceId,
isSystemGenerated, isSystemGenerated,
@@ -1273,9 +1317,9 @@ function buildExpenseItems(claim, riskSummary) {
attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachments[0] : '仅支持上传 1 张 JPG、PNG、PDF 单据', attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachments[0] : '仅支持上传 1 张 JPG、PNG、PDF 单据',
attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing', attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing',
attachments, attachments,
riskLabel: riskSummary === '无' ? '无' : '待关注', riskLabel: normalizedRiskMeta.summary === '无' ? '无' : normalizedRiskMeta.label,
riskText: riskSummary === '无' ? '' : riskSummary, riskText: normalizedRiskMeta.summary === '无' ? '' : normalizedRiskMeta.summary,
riskTone: riskSummary === '无' ? 'low' : 'medium' riskTone: normalizedRiskMeta.summary === '无' ? 'low' : normalizedRiskMeta.tone
} }
}) })
} }
@@ -1288,9 +1332,10 @@ export function mapExpenseClaimToRequest(claim) {
const approvalMeta = resolveApprovalMeta(claim?.status) const approvalMeta = resolveApprovalMeta(claim?.status)
const workflowNode = resolveWorkflowNode(claim, approvalMeta, isApplicationDocument) const workflowNode = resolveWorkflowNode(claim, approvalMeta, isApplicationDocument)
const invoiceCount = Math.max(0, parseNumber(claim?.invoice_count)) 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 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 visibleExpenseAmount = expenseItems.reduce((sum, item) => sum + parseNumber(item.itemAmount), 0)
const amountValue = relatedApplication const amountValue = relatedApplication
? expenseItems.length ? expenseItems.length
@@ -1340,6 +1385,8 @@ export function mapExpenseClaimToRequest(claim) {
updatedAt: claim?.updated_at || '', updatedAt: claim?.updated_at || '',
amount: amountValue, amount: amountValue,
riskFlags: Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : [], riskFlags: Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : [],
riskTone: riskMeta.tone,
riskLabel: riskMeta.label,
invoiceCount, invoiceCount,
workflowNode, workflowNode,
approvalKey: approvalMeta.key, approvalKey: approvalMeta.key,

View File

@@ -231,6 +231,7 @@
<script setup> <script setup>
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import EnterprisePagination from '../components/shared/EnterprisePagination.vue' import EnterprisePagination from '../components/shared/EnterprisePagination.vue'
import TableEmptyState from '../components/shared/TableEmptyState.vue' import TableEmptyState from '../components/shared/TableEmptyState.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue' import TableLoadingState from '../components/shared/TableLoadingState.vue'
@@ -252,6 +253,17 @@ const DOCUMENT_SCOPE_REVIEW = '审核单'
const DOCUMENT_SCOPE_ARCHIVE = '归档' const DOCUMENT_SCOPE_ARCHIVE = '归档'
const scopeTabs = [DOCUMENT_SCOPE_ALL, DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT, DOCUMENT_SCOPE_REVIEW, 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_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 statusTabs = ['全部', '草稿', '待提交', '审批中', '待补充', '待付款', '已完成']
const FILTER_CONFIG_BY_SCOPE = { const FILTER_CONFIG_BY_SCOPE = {
[DOCUMENT_SCOPE_ALL]: { [DOCUMENT_SCOPE_ALL]: {
@@ -296,11 +308,14 @@ const FILTER_CONFIG_BY_SCOPE = {
} }
} }
const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size })) const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
const pageSizeValues = pageSizeOptions.map((item) => item.value)
const documentTypeOptions = [ const documentTypeOptions = [
{ value: DOCUMENT_TYPE_ALL, label: '单据类型' }, { value: DOCUMENT_TYPE_ALL, label: '单据类型' },
{ value: DOCUMENT_TYPE_APPLICATION, label: '申请单' }, { value: DOCUMENT_TYPE_APPLICATION, label: '申请单' },
{ value: DOCUMENT_TYPE_REIMBURSEMENT, label: '报销单' } { value: DOCUMENT_TYPE_REIMBURSEMENT, label: '报销单' }
] ]
const route = useRoute()
const router = useRouter()
const props = defineProps({ const props = defineProps({
filteredRequests: { type: Array, required: true }, filteredRequests: { type: Array, required: true },
hasData: { type: Boolean, default: false }, hasData: { type: Boolean, default: false },
@@ -315,19 +330,91 @@ const emit = defineEmits([
'reload', 'reload',
'summary-change' 'summary-change'
]) ])
const activeScopeTab = ref(readDocumentScope(DOCUMENT_SCOPE_ALL, scopeTabs))
const activeStatusTab = ref('全部') function readDocumentCenterQueryText(key) {
const activeDocumentType = ref(DOCUMENT_TYPE_ALL) const value = route.query?.[key]
const activeScene = ref(SCENE_ALL) 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 openFilterKey = ref('')
const listKeyword = ref('') const listKeyword = ref(readDocumentCenterQueryText('dc_q'))
const datePopover = ref(false) const datePopover = ref(false)
const rangeStart = ref('') const rangeStart = ref(initialAppliedStart)
const rangeEnd = ref('') const rangeEnd = ref(initialAppliedEnd)
const appliedStart = ref('') const appliedStart = ref(initialAppliedStart)
const appliedEnd = ref('') const appliedEnd = ref(initialAppliedEnd)
const currentPage = ref(1) const currentPage = ref(readDocumentCenterQueryNumber('dc_page', 1))
const pageSize = ref(20) const pageSize = ref(resolveInitialPageSize())
const archiveRows = ref([]) const archiveRows = ref([])
const approvalRows = ref([]) const approvalRows = ref([])
const supportingLoading = ref(false) 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, () => { watch(activeFilterConfig, () => {
openFilterKey.value = '' openFilterKey.value = ''
datePopover.value = false datePopover.value = false

View File

@@ -39,10 +39,12 @@
</div> </div>
<TrendChart <TrendChart
:key="`finance-amount-${financeDashboardRenderKey}`"
mode="amount" mode="amount"
:labels="activeTrend.labels" :labels="activeTrend.labels"
:claim-count="activeTrend.claimCount" :claim-count="activeTrend.claimCount"
:claim-amount="activeTrend.claimAmount" :claim-amount="activeTrend.claimAmount"
:category-amount-series="activeTrend.categoryAmountSeries"
/> />
</article> </article>
@@ -52,6 +54,7 @@
</div> </div>
<TrendChart <TrendChart
:key="`finance-count-${financeDashboardRenderKey}`"
mode="count" mode="count"
:labels="activeTrend.labels" :labels="activeTrend.labels"
:claim-count="activeTrend.claimCount" :claim-count="activeTrend.claimCount"
@@ -362,13 +365,12 @@ const {
digitalEmployeeCategoryRows, digitalEmployeeCategoryRows,
digitalEmployeeDashboard, digitalEmployeeDashboard,
digitalEmployeeDashboardError, digitalEmployeeDashboardError,
digitalEmployeeDashboardLoaded,
digitalEmployeeDashboardLoading, digitalEmployeeDashboardLoading,
digitalEmployeeDailyRows, digitalEmployeeDailyRows,
digitalEmployeeKpiMetrics, digitalEmployeeKpiMetrics,
digitalEmployeeTaskRanking, digitalEmployeeTaskRanking,
financeDashboardLoading, financeDashboardLoading,
financeDashboardLoaded, financeDashboardRenderKey,
kpiMetrics, kpiMetrics,
rankedDepartments, rankedDepartments,
rankedEmployees, rankedEmployees,
@@ -385,7 +387,6 @@ const {
spendCenterValue, spendCenterValue,
spendLegend, spendLegend,
systemDashboardLoading, systemDashboardLoading,
systemDashboardLoaded,
systemAccuracyComparison, systemAccuracyComparison,
systemAgentDailyRatio, systemAgentDailyRatio,
systemFeedbackSummary, systemFeedbackSummary,
@@ -413,15 +414,15 @@ const activeKpiMetrics = computed(() => {
}) })
const activeDashboardLoading = computed(() => { const activeDashboardLoading = computed(() => {
if (activeDashboard.value === 'system') { if (activeDashboard.value === 'system') {
return systemDashboardLoading.value && !systemDashboardLoaded.value return systemDashboardLoading.value
} }
if (activeDashboard.value === 'digitalEmployee') { if (activeDashboard.value === 'digitalEmployee') {
return digitalEmployeeDashboardLoading.value && !digitalEmployeeDashboardLoaded.value return digitalEmployeeDashboardLoading.value
} }
if (activeDashboard.value === 'risk') { if (activeDashboard.value === 'risk') {
return riskDashboardLoading.value && !riskDashboardLoaded.value return riskDashboardLoading.value && !riskDashboardLoaded.value
} }
return financeDashboardLoading.value && !financeDashboardLoaded.value return financeDashboardLoading.value
}) })
const activeDashboardLoadingText = computed(() => { const activeDashboardLoadingText = computed(() => {
if (activeDashboard.value === 'system') return '正在加载系统看板数据' if (activeDashboard.value === 'system') return '正在加载系统看板数据'

View File

@@ -1,6 +1,6 @@
<template> <template>
<section class="receipt-folder-page"> <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="票据关联状态"> <nav class="status-tabs receipt-status-tabs" aria-label="票据关联状态">
<button <button
v-for="tab in receiptTabs" v-for="tab in receiptTabs"
@@ -134,36 +134,8 @@
loading-icon="mdi mdi-receipt-text-outline" loading-icon="mdi mdi-receipt-text-outline"
@back="backToList" @back="backToList"
> >
<section class="receipt-detail-toolbar panel"> <template #main>
<div class="receipt-detail-title"> <EnterpriseDetailCard class="receipt-preview-panel" 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="票据预览">
<div class="receipt-preview-frame"> <div class="receipt-preview-frame">
<div class="receipt-preview-box"> <div class="receipt-preview-box">
<img <img
@@ -172,7 +144,7 @@
:style="{ transform: previewTransform }" :style="{ transform: previewTransform }"
alt="票据预览" 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"> <div v-else class="preview-empty">
<i class="mdi mdi-file-eye-outline"></i> <i class="mdi mdi-file-eye-outline"></i>
<strong>当前文件暂不支持内嵌预览</strong> <strong>当前文件暂不支持内嵌预览</strong>
@@ -201,115 +173,110 @@
</button> </button>
</div> </div>
</footer> </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> </EnterpriseDetailCard>
</template>
<div class="receipt-dashboard-side"> <template #side>
<EnterpriseDetailCard class="receipt-basic-panel" title="基础信息"> <EnterpriseDetailCard class="receipt-ticket-info-panel" title="识别票据详情">
<template #actions> <template #actions>
<span class="receipt-card-count">{{ keyReceiptFields.length }} 项可编辑</span> <div class="receipt-card-actions">
</template> <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"> <div class="receipt-static-grid">
<label v-for="field in keyReceiptFields" :key="field.id" class="receipt-key-field"> <div v-for="item in basicInfoItems" :key="item.label" class="receipt-static-item">
<span>{{ field.label }}</span> <span>{{ item.label }}</span>
<input <strong>{{ item.value }}</strong>
:value="field.value" </div>
type="text" </div>
:placeholder="field.placeholder"
@input="updateReceiptField(field, $event.target.value)" <section class="receipt-ticket-section">
/> <div class="receipt-section-head">
</label> <strong>识别字段</strong>
<small>{{ detailForm.fields.length }} </small>
</div> </div>
<div class="receipt-static-grid"> <div v-if="detailForm.fields.length" class="receipt-all-field-grid" :class="{ editing: receiptInfoEditing }">
<div v-for="item in basicInfoItems" :key="item.label" class="receipt-static-item"> <label v-for="field in detailForm.fields" :key="field.key || field.label" class="receipt-ocr-field">
<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">
<span>{{ field.label || field.key }}</span> <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> </label>
</div> </div>
<div v-else class="receipt-field-empty"> <div v-else class="receipt-field-empty">
<i class="mdi mdi-information-outline"></i> <i class="mdi mdi-information-outline"></i>
<span>暂无可展示的 OCR 识别字段</span> <span>暂无可展示的 OCR 识别字段</span>
</div> </div>
</section>
</EnterpriseDetailCard>
</template>
<ElCollapse v-model="expandedFieldPanels" class="receipt-other-collapse"> <template #bottom>
<ElCollapseItem name="other"> <EnterpriseDetailCard class="receipt-association-panel" title="关联信息">
<template #title> <template #actions>
<div class="receipt-collapse-title"> <button
<strong>其他信息</strong> v-if="selectedReceipt?.status !== 'linked'"
<small>{{ editableOtherFields.length }} </small> class="minor-action"
</div> type="button"
</template> @click="openAssociateDialogForCurrentReceipt"
>
<i class="mdi mdi-link-variant-plus"></i>
<span>关联单据</span>
</button>
</template>
<div v-if="editableOtherFields.length" class="receipt-other-scroll"> <div class="receipt-data-list association">
<div <div v-for="item in linkedClaimItems" :key="item.label" class="receipt-data-item">
v-for="(field, index) in editableOtherFields" <span>{{ item.label }}</span>
:key="`${field.key || field.label}-${index}`" <strong>{{ item.value }}</strong>
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> </div>
</EnterpriseDetailCard> </div>
</div> </EnterpriseDetailCard>
</template>
<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>
<template #actions> <template #actions>
<button class="minor-action danger-action" type="button" :disabled="deleting" @click="deleteCurrentReceipt"> <button class="minor-action danger-action" type="button" :disabled="deleting" @click="deleteCurrentReceipt">
@@ -380,7 +347,6 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElCheckbox, ElCheckboxGroup } from 'element-plus/es/components/checkbox/index.mjs' 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 { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
import EnterprisePagination from '../components/shared/EnterprisePagination.vue' import EnterprisePagination from '../components/shared/EnterprisePagination.vue'
@@ -416,13 +382,13 @@ const detailLoading = ref(false)
const savingDetail = ref(false) const savingDetail = ref(false)
const deleting = ref(false) const deleting = ref(false)
const previewObjectUrl = ref('') const previewObjectUrl = ref('')
const receiptInfoEditing = ref(false)
const associateDialogOpen = ref(false) const associateDialogOpen = ref(false)
const associateStep = ref(1) const associateStep = ref(1)
const selectedReceiptIds = ref([]) const selectedReceiptIds = ref([])
const targetDraftId = ref(NEW_CLAIM_VALUE) const targetDraftId = ref(NEW_CLAIM_VALUE)
const draftClaims = ref([]) const draftClaims = ref([])
const associateBusy = ref(false) const associateBusy = ref(false)
const expandedFieldPanels = ref([])
const detailForm = reactive({ const detailForm = reactive({
file_name: '', file_name: '',
@@ -514,28 +480,20 @@ const isTrainTicket = computed(() => {
}) })
const { const {
buildDetailPayload, buildDetailPayload,
editableOtherFields,
ensureEditableReceiptFields, ensureEditableReceiptFields,
keyReceiptFields, syncEditableFieldsToTopLevel
syncEditableFieldsToTopLevel,
updateReceiptField
} = createReceiptDetailFieldModel({ detailForm, isTrainTicket }) } = createReceiptDetailFieldModel({ detailForm, isTrainTicket })
const { const {
adjustPreviewZoom, adjustPreviewZoom,
archiveInfoItems,
basicInfoItems, basicInfoItems,
linkedClaimItems, linkedClaimItems,
ocrPreviewFields,
operationLogs,
previewPageLabel, previewPageLabel,
previewTransform, previewTransform,
previewZoom, previewZoom,
receiptStatusItems,
resetPreviewView, resetPreviewView,
rotatePreview rotatePreview
} = createReceiptDetailDashboardModel({ } = createReceiptDetailDashboardModel({
detailForm, detailForm,
editableOtherFields,
formatDateTime, formatDateTime,
formatScore, formatScore,
selectedReceipt selectedReceipt
@@ -554,6 +512,15 @@ const receiptDetailTopBarPayload = computed(() => (
: null : null
)) ))
const previewKind = computed(() => selectedReceipt.value?.preview_kind || '') 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(() => ( const canProceedAssociate = computed(() => (
associateStep.value === 1 associateStep.value === 1
? selectedReceiptIds.value.length > 0 ? selectedReceiptIds.value.length > 0
@@ -635,7 +602,7 @@ function fillDetailForm(detail) {
detailForm.fields = Array.isArray(detail.fields) detailForm.fields = Array.isArray(detail.fields)
? detail.fields.map((field) => ({ ...field })) ? detail.fields.map((field) => ({ ...field }))
: [] : []
expandedFieldPanels.value = [] receiptInfoEditing.value = false
resetPreviewView() resetPreviewView()
ensureEditableReceiptFields() ensureEditableReceiptFields()
syncEditableFieldsToTopLevel() syncEditableFieldsToTopLevel()
@@ -660,6 +627,7 @@ function revokePreviewUrl() {
function backToList() { function backToList() {
selectedReceipt.value = null selectedReceipt.value = null
receiptInfoEditing.value = false
revokePreviewUrl() revokePreviewUrl()
} }
@@ -668,6 +636,17 @@ async function reloadCurrentReceipt() {
await openDetail(selectedReceipt.value) await openDetail(selectedReceipt.value)
} }
function startReceiptInfoEdit() {
receiptInfoEditing.value = true
}
function cancelReceiptInfoEdit() {
if (selectedReceipt.value) {
fillDetailForm(selectedReceipt.value)
}
receiptInfoEditing.value = false
}
async function saveDetail() { async function saveDetail() {
if (!selectedReceipt.value?.id || savingDetail.value) return if (!selectedReceipt.value?.id || savingDetail.value) return
savingDetail.value = true savingDetail.value = true
@@ -776,6 +755,7 @@ function formatScore(value) {
} }
function formatDateTime(value) { function formatDateTime(value) {
if (!String(value ?? '').trim()) return '待确认'
const date = new Date(value) const date = new Date(value)
if (Number.isNaN(date.getTime())) return '待确认' 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')}` 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')}`

View File

@@ -126,9 +126,9 @@
</p> </p>
</div> </div>
<div v-if="!isApplicationDocument" class="detail-card-actions"> <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> <i class="mdi mdi-robot-outline"></i>
<span>智能录入</span> <span>{{ uploadingExpenseId ? '识别中' : '智能录入' }}</span>
</button> </button>
<button <button
v-if="isEditableRequest" v-if="isEditableRequest"
@@ -190,6 +190,10 @@
</div> </div>
</div> </div>
<div v-if="!isApplicationDocument" class="detail-expense-table"> <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> <table>
<thead> <thead>
<tr> <tr>
@@ -199,6 +203,7 @@
<th class="col-desc">说明</th> <th class="col-desc">说明</th>
<th class="col-amount">金额</th> <th class="col-amount">金额</th>
<th class="col-attachment">附件材料</th> <th class="col-attachment">附件材料</th>
<th class="col-risk-note">异常说明</th>
<th v-if="isEditableRequest" class="col-action">操作</th> <th v-if="isEditableRequest" class="col-action">操作</th>
</tr> </tr>
</thead> </thead>
@@ -209,13 +214,17 @@
<strong>{{ item.filledAt }}</strong> <strong>{{ item.filledAt }}</strong>
<span>条款填写时间</span> <span>条款填写时间</span>
</td> </td>
<td :class="['expense-time col-time', { 'has-major-risk': isMajorExpenseRisk(item) }]"> <td :class="['expense-time col-time', { 'has-major-risk': hasExpenseRiskIndicator(item) }]">
<i <button
v-if="isMajorExpenseRisk(item)" v-if="hasExpenseRiskIndicator(item)"
class="mdi mdi-alert expense-risk-indicator" class="expense-risk-indicator"
type="button"
:title="resolveExpenseRiskIndicatorTitle(item)" :title="resolveExpenseRiskIndicatorTitle(item)"
:aria-label="resolveExpenseRiskIndicatorTitle(item)" :aria-label="resolveExpenseRiskIndicatorTitle(item)"
></i> @click="focusExpenseRisk(item)"
>
<i class="mdi mdi-alert"></i>
</button>
<template v-if="editingExpenseId === item.id"> <template v-if="editingExpenseId === item.id">
<div class="cell-editor"> <div class="cell-editor">
<input v-model="expenseEditor.itemDate" class="editor-input" type="date" /> <input v-model="expenseEditor.itemDate" class="editor-input" type="date" />
@@ -281,6 +290,10 @@
<td class="expense-attachment col-attachment"> <td class="expense-attachment col-attachment">
<template v-if="editingExpenseId === item.id"> <template v-if="editingExpenseId === item.id">
<div class="cell-editor editor-stack"> <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"> <div class="attachment-action-group">
<button <button
v-if="isEditableRequest && !item.invoiceId && !item.isSystemGenerated" v-if="isEditableRequest && !item.invoiceId && !item.isSystemGenerated"
@@ -318,7 +331,11 @@
</div> </div>
</template> </template>
<template v-else> <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> <i class="mdi mdi-calculator-variant-outline"></i>
<span>无需附件</span> <span>无需附件</span>
</div> </div>
@@ -358,6 +375,24 @@
</div> </div>
</template> </template>
</td> </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"> <td v-if="isEditableRequest" class="expense-action-cell col-action">
<div v-if="item.isSystemGenerated" class="system-row-lock"> <div v-if="item.isSystemGenerated" class="system-row-lock">
<i class="mdi mdi-lock-outline"></i> <i class="mdi mdi-lock-outline"></i>
@@ -438,7 +473,9 @@
<article <article
v-for="card in section.items" v-for="card in section.items"
:key="card.id" :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-main">
<div class="risk-advice-card-head"> <div class="risk-advice-card-head">
@@ -545,6 +582,58 @@
accept="image/*,.pdf" accept="image/*,.pdf"
@change="handleExpenseFileChange" @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>支持 JPGPNGPDF确认后系统会逐张识别并归集到草稿明细</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"> <Transition name="shared-confirm">
<div <div
v-if="attachmentPreviewOpen" v-if="attachmentPreviewOpen"

View File

@@ -1606,6 +1606,24 @@ export default {
if (await handleGuidedSuggestedAction(message, action)) return if (await handleGuidedSuggestedAction(message, action)) return
if (await handleSceneSelectionApplicationGate(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 (actionType === TRAVEL_PLANNING_ACTION_GENERATE) {
if (!lockSuggestedActionMessage(message, action)) return if (!lockSuggestedActionMessage(message, action)) return
const sourcePreview = action?.payload?.applicationPreview || action?.payload?.preview || null const sourcePreview = action?.payload?.applicationPreview || action?.payload?.preview || null

View File

@@ -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 { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js' import { useToast } from '../../composables/useToast.js'
@@ -94,6 +94,223 @@ import {
} from './travelRequestDetailAdviceModel.js' } from './travelRequestDetailAdviceModel.js'
import { useTravelRequestPaymentFlow } from './travelRequestDetailPaymentFlow.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。 * 运行时实现位于 travelRequestDetailExpenseModel.js。
@@ -388,6 +605,8 @@ export default {
const riskOverrideDialogOpen = ref(false) const riskOverrideDialogOpen = ref(false)
const riskOverrideBusy = ref(false) const riskOverrideBusy = ref(false)
const riskOverrideIndex = ref(0) const riskOverrideIndex = ref(0)
const highlightedRiskCardId = ref('')
let highlightedRiskCardTimer = 0
const riskOverrideReasons = reactive({}) const riskOverrideReasons = reactive({})
const deleteBusy = ref(false) const deleteBusy = ref(false)
const deleteDialogOpen = ref(false) const deleteDialogOpen = ref(false)
@@ -397,6 +616,16 @@ export default {
const approveConfirmDialogOpen = ref(false) const approveConfirmDialogOpen = ref(false)
const leaderOpinion = ref('') const leaderOpinion = ref('')
const expenseUploadInput = ref(null) 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 expenseAttachmentMeta = reactive({})
const attachmentPreviewOpen = ref(false) const attachmentPreviewOpen = ref(false)
const attachmentPreviewLoading = ref(false) const attachmentPreviewLoading = ref(false)
@@ -411,6 +640,7 @@ export default {
itemReason: '', itemReason: '',
itemLocation: '', itemLocation: '',
itemAmount: '', itemAmount: '',
itemNote: '',
invoiceId: '' invoiceId: ''
}) })
const detailNoteEditor = ref('') const detailNoteEditor = ref('')
@@ -669,6 +899,7 @@ export default {
|| approveBusy.value || approveBusy.value
|| payBusy.value || payBusy.value
|| creatingExpense.value || creatingExpense.value
|| smartEntryRecognitionBusy.value
|| Boolean(uploadingExpenseId.value) || Boolean(uploadingExpenseId.value)
|| Boolean(deletingAttachmentId.value) || Boolean(deletingAttachmentId.value)
|| Boolean(deletingExpenseId.value) || Boolean(deletingExpenseId.value)
@@ -773,7 +1004,7 @@ export default {
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length) const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
const expenseTableColumnCount = computed( const expenseTableColumnCount = computed(
() => 6 + (isEditableRequest.value ? 1 : 0) () => 7 + (isEditableRequest.value ? 1 : 0)
) )
const canEditDetailNote = computed(() => isDraftRequest.value) const canEditDetailNote = computed(() => isDraftRequest.value)
const stripDetailNoteRiskTags = (value) => const stripDetailNoteRiskTags = (value) =>
@@ -821,12 +1052,42 @@ export default {
() => request.value.claimId, () => request.value.claimId,
() => { () => {
riskFlagPreviewSnapshot.value = null riskFlagPreviewSnapshot.value = null
} appliedSmartEntryRecognitionPayloadIds.clear()
bindSmartEntryRecognitionTask()
},
{ immediate: true }
) )
const draftBlockingIssues = computed(() => const draftBlockingIssues = computed(() =>
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : [] isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
) )
const canSubmit = computed(() => isEditableRequest.value && !actionBusy.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(() => const attachmentPreviewEntries = computed(() =>
expenseItems.value expenseItems.value
.filter((item) => canPreviewAttachment(item)) .filter((item) => canPreviewAttachment(item))
@@ -929,6 +1190,102 @@ export default {
return `${label}${summary}` 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) { async function refreshExpenseAttachmentMeta(itemId) {
if (!request.value.claimId || !itemId) { if (!request.value.claimId || !itemId) {
return null return null
@@ -1048,10 +1405,19 @@ export default {
return normalizeRiskTone(resolveExpenseRiskState(item)?.tone) === 'high' 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) { function resolveExpenseRiskIndicatorTitle(item) {
const state = resolveExpenseRiskState(item) const state = resolveExpenseRiskState(item)
const summary = String(state?.summary || state?.headline || '').trim() const summary = String(state?.summary || state?.headline || '').trim()
return summary ? `重大风险示:${summary}` : '重大风险示' return summary ? `查看风险示:${summary}` : '查看风险示'
} }
function applyClaimRiskFlagsPayload(payload) { function applyClaimRiskFlagsPayload(payload) {
@@ -1198,6 +1564,62 @@ export default {
|| (!isEditableRequest.value && canViewApprovalRiskAdvice.value && aiAdvice.value.riskCards.length > 0) || (!isEditableRequest.value && canViewApprovalRiskAdvice.value && aiAdvice.value.riskCards.length > 0)
|| (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value && hasVisibleRiskCards.value) || (!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(() => { const aiAdviceTitle = computed(() => {
if (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value) { if (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value) {
return '报销风险提示' return '报销风险提示'
@@ -1375,6 +1797,7 @@ export default {
expenseEditor.itemLocation = expenseEditor.itemLocation =
item.itemLocation || (isSyntheticLocationDisplay(item.detail, item.itemType) ? '' : item.detail) item.itemLocation || (isSyntheticLocationDisplay(item.detail, item.itemType) ? '' : item.detail)
expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : '' expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : ''
expenseEditor.itemNote = item.itemNote || ''
expenseEditor.invoiceId = item.invoiceId || '' expenseEditor.invoiceId = item.invoiceId || ''
} }
@@ -1416,14 +1839,10 @@ export default {
return '' return ''
} }
async function handleAddExpenseItem() { async function createDraftExpenseItem({ openEditor = true } = {}) {
if (!isEditableRequest.value || actionBusy.value) {
return
}
if (!request.value.claimId) { if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法新增费用明细。') toast('当前草稿缺少 claimId暂时无法新增费用明细。')
return return null
} }
creatingExpense.value = true creatingExpense.value = true
@@ -1441,15 +1860,108 @@ export default {
const nextItem = buildExpenseItemViewModel(createdItem, expenseItems.value.length, request.value) const nextItem = buildExpenseItemViewModel(createdItem, expenseItems.value.length, request.value)
expenseItems.value = rebuildExpenseItems([...expenseItems.value, nextItem], request.value) expenseItems.value = rebuildExpenseItems([...expenseItems.value, nextItem], request.value)
creatingExpense.value = false creatingExpense.value = false
startExpenseEdit(nextItem) if (openEditor) {
toast('已新增一条费用明细,请继续填写。') startExpenseEdit(nextItem)
toast('已新增一条费用明细,请继续填写。')
}
return nextItem
} catch (error) { } catch (error) {
toast(error?.message || '新增费用明细失败,请稍后重试。') toast(error?.message || '新增费用明细失败,请稍后重试。')
return null
} finally { } finally {
creatingExpense.value = false 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) { function triggerExpenseUpload(item) {
if (!isEditableRequest.value || actionBusy.value) { if (!isEditableRequest.value || actionBusy.value) {
return return
@@ -1570,31 +2082,7 @@ export default {
const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file) const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file)
applyClaimRiskFlagsPayload(payload) applyClaimRiskFlagsPayload(payload)
expenseAttachmentMeta[item.id] = payload?.attachment || null expenseAttachmentMeta[item.id] = payload?.attachment || null
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount) const itemPatch = buildRecognizedExpenseItemPatch(payload, file.name)
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)
}
applyLocalExpenseItemPatch(item.id, { applyLocalExpenseItemPatch(item.id, {
...itemPatch ...itemPatch
}) })
@@ -1603,8 +2091,10 @@ export default {
emit('request-updated', { claimId: request.value.claimId }) emit('request-updated', { claimId: request.value.claimId })
const riskNotice = buildAttachmentRiskNotice(payload?.attachment) const riskNotice = buildAttachmentRiskNotice(payload?.attachment)
toast(riskNotice || payload?.message || `${file.name} 已关联到当前费用明细。`) toast(riskNotice || payload?.message || `${file.name} 已关联到当前费用明细。`)
return true
} catch (error) { } catch (error) {
toast(error?.message || '附件上传失败,请稍后重试。') toast(error?.message || '附件上传失败,请稍后重试。')
return false
} finally { } finally {
uploadingExpenseId.value = '' uploadingExpenseId.value = ''
} }
@@ -1693,6 +2183,7 @@ export default {
expenseEditor.itemReason = '' expenseEditor.itemReason = ''
expenseEditor.itemLocation = '' expenseEditor.itemLocation = ''
expenseEditor.itemAmount = '' expenseEditor.itemAmount = ''
expenseEditor.itemNote = ''
expenseEditor.invoiceId = '' expenseEditor.invoiceId = ''
} }
if (pendingUploadExpenseId.value === item.id) { if (pendingUploadExpenseId.value === item.id) {
@@ -1736,6 +2227,7 @@ export default {
item_type: expenseEditor.itemType, item_type: expenseEditor.itemType,
item_reason: expenseEditor.itemReason.trim(), item_reason: expenseEditor.itemReason.trim(),
item_location: preservedLocation, item_location: preservedLocation,
item_note: expenseEditor.itemNote.trim(),
item_amount: nextAmount, item_amount: nextAmount,
invoice_id: nextInvoiceId invoice_id: nextInvoiceId
} }
@@ -1748,6 +2240,7 @@ export default {
itemType: expenseEditor.itemType, itemType: expenseEditor.itemType,
itemReason: expenseEditor.itemReason.trim(), itemReason: expenseEditor.itemReason.trim(),
itemLocation: preservedLocation, itemLocation: preservedLocation,
itemNote: expenseEditor.itemNote.trim(),
itemAmount: nextAmount, itemAmount: nextAmount,
invoiceId: nextInvoiceId invoiceId: nextInvoiceId
}) })
@@ -1788,11 +2281,6 @@ export default {
return return
} }
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
openRiskOverrideDialog()
return
}
submitConfirmDialogOpen.value = true submitConfirmDialogOpen.value = true
} }
@@ -1823,12 +2311,6 @@ export default {
return return
} }
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
submitConfirmDialogOpen.value = false
openRiskOverrideDialog()
return
}
submitBusy.value = true submitBusy.value = true
try { try {
const payload = await submitExpenseClaim(request.value.claimId) 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() { function buildApplicationEditPreview() {
const factEntries = applicationDetailFactItems.value const factEntries = applicationDetailFactItems.value
.map((item) => [String(item?.label || '').trim(), String(item?.value || '').trim()]) .map((item) => [String(item?.label || '').trim(), String(item?.value || '').trim()])
@@ -2098,6 +2560,14 @@ export default {
} }
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (highlightedRiskCardTimer) {
window.clearTimeout(highlightedRiskCardTimer)
highlightedRiskCardTimer = 0
}
if (stopSmartEntryRecognitionTask) {
stopSmartEntryRecognitionTask()
stopSmartEntryRecognitionTask = null
}
closeAttachmentPreview() closeAttachmentPreview()
}) })
@@ -2112,9 +2582,10 @@ export default {
canNavigateAttachmentPreview, canNavigateAttachmentPreview,
canModifyReturnedApplication, canOpenAiEntry, canApproveRequest, canPayRequest, canReturnRequest, canSubmit, canPreviewAttachment, canModifyReturnedApplication, canOpenAiEntry, canApproveRequest, canPayRequest, canReturnRequest, canSubmit, canPreviewAttachment,
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog, closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
closeRiskOverrideDialog, closeRiskOverrideDialog, closeSmartEntryUploadDialog,
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest, closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
confirmPayRequest, confirmRiskOverrideReasons, confirmPayRequest, confirmRiskOverrideReasons, confirmSmartEntryUpload,
chooseSmartEntryFile, clearSmartEntryFile,
currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion, currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion,
currentSubmitRiskWarning, currentSubmitRiskWarning,
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen, canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
@@ -2123,20 +2594,27 @@ export default {
expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput, expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS, expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
goToNextSubmitRisk, goToPreviousSubmitRisk, goToNextSubmitRisk, goToPreviousSubmitRisk,
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange, focusExpenseRisk,
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange, handleSmartEntryFileChange,
handleModifyApplication, handleModifyApplication,
handlePayRequest, handlePayRequest,
handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest, handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest,
isMajorExpenseRisk, isMajorExpenseRisk,
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview, hasExpenseRiskIndicator,
hasExpenseRiskOrAbnormal,
triggerSmartEntryUpload, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem, payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta, hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta,
resolveExpenseRiskIndicatorTitle, resolveExpenseRiskIndicatorTitle,
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition, resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues, resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
resolveRiskCardDomId, isHighlightedRiskCard,
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel, returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
requiresApprovalOpinion, requiresApprovalOpinion,
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId, riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
smartEntrySelectedFileCount, smartEntrySelectedFileNames, smartEntrySelectedFileSummary,
smartEntryRecognitionBusy, smartEntryRecognitionText,
smartEntryUploadBusy, smartEntryUploadDialogOpen, smartEntryUploadInput,
showAiAdvicePanel, showApplicationLeaderOpinion, showAiAdvicePanel, showApplicationLeaderOpinion,
showBudgetAnalysis, showStageRiskAdvice, showBudgetAnalysis, showStageRiskAdvice,
showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy, showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy,

View File

@@ -2,7 +2,6 @@ import { computed, ref } from 'vue'
export function createReceiptDetailDashboardModel({ export function createReceiptDetailDashboardModel({
detailForm, detailForm,
editableOtherFields,
formatDateTime, formatDateTime,
formatScore, formatScore,
selectedReceipt selectedReceipt
@@ -14,74 +13,29 @@ export function createReceiptDetailDashboardModel({
const pageCount = Number(selectedReceipt.value?.page_count || 1) const pageCount = Number(selectedReceipt.value?.page_count || 1)
return `1 / ${Number.isFinite(pageCount) && pageCount > 0 ? pageCount : 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(() => [ const basicInfoItems = computed(() => [
{ label: '票据类型', value: fallback(detailForm.document_type_label) }, { label: '票据类型', value: fallback(detailForm.document_type_label) },
{ label: '票据名称', value: fallback(detailForm.file_name) }, { 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: fallback(selectedReceipt.value?.owner_name || selectedReceipt.value?.owner || '当前用户') },
{ label: '上传时间', value: formatDateTime(selectedReceipt.value?.uploaded_at) }, { label: '上传时间', value: formatDateTime(selectedReceipt.value?.uploaded_at) },
{ label: '所属单据编号', value: fallback(selectedReceipt.value?.linked_claim_no, '未关联') }, { label: '所属单据编号', value: fallback(selectedReceipt.value?.linked_claim_no, '未关联') },
{ label: 'OCR 置信度', value: formatScore(selectedReceipt.value?.avg_score) } { 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(() => [ const linkedClaimItems = computed(() => [
{ label: '关联状态', value: selectedReceipt.value?.status === 'linked' ? '已关联' : '未关联' },
{ label: '报销单编号', value: fallback(selectedReceipt.value?.linked_claim_no, '未关联') }, { label: '报销单编号', value: fallback(selectedReceipt.value?.linked_claim_no, '未关联') },
{ label: '报销单名称', value: linkedClaimName.value }, { label: '报销单名称', value: linkedClaimName.value },
{ label: '费用类型', value: fallback(detailForm.scene_label) }, { label: '费用类型', value: fallback(detailForm.scene_label) },
{ label: '申请日期', value: dateOnly(selectedReceipt.value?.linked_at || selectedReceipt.value?.uploaded_at) }, { label: '关联时间', value: formatDateTime(selectedReceipt.value?.linked_at) },
{ label: '审批状态', value: selectedReceipt.value?.status === 'linked' ? '已关联' : '待关联' }, { label: '关联附件数量', value: selectedReceipt.value?.status === 'linked' ? '1' : '0' }
{ 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, '待统计') }
]) ])
const linkedClaimName = computed(() => ( const linkedClaimName = computed(() => (
selectedReceipt.value?.linked_claim_no selectedReceipt.value?.linked_claim_no
? `${fallback(detailForm.scene_label)}票据归集` ? `${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) { function adjustPreviewZoom(delta) {
previewZoom.value = Math.min(1.8, Math.max(0.6, Number((previewZoom.value + delta).toFixed(2)))) previewZoom.value = Math.min(1.8, Math.max(0.6, Number((previewZoom.value + delta).toFixed(2))))
@@ -98,16 +52,12 @@ export function createReceiptDetailDashboardModel({
return { return {
adjustPreviewZoom, adjustPreviewZoom,
archiveInfoItems,
basicInfoItems, basicInfoItems,
linkedClaimItems, linkedClaimItems,
ocrPreviewFields,
operationLogs,
previewPageLabel, previewPageLabel,
previewRotation, previewRotation,
previewTransform, previewTransform,
previewZoom, previewZoom,
receiptStatusItems,
resetPreviewView, resetPreviewView,
rotatePreview rotatePreview
} }
@@ -117,8 +67,3 @@ function fallback(value, empty = '待补充') {
const text = String(value || '').trim() const text = String(value || '').trim()
return text || empty return text || empty
} }
function dateOnly(value) {
const text = String(value || '').trim()
return text ? text.slice(0, 10) : '待确认'
}

View File

@@ -448,18 +448,14 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
].join('\n') ].join('\n')
const reviewFormValues = { const reviewFormValues = {
expense_type: typeLabel, expense_type: typeLabel,
reimbursement_type: typeLabel,
reason: values.reason || applicationReason || values.customer_name || '', reason: values.reason || applicationReason || values.customer_name || '',
reason_value: values.reason || applicationReason || '',
customer_name: values.customer_name || '', customer_name: values.customer_name || '',
participants: values.participants || '', participants: values.participants || '',
location: values.location || applicationLocation || '', location: values.location || applicationLocation || '',
business_location: values.location || applicationLocation || '',
time_range: values.time_range || applicationBusinessTime || '', time_range: values.time_range || applicationBusinessTime || '',
business_time: values.time_range || applicationBusinessTime || '',
transport_mode: values.transport_mode || applicationTransportMode || '', transport_mode: values.transport_mode || applicationTransportMode || '',
amount: linkedApplication ? (values.amount || '') : (values.amount || applicationAmount || ''), 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_id: values.application_claim_id || '',
application_claim_no: values.application_claim_no || '', application_claim_no: values.application_claim_no || '',
application_reason: values.application_reason || '', application_reason: values.application_reason || '',

View File

@@ -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 = {}) { export function buildBusinessTimeContextFromReviewValues(values = {}) {
const timeText = String(values.time_range || values.business_time || values.occurred_date || '').trim() const timeText = String(values.time_range || values.business_time || values.occurred_date || '').trim()
if (!timeText) { if (!timeText) {
@@ -113,12 +175,12 @@ export function buildReviewFormContextFromPayload(reviewPayload, inlineState = n
).trim() ).trim()
if (inheritedTimeRange) { if (inheritedTimeRange) {
values.time_range = values.time_range || 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 { return {
review_form_values: values, review_form_values: ontologyValues,
...(businessTimeContext ? { business_time_context: businessTimeContext } : {}) ...(businessTimeContext ? { business_time_context: businessTimeContext } : {})
} }
} }

View File

@@ -401,6 +401,7 @@ export function buildExpenseItemViewModel(source, index, requestModel, travelTim
const id = resolveExpenseItemViewId(source, index, requestModel) const id = resolveExpenseItemViewId(source, index, requestModel)
const itemReason = String(source?.itemReason ?? source?.item_reason ?? '').trim() const itemReason = String(source?.itemReason ?? source?.item_reason ?? '').trim()
const itemLocation = String(source?.itemLocation ?? source?.item_location ?? '').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 itemDate = normalizeIsoDateValue(source?.itemDate ?? source?.item_date)
const itemAmount = parseCurrency(source?.itemAmount ?? source?.item_amount) const itemAmount = parseCurrency(source?.itemAmount ?? source?.item_amount)
const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim() const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim()
@@ -421,6 +422,7 @@ export function buildExpenseItemViewModel(source, index, requestModel, travelTim
itemType, itemType,
itemReason, itemReason,
itemLocation, itemLocation,
itemNote,
itemAmount, itemAmount,
invoiceId, invoiceId,
isSystemGenerated, isSystemGenerated,

View File

@@ -442,6 +442,9 @@ function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analy
return withRiskTags({ return withRiskTags({
id: `${normalizeText(item?.id) || `expense-${index}`}-${pointIndex}`, id: `${normalizeText(item?.id) || `expense-${index}`}-${pointIndex}`,
itemId: normalizeId(item?.id),
itemIndex: index + 1,
invoiceId: normalizeText(item?.invoiceId),
businessStage: normalizeBusinessStage(businessStage) || 'reimbursement', businessStage: normalizeBusinessStage(businessStage) || 'reimbursement',
tone, tone,
label: resolveRiskLevelLabel(tone), label: resolveRiskLevelLabel(tone),
@@ -631,6 +634,9 @@ export function buildAttachmentRiskCards({
return risks.map((risk, pointIndex) => withRiskTags({ return risks.map((risk, pointIndex) => withRiskTags({
id: `claim-risk-${index}-${pointIndex}`, 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), businessStage: resolveFlagBusinessStage(flag, normalizedBusinessStage),
tone, tone,
label: resolveRiskLevelLabel(tone), label: resolveRiskLevelLabel(tone),

View File

@@ -20,7 +20,7 @@ export function resolveSubmitConfirmDescription({ isApplicationDocument, hasHigh
return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。' return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。'
} }
if (hasHighRiskWarnings) { if (hasHighRiskWarnings) {
return '系统自动检测存在重大风险,请确认已逐条填写风险原因。确认后将带着风险说明进入审批流程。' return '系统自动检测存在重大风险,请确认费用明细中的异常说明已按需补充。确认后将进入审批流程。'
} }
return '系统已在草稿保存和附件识别后完成自动检测,请确认费用明细、附件材料和补充说明均已核对无误。确认后将进入审批流程。' return '系统已在草稿保存和附件识别后完成自动检测,请确认费用明细、附件材料和补充说明均已核对无误。确认后将进入审批流程。'
} }

View File

@@ -319,13 +319,9 @@ export function useTravelReimbursementGuidedFlow({
}, },
review_form_values: { review_form_values: {
expense_type: expenseTypeLabel, expense_type: expenseTypeLabel,
reimbursement_type: expenseTypeLabel,
reason: applicationReason, reason: applicationReason,
reason_value: applicationReason,
location: applicationLocation, location: applicationLocation,
business_location: applicationLocation,
time_range: applicationBusinessTime, time_range: applicationBusinessTime,
business_time: applicationBusinessTime,
transport_mode: applicationTransportMode, transport_mode: applicationTransportMode,
amount: '', amount: '',
application_claim_id: applicationId, application_claim_id: applicationId,

View File

@@ -18,6 +18,7 @@ import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplica
import { fetchOntologyParse } from '../../services/ontology.js' import { fetchOntologyParse } from '../../services/ontology.js'
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js' import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
import { calculateTravelReimbursement } from '../../services/reimbursements.js' import { calculateTravelReimbursement } from '../../services/reimbursements.js'
import { fetchReceiptFolderItems } from '../../services/receiptFolder.js'
import { import {
handleBudgetCompileReportSubmit, handleBudgetCompileReportSubmit,
shouldUseBudgetCompileReport 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) { function buildConfirmedAssociationText(message) {
return String(message?.text || '') return String(message?.text || '')
.replace(`[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})`, '已确认') .replace(`[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})`, '已确认')
@@ -653,6 +726,20 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return null return null
} }
if (await promptUnlinkedReceiptFolderIfNeeded({
detailScopedClaimId,
files,
fileNames,
options,
rawText,
resolvedUploadDisposition,
reviewAction,
systemGenerated,
userText
})) {
return null
}
const hasUnsavedReviewDraft = Boolean( const hasUnsavedReviewDraft = Boolean(
!isKnowledgeSession.value && !isKnowledgeSession.value &&
files.length && files.length &&

View File

@@ -75,6 +75,21 @@ test('documents center reloads immediately when entered or clicked again', () =>
assert.match(appShellComposable, /reloadDocumentCenterRequests,/) 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', () => { 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, /const SMART_ENTRY_SOURCE_APPLICATION = 'application'/)
assert.match(appShellComposable, /function openExpenseApplicationCreate\(\) \{[\s\S]*openFinancialAssistantCreate\(SMART_ENTRY_SOURCE_APPLICATION\)/) assert.match(appShellComposable, /function openExpenseApplicationCreate\(\) \{[\s\S]*openFinancialAssistantCreate\(SMART_ENTRY_SOURCE_APPLICATION\)/)

View File

@@ -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_REIMBURSEMENT = '报销单'/)
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_REVIEW = '审核单'/) assert.match(documentsCenterView, /const DOCUMENT_SCOPE_REVIEW = '审核单'/)
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_ARCHIVE = '归档'/) 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( assert.match(
documentsCenterView, 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]*\]/ /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', () => { test('documents center category tabs map to the intended row sources', () => {
assert.match(documentsCenterView, /excludeArchivedDocumentRows/) assert.match(documentsCenterView, /excludeArchivedDocumentRows/)
assert.match(documentsCenterView, /approvalRows\.value = excludeArchivedDocumentRows/) assert.match(documentsCenterView, /approvalRows\.value = excludeArchivedDocumentRows/)

View File

@@ -21,6 +21,10 @@ const barChart = readFileSync(
fileURLToPath(new URL('../src/components/charts/BarChart.vue', import.meta.url)), fileURLToPath(new URL('../src/components/charts/BarChart.vue', import.meta.url)),
'utf8' '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', () => { test('finance dashboard keeps legacy ranking range constants for backend compatibility', () => {
assert.deepEqual(departmentRangeOptions, [ 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: `\$\{Number\(item\.employeeCount/)
assert.match(overviewViewModel, /meta: `\$\{item\.department/) 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'/)
})

View File

@@ -13,30 +13,38 @@ function testReceiptFolderViewSurface() {
assert.match(view, /activeStatus = ref\('all'\)/) assert.match(view, /activeStatus = ref\('all'\)/)
assert.match(view, /value: 'all'/) assert.match(view, /value: 'all'/)
assert.match(view, /value: 'unlinked'/)
assert.match(view, /value: 'linked'/)
assert.match(view, /openAssociateDialog/) assert.match(view, /openAssociateDialog/)
assert.match(view, /receipt-detail-toolbar/) assert.match(view, /<EnterpriseDetailPage/)
assert.match(view, /receipt-dashboard/) assert.match(view, /variant="receipt-folder-detail"/)
assert.match(view, /receipt-dashboard-preview/) assert.match(view, /<template #main>/)
assert.match(view, /receipt-dashboard-side/) assert.match(view, /<template #side>/)
assert.match(view, /receipt-dashboard-bottom/) assert.match(view, /<template #bottom>/)
assert.match(view, /receipt-ocr-panel/) assert.match(view, /receipt-preview-panel/)
assert.match(view, /receipt-status-panel/) assert.match(view, /receipt-ticket-info-panel/)
assert.match(view, /keyReceiptFields/) assert.match(view, /receipt-association-panel/)
assert.match(view, /editableOtherFields/) assert.match(view, /receipt-edit-log-section/)
assert.match(view, /ocrPreviewFields/) assert.match(view, /receipt-all-field-grid/)
assert.match(view, /class="receipt-key-grid"/) assert.match(view, /receiptInfoEditing/)
assert.match(view, /class="receipt-other-collapse"/) assert.match(view, /startReceiptInfoEdit/)
assert.match(view, /class="receipt-other-scroll"/) assert.match(view, /cancelReceiptInfoEdit/)
assert.match(view, /receiptEditLogs/)
assert.match(view, /previewFrameUrl/)
assert.match(view, /previewTransform/) assert.match(view, /previewTransform/)
assert.match(view, /String\(value \?\? ''\)\.trim\(\)/)
assert.match(view, /openAssociateDialogForCurrentReceipt/) assert.match(view, /openAssociateDialogForCurrentReceipt/)
assert.match(view, /createReceiptDetailDashboardModel/) assert.match(view, /createReceiptDetailDashboardModel/)
assert.match(view, /ElCollapse/) assert.match(view, /createReceiptDetailFieldModel/)
assert.doesNotMatch(view, /addField/) assert.doesNotMatch(view, /receipt-detail-toolbar/)
assert.match(view, /const isTrainTicket = computed/) assert.doesNotMatch(view, /receipt-side-stack/)
assert.doesNotMatch(view, /打开源文件/) 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.doesNotMatch(view, /openSourceFile/)
assert.match(view, /back-label=/) assert.match(view, /back-label=/)
assert.doesNotMatch(view, /back-btn/)
assert.match(view, /deleteCurrentReceipt/) assert.match(view, /deleteCurrentReceipt/)
assert.match(view, /ElCheckboxGroup/) assert.match(view, /ElCheckboxGroup/)
assert.match(view, /fetchReceiptFolderItems\('all'\)/) assert.match(view, /fetchReceiptFolderItems\('all'\)/)
@@ -93,60 +101,66 @@ function testReceiptFolderDetailLayoutAdjustments() {
const receiptView = readProjectFile('web/src/views/ReceiptFolderView.vue') const receiptView = readProjectFile('web/src/views/ReceiptFolderView.vue')
const receiptStyles = readProjectFile('web/src/assets/styles/views/receipt-folder-view.css') const receiptStyles = readProjectFile('web/src/assets/styles/views/receipt-folder-view.css')
const fieldModel = readProjectFile('web/src/views/scripts/receiptFolderDetailFields.js') 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, /showStatusColumn/)
assert.match(receiptView, /<col v-if="showStatusColumn" class="col-status">/) 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, /document_date/)
assert.match(receiptView, /<td>\s*<strong class="doc-id">/) 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, /buildDetailPayload\(\)/) assert.match(receiptView, /buildDetailPayload\(\)/)
assert.match(receiptView, /receiptDetailSubtitle/) assert.match(receiptView, /receiptDetailSubtitle/)
assert.match(receiptView, /receiptDetailTopBarPayload/) assert.match(receiptView, /receiptDetailTopBarPayload/)
assert.match(receiptView, /eyebrow:/)
assert.match(receiptView, /detail-topbar-change/) assert.match(receiptView, /detail-topbar-change/)
assert.doesNotMatch(receiptView, /<article v-else class="receipt-folder-detail/) assert.doesNotMatch(receiptView, /<article v-else class="receipt-folder-detail/)
assert.doesNotMatch(receiptView, /class="back-btn"/) assert.doesNotMatch(receiptView, /class="back-btn"/)
assert.doesNotMatch(receiptView, /receipt-detail-head/) assert.doesNotMatch(receiptView, /receipt-basic-panel/)
assert.doesNotMatch(receiptView, /detail-actions receipt-detail-foot/) assert.doesNotMatch(receiptView, /receipt-ocr-panel/)
assert.match(receiptStyles, /\.receipt-folder-list th:first-child/) 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-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\(\.detail-actions\)/)
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.enterprise-detail-card \.card-head\)/) assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.enterprise-detail-card \.card-head\)/)
assert.match(receiptStyles, /\.receipt-detail-toolbar/) assert.match(receiptStyles, /\.receipt-preview-panel/)
assert.match(receiptStyles, /\.receipt-dashboard/) assert.match(receiptStyles, /\.receipt-ticket-info-panel/)
assert.match(receiptStyles, /\.receipt-dashboard-bottom/) assert.match(receiptStyles, /\.receipt-association-panel/)
assert.match(receiptStyles, /\.receipt-preview-tools/) assert.match(receiptStyles, /\.receipt-preview-box[\s\S]*height: clamp\(380px, 56vh, 640px\)/)
assert.match(receiptStyles, /\.receipt-log-list/) assert.match(receiptStyles, /\.receipt-all-field-grid/)
assert.match(receiptStyles, /\.receipt-key-grid/) assert.match(receiptStyles, /\.receipt-edit-log-list/)
assert.match(receiptStyles, /\.receipt-other-collapse/) assert.doesNotMatch(receiptStyles, /\.receipt-detail-toolbar/)
assert.match(receiptStyles, /\.receipt-other-scroll/) assert.doesNotMatch(receiptStyles, /\.receipt-side-stack/)
assert.doesNotMatch(receiptStyles, /\.receipt-detail-head\b/) assert.doesNotMatch(receiptStyles, /\.receipt-bottom-grid/)
assert.doesNotMatch(receiptStyles, /\.receipt-detail-layout\b/) assert.doesNotMatch(receiptStyles, /\.receipt-log-list/)
assert.doesNotMatch(receiptStyles, /\.detail-loading\b/) assert.match(detailPage, /\$slots\.bottom/)
assert.doesNotMatch(receiptStyles, /\.back-btn\b/) assert.match(detailPage, /class="detail-bottom"/)
assert.doesNotMatch(receiptStyles, /\.danger-btn\b/)
assert.match(fieldModel, /TRAIN_KEY_FIELD_DEFINITIONS/) assert.match(fieldModel, /TRAIN_KEY_FIELD_DEFINITIONS/)
assert.match(fieldModel, /id: 'invoice_number'/) assert.match(fieldModel, /id: 'invoice_number'/)
assert.match(fieldModel, /id: 'invoice_date'/) assert.match(fieldModel, /id: 'invoice_date'/)
assert.match(fieldModel, /id: 'fare'/) assert.match(fieldModel, /id: 'fare'/)
assert.match(fieldModel, /id: 'passenger_name'/) assert.match(fieldModel, /id: 'passenger_name'/)
assert.match(fieldModel, /syncEditableFieldsToTopLevel/) assert.match(fieldModel, /syncEditableFieldsToTopLevel/)
const dashboardModel = readProjectFile('web/src/views/scripts/receiptFolderDetailDashboard.js')
assert.match(dashboardModel, /createReceiptDetailDashboardModel/) assert.match(dashboardModel, /createReceiptDetailDashboardModel/)
assert.match(dashboardModel, /basicInfoItems/) assert.match(dashboardModel, /basicInfoItems/)
assert.match(dashboardModel, /operationLogs/) assert.match(dashboardModel, /linkedClaimItems/)
assert.match(dashboardModel, /archiveInfoItems/) 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() { function run() {
@@ -155,6 +169,7 @@ function run() {
testAppShellWiresReceiptFolder() testAppShellWiresReceiptFolder()
testSharedDocumentListStyleReuse() testSharedDocumentListStyleReuse()
testReceiptFolderDetailLayoutAdjustments() testReceiptFolderDetailLayoutAdjustments()
testAssistantUnlinkedReceiptPrompt()
console.log('receipt folder view tests passed') console.log('receipt folder view tests passed')
} }

View File

@@ -74,6 +74,58 @@ test('claim mapper falls back to employee name for legacy profile lookup', () =>
assert.equal(request.profileEmployeeId, 'Legacy Alice') 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', () => { test('application claims are mapped as application documents', () => {
const request = mapExpenseClaimToRequest({ const request = mapExpenseClaimToRequest({
id: 'claim-application-1', id: 'claim-application-1',

View File

@@ -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_action, 'save_draft')
assert.equal(submitOptions.extraContext.review_form_values.application_claim_no, 'AP-202605-001') 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.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.amount, '')
assert.equal(submitOptions.extraContext.review_form_values.application_amount, '1800') 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_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.application_days, '4 天')
assert.equal(submitOptions.extraContext.review_form_values.transport_mode, '火车') 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.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_lodging_daily_cap, '600元/天')
assert.equal(submitOptions.extraContext.review_form_values.application_subsidy_daily_cap, '120元/天') 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') assert.equal(submitOptions.extraContext.expense_scene_selection.application_claim_no, 'AP-202605-001')

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
import { ref } from 'vue' import { ref } from 'vue'
import { import {
buildReviewFormContextFromPayload,
buildLocallySyncedReviewPayload, buildLocallySyncedReviewPayload,
buildReviewNextStepRichCopy, buildReviewNextStepRichCopy,
buildReviewPlainFollowupCopy, 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', () => { test('review drawer save action is disabled while receipt recognition is submitting', () => {
assert.match(createViewScript, /const submitting = ref\(false\)/) assert.match(createViewScript, /const submitting = ref\(false\)/)
assert.match( assert.match(

View File

@@ -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', () => { 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.match(detailViewTemplate, /class="risk-advice-compact-meta"/)
assert.doesNotMatch(detailViewTemplate, /section\.hiddenCount/) assert.doesNotMatch(detailViewTemplate, /section\.hiddenCount/)
assert.doesNotMatch(detailViewTemplate, /risk-advice-more/) 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', () => { test('expense rows show a major-risk warning icon before time', () => {
assert.match(detailViewTemplate, /'has-major-risk': isMajorExpenseRisk\(item\)/) assert.match(detailViewTemplate, /'has-major-risk': hasExpenseRiskIndicator\(item\)/)
assert.match(detailViewTemplate, /class="mdi mdi-alert expense-risk-indicator"/) assert.match(detailViewTemplate, /class="expense-risk-indicator"/)
assert.match(detailViewTemplate, /@click="focusExpenseRisk\(item\)"/)
assert.match(detailViewStyle, /\.expense-risk-indicator \{/) assert.match(detailViewStyle, /\.expense-risk-indicator \{/)
assert.match(detailViewScript, /function isMajorExpenseRisk\(item\)/) assert.match(detailViewScript, /function hasExpenseRiskIndicator\(item\)/)
assert.match(detailViewScript, /buildItemClaimRiskState\(item, resolveClaimRiskFlags\(\)\)/) 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', () => { test('AI advice shows only the latest manual return while preserving return count context', () => {
const riskCards = buildAttachmentRiskCards({ const riskCards = buildAttachmentRiskCards({
claimRiskFlags: [ 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(detailExpenseModelScript, /const OPTIONAL_ATTACHMENT_EXPENSE_TYPES = new Set\(\['ride_ticket', 'travel_allowance'\]\)/)
assert.match(detailViewTemplate, /'system-generated-row': item\.isSystemGenerated/) 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-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, /系统自动计算的补贴行不能手动编辑/)
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(detailViewTemplate, /<span>条款填写时间<\/span>/)
assert.match(detailViewScript, /function formatExpenseFilledTime\(value\)/) assert.match(detailViewScript, /function formatExpenseFilledTime\(value\)/)
assert.match(detailViewScript, /source\?\.filledAt[\s\S]*source\?\.created_at/) 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\) \|\| '待同步'/) 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', () => { test('expense item upload remains limited to one receipt per detail row', () => {
assert.match(detailViewTemplate, /ref="expenseUploadInput"[\s\S]*type="file"/) 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( assert.equal(
(detailViewTemplate.match(/v-if="isEditableRequest && !item\.invoiceId && !item\.isSystemGenerated"/g) || []).length, (detailViewTemplate.match(/v-if="isEditableRequest && !item\.invoiceId && !item\.isSystemGenerated"/g) || []).length,
2 2
@@ -682,6 +710,34 @@ test('expense item upload remains limited to one receipt per detail row', () =>
assert.match(detailViewScript, /fileCount > 1[\s\S]*一条费用明细只能上传一张单据/) 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', () => { 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 recognizedItemAmount = Number\(payload\?\.item_amount \?\? payload\?\.itemAmount\)/)
assert.match(detailViewScript, /const recognizedItemDate = normalizeIsoDateValue\(payload\?\.item_date \?\? payload\?\.itemDate\)/) 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 amountText = String\(expenseEditor\.itemAmount \|\| ''\)\.trim\(\)/)
assert.match(detailViewScript, /const nextAmount = amountText \? Number\(amountText\) : 0/) 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, /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', () => { test('travel detail AI advice uses material prompts only for required hotel receipts', () => {

View File

@@ -63,17 +63,22 @@ test('detail submit opens a confirmation dialog before calling submit API', () =
assert.match(confirmSubmitRequest, /submitExpenseClaim\(request\.value\.claimId\)/) 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, /:open="riskOverrideDialogOpen"/)
assert.match(detailViewTemplate, /重大风险/) assert.match(detailViewTemplate, /重大风险/)
assert.match(detailViewTemplate, /goToPreviousSubmitRisk/) assert.match(detailViewTemplate, /goToPreviousSubmitRisk/)
assert.match(detailViewTemplate, /goToNextSubmitRisk/) assert.match(detailViewTemplate, /goToNextSubmitRisk/)
assert.match(detailViewTemplate, /v-model="riskOverrideReasons\[currentSubmitRiskWarning\.id\]"/) assert.match(detailViewTemplate, /v-model="riskOverrideReasons\[currentSubmitRiskWarning\.id\]"/)
assert.match(detailViewScript, /const submitRiskWarnings = computed/) 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, /function confirmRiskOverrideReasons\(\)/)
assert.match(detailViewScript, /updateExpenseClaim\(request\.value\.claimId,\s*\{\s*reason: nextNote/s) assert.match(detailViewScript, /updateExpenseClaim\(request\.value\.claimId,\s*\{\s*reason: nextNote/s)
assert.match(detailViewScript, /超标说明:\$\{tags\}/) assert.match(detailViewScript, /超标说明:\$\{tags\}/)
assert.match(detailViewTemplate, /异常说明/)
}) })
test('detail header and fallback progress use reimbursement wording', () => { test('detail header and fallback progress use reimbursement wording', () => {