28 Commits

Author SHA1 Message Date
caoxiaozhu
75d5c178e1 feat(workbench): persist topbar notification state 2026-06-03 21:43:35 +08:00
caoxiaozhu
b9826a1985 fix: keep adjusted risks visible to reviewers 2026-06-03 19:14:40 +08:00
caoxiaozhu
0f8bc4071a fix: preserve reviewer risk notice after standard adjustment 2026-06-03 19:10:29 +08:00
caoxiaozhu
cb36d78fa2 fix: 优化顶部导航栏布局与工作台摘要展示并清理旧票据数据 2026-06-03 17:40:52 +08:00
caoxiaozhu
8e2477587f fix: handle risk explanation standard adjustment 2026-06-03 17:31:40 +08:00
caoxiaozhu
67b81a1bd8 fix(workbench): replay profile radar animation 2026-06-03 17:31:12 +08:00
caoxiaozhu
9c24a852e7 fix(workbench): remount expense stats chart on reopen 2026-06-03 17:22:48 +08:00
caoxiaozhu
95956afbc6 fix(notifications): refine bell notification center 2026-06-03 17:16:09 +08:00
caoxiaozhu
c73178b65d fix(documents): move unread notice into bell 2026-06-03 17:05:34 +08:00
caoxiaozhu
8c2f301d85 fix(documents): sort newest rows first 2026-06-03 16:52:49 +08:00
caoxiaozhu
4717ee6086 fix(documents): refine unread badges and mark all read 2026-06-03 16:46:13 +08:00
caoxiaozhu
513ff909f9 fix: remove manual expense detail add action 2026-06-03 16:44:06 +08:00
caoxiaozhu
92198549f6 fix: require explicit transport mode for applications 2026-06-03 16:36:02 +08:00
caoxiaozhu
59d3bf0f00 fix(auth): keep admin out of personal workbench 2026-06-03 16:31:27 +08:00
caoxiaozhu
04f0951b3d fix: restrict application linking for reimbursement drafts 2026-06-03 16:28:09 +08:00
caoxiaozhu
8887cf5a27 fix(workbench): stretch profile tag card 2026-06-03 15:53:30 +08:00
caoxiaozhu
34457f9c3e feat: 本体字段治理与风险规则模板执行器重构
- 新增本体字段注册表与字段治理审计脚本
- 重构风险规则模板执行器、DSL 验证与清单分类器
- 完善票据夹服务与差旅请求详情页交互
- 优化趋势图表与总览页数据展示
- 增强报销平台风险分级与模拟公司筛选
- 补充本体字段、风险规则生成与票据夹服务测试覆盖
2026-06-03 15:46:56 +08:00
caoxiaozhu
e12b140508 fix(workbench): show single expense distribution chart 2026-06-03 15:46:51 +08:00
caoxiaozhu
18d716bc6b feat(workbench): show expense distribution as donut chart 2026-06-03 15:31:09 +08:00
caoxiaozhu
74d488adfa fix(workbench): center progress expense type 2026-06-03 15:21:38 +08:00
caoxiaozhu
31052d0b98 feat(workbench): keep progress detail return context 2026-06-03 15:14:44 +08:00
caoxiaozhu
20cb60e247 feat(workbench): add expense stats detail modal 2026-06-03 14:59:55 +08:00
caoxiaozhu
3130c42d76 feat(workbench): separate stale progress items 2026-06-03 12:38:17 +08:00
caoxiaozhu
6fc5e66ea1 feat(workbench): show progress update time first 2026-06-03 12:28:21 +08:00
caoxiaozhu
27dd2f0a0d feat(dashboard): reorganize budget and risk cards 2026-06-03 10:47:11 +08:00
caoxiaozhu
faa39e6c06 test(dashboard): cover shared loading overlay 2026-06-03 09:45:06 +08:00
caoxiaozhu
d060f89d30 style(dashboard): reuse shared loading overlay 2026-06-03 09:43:36 +08:00
caoxiaozhu
0d6327a990 feat(dashboard): polish risk and digital employee boards 2026-06-03 09:41:32 +08:00
188 changed files with 11587 additions and 3331 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

@@ -0,0 +1,111 @@
# 工作台费用统计详情弹窗概念文档
## 功能一句话
在个人工作台的“费用统计”卡片中提供本地弹窗详情,让用户直接查看历史费用分布、单据处理时间和系统操作明细。
## 背景与问题
当前“费用统计”右上角的“查看详情”会进入助手问答不符合用户期望的“像用户画像一样直接看详情”的操作方式。费用进度区域也存在两个可见性问题右上角“全部进度”按钮没有实际承载完整列表能力10 日以上分割标识靠左且不醒目。
本次调整需要让工作台成为个人费用操作的直接入口:用户不离开首页即可理解自己的费用结构、单据流转时间和近期系统动作。
## 目标与非目标
目标:
- 移除“费用进度”右上角的“全部进度”按钮,减少无效操作。
- 将“10日以上”分割标识放在分割线中间并使用更醒目的主题强调色。
- 将“费用统计”的“查看详情”改为打开详情弹窗。
- 弹窗展示历史报销费用分布、单据处理时间和系统操作详情。
- 数据优先来自 `buildWorkbenchSummary` 已有的当前用户单据汇总,不新增后端接口。
非目标:
- 不新增后端 API。
- 不改变报销单据审批状态计算规则。
- 不替代用户画像详情弹窗。
- 不做复杂图表库接入,避免为了一个工作台弹窗扩大依赖和维护面。
## 用户与场景
主要用户是个人员工和经常处理报销的业务人员。典型场景:
- 在首页查看本月报销情况后,想进一步确认自己的历史费用主要花在哪些类别。
- 想知道近期单据从创建到当前状态大概处理了多久。
- 想复盘系统里最近需要处理或已提醒的费用相关动作。
## 功能能力
### 费用分布
按单据标题、场景或备注归类费用类型,统计每类金额、单据数量和金额占比。详情区使用项目现有 `DonutChart` 饼图展示费用分布,并通过图例保留金额与占比信息。若数据不足,展示空状态。
### 处理时间
按单据创建、提交、更新或进度步骤时间推断处理耗时,输出可读的耗时文案,并展示当前状态和节点数量。
### 操作详情
基于待办、通知和进度项生成系统操作明细,帮助用户理解最近有哪些费用动作需要关注。
## 方案设计
前端实现:
-`workbenchSummary.js` 中新增 `expenseStatsDetail` 汇总结构。
- 新增 `ExpenseStatsDetailModal.vue`,复用 Element Plus `ElDialog``ElButton``ElTag` 的企业后台弹窗体验。
- 费用分布展示复用现有 `DonutChart`,不手写临时 SVG、Canvas 或 CSS 饼图。
-`PersonalWorkbench.vue` 中接入弹窗状态,费用统计“查看详情”只打开弹窗。
- 调整 `personal-workbench.css` 中长时间分割标识的居中与强调样式。
数据结构:
```js
expenseStatsDetail: {
distributionRows: [],
processingRows: [],
operationRows: []
}
```
## 算法与公式
费用类型金额占比:
$$
percent_i = \frac{amount_i}{\sum_{k=1}^{n} amount_k} \times 100
$$
单据处理耗时:
$$
duration = latestTime - firstTime
$$
其中 `firstTime` 优先取单据创建时间、提交时间或最早进度步骤时间,`latestTime` 优先取更新时间或最新进度步骤时间。
## 测试方案
- 源码测试确认费用进度不再渲染“全部进度”按钮。
- 源码测试确认“费用统计”的“查看详情”打开弹窗而不是进入助手。
- 单元测试确认 `buildWorkbenchSummary` 能生成费用分布、处理时间和操作明细。
- 源码测试确认弹窗包含费用分布饼图、处理时间和系统操作详情区块。
- 运行前端构建验证组件编译通过。
## 指标与验收
- “10日以上”标识位于分割线中间且使用主题强调色。
- “费用进度”卡片右上角不再出现“全部进度”。
- 点击“费用统计”的“查看详情”打开详情弹窗。
- 弹窗至少包含费用分布饼图、处理时间、系统操作详情三个信息区。
- 相关测试与前端构建通过。
## 风险与开放问题
- 当前数据来自工作台前端汇总,历史维度受首页已加载单据范围影响;若后续需要跨年或分页全量统计,应补后端专用接口。
- 单据类型归类依赖标题、场景和备注,属于前端轻量归类;后续可与 ontology 费用类别字段打通。
## 2026-06-03 饼图呈现修正
费用分布仍复用项目已有 `DonutChart`,但在费用统计详情弹窗内关闭组件自带图例,只保留一个环形饼图入口。费用类型、金额、笔数和占比改为右侧文字明细列表,避免环图主体和双列图例在同一卡片内被误认为出现两个饼图。

View File

@@ -0,0 +1,31 @@
# 工作台费用统计详情弹窗 TODO
## 调研与契约
- [x] 核对 `PersonalWorkbench.vue`、工作台样式和现有用户画像弹窗结构。[CONCEPT: 方案设计] 证据:已确认工作台入口、`ExpenseProfileDetailModal.vue` 弹窗模式和 `personal-workbench.css` 分割样式。
- [x] 明确费用详情弹窗的数据结构,并限制为前端工作台汇总数据。[CONCEPT: 功能能力] 证据:采用 `expenseStatsDetail`,由 `buildWorkbenchSummary` 基于当前用户单据生成。
## 前端实现
- [x] 移除费用进度卡片右上角“全部进度”按钮。[CONCEPT: 目标与非目标] 证据:`PersonalWorkbench.vue` 的费用进度标题区已移除该按钮。
- [x] 调整“10日以上”分割标识为居中、醒目主题色样式。[CONCEPT: 指标与验收] 证据:`personal-workbench.css` 使用 `left: 50%``transform: translateX(-50%)` 和主题强调色。
- [x]`workbenchSummary.js` 生成费用分布、处理时间、系统操作详情数据。[CONCEPT: 算法与公式] 证据:新增 `expenseStatsDetail` 汇总结构。
- [x] 新增费用统计详情弹窗组件,展示三个详情区块和空状态。[CONCEPT: 功能能力] 证据:新增 `ExpenseStatsDetailModal.vue`
- [x]`PersonalWorkbench.vue` 接入弹窗状态与费用统计“查看详情”按钮。[CONCEPT: 方案设计] 证据:新增 `expenseStatsModalOpen``openExpenseStatsModal`
- [x] 将费用分布区从条形列表改为 `DonutChart` 饼图展示。[CONCEPT: 功能能力] 证据:`ExpenseStatsDetailModal.vue` 已接入 `DonutChart``distributionChartItems`
- [x] 关闭费用详情内 `DonutChart` 自带图例,改为单饼图加右侧文字明细。[CONCEPT: 2026-06-03 饼图呈现修正] 证据:`ExpenseStatsDetailModal.vue` 传入 `:show-legend="false"` 并新增 `expense-distribution-summary-list`
- [x] 为通用 `DonutChart` 增加可隐藏内置图例的开关,默认保持其它页面不变。[CONCEPT: 2026-06-03 饼图呈现修正] 证据:`DonutChart.vue` 新增 `showLegend` 默认值和 `donut-chart--legendless` 状态。
## 测试与验证
- [x] 补充工作台源码测试,覆盖按钮移除、弹窗接入和分割标识样式。[CONCEPT: 测试方案] 证据:`node web/tests/personal-workbench-assistant.test.mjs` 通过。
- [x] 补充工作台汇总单元测试,覆盖详情数据生成。[CONCEPT: 测试方案] 证据:`node web/tests/workbench-summary.test.mjs` 通过。
- [x] 补充弹窗源码测试,覆盖费用分布、处理时间、系统操作详情区块。[CONCEPT: 测试方案] 证据:`node web/tests/expense-stats-detail-modal.test.mjs` 通过。
- [x] 运行前端定向测试和构建验证。[CONCEPT: 指标与验收] 证据:以上定向测试和 `npm.cmd --prefix web run build` 均通过。
- [x] 更新弹窗源码测试,确认费用分布使用饼图组件。[CONCEPT: 测试方案] 证据:`node web/tests/expense-stats-detail-modal.test.mjs` 通过,`npm.cmd --prefix web run build` 通过。
- [x] 更新弹窗与环图源码测试,确认详情弹窗只使用一个饼图入口且关闭内置图例。[CONCEPT: 2026-06-03 饼图呈现修正] 证据:`node web/tests/expense-stats-detail-modal.test.mjs``node web/tests/donut-chart.test.mjs` 通过。
## 交付
- [x] 复查本次暂存范围,避免纳入无关工作区改动。[CONCEPT: 风险与开放问题] 证据:`git diff --cached --name-only` 仅包含本次工作台弹窗、样式、汇总测试和开发文档。
- [x] 提交并 push 本次功能分支。[CONCEPT: 指标与验收] 证据:本次单饼图修复完成后提交并推送当前分支。

View File

@@ -0,0 +1,75 @@
# 工作台费用进度详情返回与类型列概念文档
## 功能一句话
让用户从首页费用进度进入单据详情后能返回首页,并在费用进度列表中直接看到每笔单据的费用类型。
## 背景与问题
当前首页费用进度点击单据后进入详情页,但详情页返回按钮默认回到单据中心,破坏了用户从首页查看进度的上下文。同时费用进度行只展示单号、标题、流程、状态和金额,用户需要点进详情才知道单据属于差旅、招待、办公等哪类费用。
## 目标与非目标
目标:
- 从首页费用进度进入详情时,详情页返回按钮回到个人工作台。
- 从单据中心进入详情时,原有返回单据中心逻辑不变。
- 在首页费用进度行新增“费用类型”列。
- 费用类型优先使用单据已有类型字段,缺失时按标题、场景和备注轻量归类。
非目标:
- 不修改详情页主体内容。
- 不新增后端接口。
- 不改变单据中心列表、审批详情和其他来源的返回逻辑。
## 用户与场景
个人员工在首页查看最近费用进度,点击某笔单据进入详情核对处理情况。查看完后点击返回,应回到刚才的首页工作台继续看其他进度项,而不是跳到单据中心列表。
## 功能能力
- 首页打开详情时带入 `returnTo=workbench` 来源标记。
- 详情页根据来源标记动态显示返回按钮文案并执行返回首页。
- 费用进度数据新增 `expenseTypeLabel`
- 费用类型列在桌面端作为独立列展示,窄屏下按移动端布局折行展示。
## 方案设计
前端实现:
- `PersonalWorkbench.vue``open-document` 事件 payload 中补 `source: 'workbench'``returnTo: 'workbench'`
- `AppShellRouteView.vue` 接收工作台来源并传给 `openRequestDetail`
- `useAppShell.js` 在打开详情时写入查询参数,在关闭详情时根据查询参数返回工作台或单据中心。
- `workbenchSummary.js``progressItems` 中补费用类型字段。
- `personal-workbench.css` 与响应式样式新增费用类型列。
## 算法与公式
当前功能不涉及复杂数学公式。
费用类型归类优先级:
1. 单据显式字段:`expenseCategory``expense_type``category` 等。
2. 文本规则:从场景、标题、备注和描述中匹配差旅、招待、办公、培训、市场等关键词。
3. 兜底为“其他费用”。
## 测试方案
- 源码测试确认首页费用进度打开详情时带 `returnTo=workbench`
- 源码测试确认详情返回文案和关闭逻辑支持工作台来源。
- 单元测试确认 `progressItems` 输出费用类型字段。
- 源码测试确认费用进度模板和样式包含费用类型列。
- 运行前端定向测试与构建。
## 指标与验收
- 从首页费用进度进入详情后,返回按钮回到个人工作台。
- 从单据中心进入详情后,返回按钮仍回到单据中心。
- 首页费用进度行可直接看到费用类型。
- 相关定向测试与前端构建通过。
## 风险与开放问题
- 当前费用类型归类仍是前端轻量归类。后续若后端已有稳定 ontology 类型字段,应优先接入 canonical 字段。
- 路由查询参数只用于详情返回来源,不应影响单据筛选和详情数据加载。

View File

@@ -0,0 +1,25 @@
# 工作台费用进度详情返回与类型列 TODO
## 调研与契约
- [x] 核对首页费用进度点击链路、详情页返回逻辑和当前进度行样式。[CONCEPT: 方案设计] 证据:已确认 `PersonalWorkbench.vue` 发出 `open-document``AppShellRouteView.vue` 转入详情,`useAppShell.js` 默认返回单据中心。
- [x] 明确来源标记与费用类型字段的前端契约。[CONCEPT: 功能能力] 证据:采用 `returnTo: 'workbench'``expenseTypeLabel`
## 前端实现
- [x] 首页费用进度打开详情时带入工作台返回来源。[CONCEPT: 方案设计] 证据:`PersonalWorkbench.vue``open-document` payload 已包含 `source``returnTo`
- [x] 详情页关闭逻辑按来源返回工作台或单据中心。[CONCEPT: 功能能力] 证据:`useAppShell.js` 根据 `route.query.returnTo` 选择 `app-workbench``app-documents`
- [x] 工作台进度汇总新增费用类型字段。[CONCEPT: 功能能力] 证据:`workbenchSummary.js``progressItems` 输出 `expenseTypeLabel`
- [x] 首页费用进度行新增费用类型列及响应式样式。[CONCEPT: 指标与验收] 证据:`PersonalWorkbench.vue` 新增 `progress-type`,样式和响应式布局已更新。
## 测试与验证
- [x] 补充详情返回来源源码测试。[CONCEPT: 测试方案] 证据:`node web/tests/workbench-detail-return.test.mjs` 通过。
- [x] 补充费用进度类型列源码测试。[CONCEPT: 测试方案] 证据:`node web/tests/personal-workbench-assistant.test.mjs` 通过。
- [x] 补充工作台汇总单元测试,覆盖费用类型字段。[CONCEPT: 测试方案] 证据:`node web/tests/workbench-summary.test.mjs` 通过。
- [x] 运行定向测试和前端构建。[CONCEPT: 指标与验收] 证据:以上定向测试和 `npm.cmd --prefix web run build` 均通过。
## 交付
- [x] 复查暂存范围,避免纳入无关工作区改动。[CONCEPT: 风险与开放问题] 证据:`git diff --cached --name-only` 仅包含本次工作台进度、返回来源、测试和开发文档。
- [ ] 提交并 push 本次功能分支。[CONCEPT: 指标与验收]

View File

@@ -0,0 +1,115 @@
# 财务与风险看板卡片重组
## 功能一句话
将财务看板的预算执行率合并进预算指标卡片,并重组风险看板尾部卡片,让异常排行和风险占比成为主要分析信息。
## 背景与问题
当前分析看板存在两个体验问题:
- 财务看板底部同时有“预算指标”和“预算执行率(本月)”两个预算卡片,信息相近但占用两块空间。
- 风险看板中“算法闭环效果”和“近期高风险观察”对当前看板判断价值较低;“来源分布”展示 `unknown` 时会让用户误以为数据异常,实际用户想看每类风险占比。
## 目标与非目标
目标:
- 将预算执行率仪表图整合进“预算指标”卡片,取消单独的预算执行率卡片,并把整合后的预算指标卡放在“高额单据”右侧空白位。
- 风险看板把“来源分布”改为“风险占比”,展示风险信号或风险类型占比。
- 风险看板移除“算法闭环效果”和“近期高风险观察”卡片。
- 异常排行重新设计为占满整张卡片的图表化内容,减少碎片列表感。
非目标:
- 不改后端接口,不新增风险或预算接口。
- 不改顶部 KPI 和风险趋势图数据口径。
- 不引入新的图表库,继续复用现有 `DonutChart``BarChart``GaugeChart`
## 用户与场景
用户:
- 财务分析人员、风险复核人员、管理员。
场景:
- 财务人员查看预算指标时,一眼看到预算执行率、预算总额、已用和剩余额度。
- 风险人员查看风险看板时,优先看到风险类型占比和异常维度排行,而不是来源未知或低价值尾部卡片。
## 功能能力
财务看板:
- “预算指标”卡片包含预算执行率仪表图和预算指标列表,桌面端与“高额单据”同处底部半宽行,避免预算信息独占新行造成留白。
- `budgetSummary` 仍作为仪表图数据源。
- `budgetMetrics` 仍作为指标列表数据源。
- 单独 `budget-panel` 不再渲染。
风险看板:
- “来源分布”改为“风险占比”,数据来自 `signalDistribution``topRiskSignals`
- 异常排行卡片横跨整行,主图表填满卡片,下面只保留紧凑排行明细。
- 删除算法闭环效果和近期高风险观察两个卡片。
## 方案设计
前端:
- `OverviewView.vue`
- 删除独立预算执行率卡片。
- 在预算指标卡片内部增加 `GaugeChart` 区域,与指标列表左右布局。
- `overview-view.css`
- 调整 `budget-metrics-panel` 的布局宽度和内部栅格,桌面端占 6 栅格贴合“高额单据”右侧。
- 新增预算整合布局样式,移动端自动单列。
- `useOverviewView.js`
-`riskSourceLegend` 改为风险占比 legend优先使用风险信号分布。
- `RiskObservationDashboard.vue`
- 风险占比卡片标题改为“风险占比”。
- 异常排行卡片改为整行大卡。
- 移除算法闭环效果和近期高风险观察模板与样式。
## 算法与公式
本次不改变后端算法,只改变前端展示。
风险占比:
$$
share_i = \frac{count_i}{\sum_{j=1}^{n} count_j}
$$
预算执行率沿用已有 `budgetSummary.ratio`
$$
budgetUsageRate = \frac{usedBudget}{totalBudget}
$$
## 测试方案
- 前端源码测试:
- 财务看板不再渲染独立 `budget-panel`
- 预算指标卡片包含 `GaugeChart`
- 风险看板标题为“风险占比”,不再使用“来源分布”。
- 风险看板不再渲染算法闭环效果和近期高风险观察。
- 异常排行卡片使用整行样式和图表填充样式。
- 构建验证:
- `node web/tests/risk-observation-dashboard.test.mjs`
- 如有财务看板测试则补充运行。
- `npm.cmd --prefix web run build`
## 指标与验收
- 财务看板底部不再多出单独“预算执行率(本月)”卡片。
- 预算指标卡片内部能看到预算执行率和预算指标,并在桌面端填充“高额单据”右侧空白位。
- 风险看板不再显示“算法闭环效果”和“近期高风险观察”。
- 风险占比不再显示来源未知,而是展示具体风险占比。
- 异常排行卡片占满整行,图表区域明显成为主内容。
## 风险与开放问题
- 当前工作区已有未提交改动,提交时必须只纳入本次相关文件。
- 本次只改前端展示,如果后端风险信号为空,则仍需要显示“暂无数据”兜底。

View File

@@ -0,0 +1,30 @@
# 财务与风险看板卡片重组 TODO
## 调研
- [x] 盘点财务预算卡片和风险看板卡片现状。[CONCEPT: 背景与问题] 证据:已检查 `OverviewView.vue``overview-view.css``RiskObservationDashboard.vue``useOverviewView.js` 和风险看板测试。
## 契约
- [x] 确认本次不改后端接口,只调整前端展示和数据映射。[CONCEPT: 目标与非目标] 证据:现有 `budgetSummary``budgetMetrics``signalDistribution``topRiskSignals` 足够支撑改动。
## 前端
- [x] 将预算执行率整合到预算指标卡片,移除独立预算执行率卡片。[CONCEPT: 财务看板] 证据:`OverviewView.vue` 中预算指标卡片内新增 `GaugeChart`,并保留在“高额单据”右侧的底部栅格位置;独立 `budget-panel` 已移除。
- [x] 将风险“来源分布”改成“风险占比”,使用风险信号分布数据。[CONCEPT: 风险看板] 证据:`riskCompositionLegend` 优先读取 `signalDistribution`,标题显示“风险占比”。
- [x] 移除风险看板“算法闭环效果”和“近期高风险观察”卡片。[CONCEPT: 风险看板] 证据:模板、计算属性和样式中的 `risk-effect-*``risk-recent-*` 已删除。
- [x] 重设异常排行卡片为整行大图表布局。[CONCEPT: 风险看板] 证据:`.risk-ranking-panel` 改为 `grid-column: span 12`,并新增 `risk-ranking-chart-block`
## 测试
- [x] 更新风险看板源码测试。[CONCEPT: 测试方案] 证据:`risk-observation-dashboard.test.mjs` 覆盖删卡、异常排行图表化、风险映射中文化和顶部时间范围驱动。
- [x] 补充或更新财务看板源码测试。[CONCEPT: 测试方案] 证据:新增 `finance-dashboard-budget-card.test.mjs`,校验预算指标卡位于高额单据之后且桌面端 `grid-column: span 6`
- [x] 运行定向前端测试。[CONCEPT: 测试方案] 证据:`node web/tests/risk-observation-dashboard.test.mjs``node web/tests/finance-dashboard-ranking.test.mjs``node web/tests/finance-dashboard-budget-card.test.mjs` 通过。
- [x] 运行前端构建验证。[CONCEPT: 测试方案] 证据:`npm.cmd --prefix web run build` 通过,仅保留 Vite 大 chunk 与第三方 PURE 注释警告。
## 验收
- [x] 确认财务看板只有一个预算卡片且含预算执行率。[CONCEPT: 指标与验收] 证据:源码测试确认 `budget-metrics-panel` 包含 `GaugeChart`、没有旧 `budget-panel`,并在桌面端填充“高额单据”右侧空白位。
- [x] 确认风险占比展示具体风险类型,不再展示来源未知。[CONCEPT: 指标与验收] 证据:源码测试确认使用 `riskCompositionLegend``signalDistribution`,并补充 `budget_pressure``missing_material``simulation` 中文映射。
- [x] 确认风险看板尾部仅保留重设计后的异常排行核心信息。[CONCEPT: 指标与验收] 证据:源码测试确认 `risk-ranking-visual``rankingChartItems` 生效,且 `risk-effect-panel``risk-recent-panel` 不再渲染。
- [x] 提交并推送本次改动,避免纳入无关脏工作区文件。[CONCEPT: 风险与开放问题] 证据:本次看板相关文件将随 `feat(dashboard): reorganize budget and risk cards` 提交并推送到当前分支。

View File

@@ -0,0 +1,111 @@
# 通知中心状态持久化概念文档
## 功能一句话
为首页小铃铛通知中心补齐服务端状态接口,让同一用户在不同电脑登录时看到一致的已读、清空和隐藏状态,并优化笔记本等小屏幕下的通知弹窗可读性。
## 背景与问题
当前小铃铛通知由前端从单据中心、个人工作台摘要等数据源即时生成,但已读与清空状态主要写入浏览器 `localStorage`。这会导致同一账号在 A 电脑清空通知后,换到 B 电脑仍然看到通知。
同时,通知条数较多或屏幕高度较小时,列表内容容易挤压头部操作区,通知标题与描述也容易在窄宽度下互相挤压。
## 目标与非目标
目标:
- 提供当前用户维度的通知状态接口。
- 支持批量同步通知状态,至少覆盖已读与隐藏。
- 前端优先使用服务端状态,接口不可用时保留本地降级能力。
- 优化小屏幕通知弹窗,列表多时使用内部滚动,标题、描述与操作按钮不互相挤压。
非目标:
- 不做独立消息投递系统。
- 不新增推送、WebSocket 或邮件通知能力。
- 不改变通知来源生成逻辑,当前仍由单据中心和工作台摘要生成。
## 用户与场景
- 普通员工:在个人工作台查看待办、单据新消息,跨电脑登录后已读状态一致。
- 审批人:处理待审批单据后,通知中心不因换电脑重新显示已清空内容。
- 管理员:仍可看到系统内已有通知入口,但 admin 是否展示工作台由现有逻辑决定。
## 功能能力
- `GET /notification-states`:读取当前登录用户的通知状态集合。
- `POST /notification-states`:批量保存当前登录用户的通知状态。
- 状态字段:
- `notification_id`:前端生成的稳定通知 ID。
- `read_at`:已读时间。
- `hidden_at`:隐藏或清空时间。
- `context_json`:保留通知来源、类型等低风险上下文,便于排查。
- 前端能力:
- 打开工作台或弹窗时读取服务端状态。
- 点击通知写入已读。
- 清空通知写入隐藏。
- 接口失败时仍写入本地缓存,避免用户操作失效。
## 方案设计
后端:
- 新增 `NotificationState` SQLAlchemy 模型。
- 新增 `NotificationStateService`,负责按 `CurrentUserContext.username` 读写状态。
- 新增 `notification_states` endpoint并挂到 API v1 router。
- 服务初始化时使用项目现有 `Base.metadata.create_all(..., tables=[...])` 模式确保表存在。
前端:
- 新增 `web/src/services/notificationStates.js` 封装接口。
- `TopBar.vue``localStorage` 状态作为初始兜底,服务端状态返回后合并覆盖。
- `markNotificationRead``clearAllNotifications` 做乐观更新,再异步同步服务端。
- 对单据通知仍调用现有 `markDocumentInboxRowRead`,同时写入通知状态接口,保证跨设备一致。
小屏幕布局:
- 弹窗宽度使用 `clamp``100vw` 约束。
- 弹窗最大高度使用 `min(..., calc(100vh - ...))`
- 列表作为唯一滚动区域,头部和 tab 固定在弹窗网格内。
- 通知描述允许两行截断,避免窄屏时横向挤压。
## 算法与公式
当前功能不涉及显式数学公式。状态合并规则为:
$$
visible = notification\_id \notin hiddenIds
$$
$$
unread = sourceUnread \land notification\_id \notin readIds \land notification\_id \notin hiddenIds
$$
服务端状态优先,前端本地状态仅作为接口失败或首次加载前的兜底。
## 测试方案
- 后端单元测试:
- 当前用户只能读取自己的通知状态。
- 批量 upsert 后可读取 `read_at``hidden_at`
- 清空通知写入 hidden 状态。
- 前端静态测试:
- `TopBar` 引用通知状态服务。
- 已读、清空操作会同步服务端。
- 小屏 CSS 使用弹窗 max-height、内部滚动和移动端约束。
- 构建验证:
- 运行前端构建确认 Vue 与服务导入无误。
- 在容器内运行后端定向 pytest。
## 指标与验收
- 同一用户跨电脑登录后,已读和清空状态由服务端保持一致。
- 接口失败时用户仍可本地清空,不阻断主要流程。
- 通知弹窗在笔记本高度下不会挤压头部按钮,列表内部滚动。
- 通知标题、描述、时间在窄屏下不横向溢出。
## 风险与开放问题
- 当前通知本身仍由前端即时生成,服务端只保存状态,不保存完整通知正文。
- 通知 ID 需要保持稳定,否则服务端状态无法命中;本次沿用现有 `document:``workbench:` 前缀。
- 历史 localStorage 状态会作为首次迁移兜底,后续服务端会逐步成为主状态源。

View File

@@ -0,0 +1,10 @@
# 通知中心状态持久化 TODO
- [x] 调研现有小铃铛通知来源、localStorage 键和单据中心已读逻辑。[CONCEPT: 背景与问题] 证据:`TopBar.vue``useDocumentCenterInbox.js``documentCenterNewState.js` 已确认。
- [x] 新增后端通知状态模型、Schema、Service 与 API endpoint。[CONCEPT: 方案设计] 证据:`notification_states` 支持按用户保存已读与隐藏状态。
- [x] 将通知状态接口挂载到 API v1 router并保持当前用户隔离。[CONCEPT: 功能能力] 证据:`GET /notification-states``POST /notification-states` 已接入。
- [x] 新增前端 `notificationStates` 服务封装读取与批量保存。[CONCEPT: 前端] 证据:服务层统一请求 `/notification-states`
- [x] 改造 `TopBar` 已读、清空逻辑,优先同步服务端,保留本地降级。[CONCEPT: 前端] 证据:小铃铛点击已读、清空通知都会写入状态接口。
- [x] 优化通知弹窗笔记本与窄屏布局,避免条数多时挤压。[CONCEPT: 小屏幕布局] 证据弹窗限制视口高度列表滚动描述两行截断420px 下隐藏行箭头。
- [x] 补充后端和前端回归测试。[CONCEPT: 测试方案] 证据:`server/tests/test_notification_states.py``web/tests/sidebar-document-unread-dot.test.mjs`
- [x] 运行容器后端定向 pytest、前端 Node 测试与前端 build。[CONCEPT: 指标与验收] 证据pytest 2 passedNode 4 passedVite build passed。

View File

@@ -0,0 +1,95 @@
# 风险与数字员工看板视觉优化
## 功能一句话
修正分析看板中风险看板的英文指标展示,将异常排行改成图表化表达,并优化数字员工看板的卡片布局和图表填充。
## 背景与问题
当前分析看板已经接入风险观察和数字员工数据,但存在三个影响个人操作体验的问题:
- 风险看板仍会把 `duplicate_invoice``rule_center``unknown` 等后端 key 直接展示给用户。
- 异常排行以多列文字列表呈现,分类多、层级碎,难以快速判断哪个异常维度最突出。
- 数字员工看板部分卡片高度没有被内容充分利用,图表固定高度偏小,视觉上留下较多空白。
## 目标与非目标
目标:
- 风险看板可见指标全部中文化,常见风险信号、来源、状态、规则名和未知占位都不再直接显示英文 key。
- 异常排行聚合成一张图表化总览,保留部门、员工、供应商、规则和费用类型五个维度,并展示数量与金额。
- 数字员工看板减少无效空白,让趋势图、技能分布、模块排行和业务产出更充分占满卡片。
- 保持企业 SaaS 风格,继续复用现有 ECharts 封装组件和直角低饱和视觉体系。
非目标:
- 不新增接口,不改变后端数据契约。
- 不引入新的图表库。
- 不重做分析看板顶部导航、财务看板、系统看板和页面路由。
## 用户与场景
用户:
- 财务人员、风险复核人员、管理员。
场景:
- 用户进入风险看板,快速识别最近周期的风险来源、风险等级和主要异常维度。
- 用户查看异常排行时,优先通过图形长度和金额标签判断高发异常。
- 用户进入数字员工看板,查看后台任务趋势、技能类型、工作模块和产出,不需要在大面积空白里寻找信息。
## 功能能力
风险看板:
- 对风险信号 key、风险来源 key、状态 key、英文规则名和未知值做前端中文化。
- 异常排行从五列小列表改为组合图表:
- 每个维度取排名第一项作为主条形图。
- 展示维度名称、异常项名称、数量和金额。
- 保留各维度的次级排行,作为图表下方的紧凑明细。
数字员工看板:
- 主趋势卡片与每日摘要组成同一行,趋势图高度随卡片拉伸。
- 技能分布、工作模块排行和业务产出统一为等高卡片。
- 最近工作记录独占整行,减少右侧空白和表格压缩。
## 前端方案
- `RiskObservationDashboard.vue`
- 扩展 `formatSignal``formatDimensionName``formatRiskLevel` 等映射。
- 新增异常排行图表数据 `rankingChartItems`,复用 `BarChart` 展示五个维度的头部异常。
- 将原 `risk-ranking-grid` 改成图表 + 紧凑明细布局。
- `DigitalEmployeeDashboard.vue`
- 给卡片设置 flex 纵向结构,让图表区和列表区可拉伸。
- 调整栅格跨度:趋势 7、每日摘要 5技能分布、模块排行、业务产出各 4最近记录 12。
- 为图表容器增加可填充高度,减少固定高度导致的空白。
- `DigitalEmployeeDailyWorkChart.vue`
- 将固定高度改为跟随父容器的 `100%`,用最小高度保证可读性。
## 测试方案
- 前端源码测试:
- 风险看板不再暴露常见英文风险 key。
- 异常排行包含 `rankingChartItems` 并复用 `BarChart`
- 数字员工看板包含布局填充类名和可拉伸图表区域。
- 构建验证:
- `node web/tests/risk-observation-dashboard.test.mjs`
- `node web/tests/digital-employee-dashboard.test.mjs`
- `npm.cmd --prefix web run build`
## 验收标准
- 风险看板常见英文 key 在用户可见位置被中文文案替代。
- 异常排行以图表作为主视觉,不再只是五列文字列表。
- 数字员工看板主要图表能够跟随卡片高度填充,卡片间高度更均衡。
- 定向测试和前端构建通过。
## 风险与开放问题
- 当前工作区有大量既有未提交和未跟踪文件,本次提交需要严格隔离目标文件。
- 若现有测试文件中保留了旧版乱码断言,需要同步更新为 UTF-8 中文断言。
- 本次不改后端,如果后端后续新增新的风险 key需要前端映射表继续补充。

View File

@@ -0,0 +1,31 @@
# 风险与数字员工看板视觉优化 TODO
## 调研
- [x] 盘点风险看板英文指标、异常排行和数字员工布局现状。[CONCEPT: 背景与问题] 证据:已检查 `RiskObservationDashboard.vue``DigitalEmployeeDashboard.vue``DigitalEmployeeDailyWorkChart.vue``BarChart.vue` 和相关测试。
## 契约
- [x] 确认本次不改后端接口,只做前端展示归一化和布局优化。[CONCEPT: 目标与非目标] 证据:风险看板和数字员工看板已有所需数据字段。
## 前端
- [x] 扩展风险看板中文化映射,覆盖风险信号、来源、状态、未知值和规则名。[CONCEPT: 功能能力] 证据:新增 `riskLabels.js``RiskObservationDashboard.vue``useOverviewView.js` 已接入统一中文化函数。
- [x] 将异常排行改为图表化主视觉,并保留紧凑明细。[CONCEPT: 前端方案] 证据:`RiskObservationDashboard.vue` 新增 `rankingChartItems``rankingDetailGroups``risk-ranking-visual`
- [x] 优化数字员工看板卡片跨度、等高布局和图表填充。[CONCEPT: 前端方案] 证据:`DigitalEmployeeDashboard.vue` 调整趋势/摘要/最近记录栅格,并新增 `digital-chart-fill``digital-card-fill`
- [x] 调整数字员工趋势图高度,使其跟随父容器填充。[CONCEPT: 前端方案] 证据:`DigitalEmployeeDailyWorkChart.vue` 高度改为 `100%` 并保留 `min-height`
## 测试
- [x] 更新风险看板源码测试,覆盖中文化和图表化异常排行。[CONCEPT: 测试方案] 证据:`risk-observation-dashboard.test.mjs` 新增中文化 helper 和排行图表断言。
- [x] 更新数字员工看板源码测试,覆盖布局填充类名和图表高度策略。[CONCEPT: 测试方案] 证据:`digital-employee-dashboard.test.mjs` 新增填充布局断言。
- [x] 运行风险看板定向测试。[CONCEPT: 测试方案] 证据:`node web/tests/risk-observation-dashboard.test.mjs`7 passed。
- [x] 运行数字员工看板定向测试。[CONCEPT: 测试方案] 证据:`node web/tests/digital-employee-dashboard.test.mjs`4 passed。
- [x] 运行前端构建验证。[CONCEPT: 测试方案] 证据:`npm.cmd --prefix web run build` 通过,仍有既有 Rollup 注释和大 chunk 警告。
## 验收
- [x] 确认风险看板可见文案不再暴露常见英文 key。[CONCEPT: 验收标准] 证据:测试覆盖 `duplicate_invoice``policy.duplicate_invoice``travel``rule_center``financial_risk_graph` 中文化。
- [x] 确认异常排行主视觉为图表形式。[CONCEPT: 验收标准] 证据:组件中异常排行由 `BarChart``rankingChartItems` 驱动。
- [x] 确认数字员工看板主要图表和卡片减少无效空白。[CONCEPT: 验收标准] 证据:趋势图、饼图、条形图和业务产出卡片均接入可填充容器。
- [x] 评估提交和推送范围,避免纳入无关脏工作区变更。[CONCEPT: 风险与开放问题] 证据:暂存区限定为分析看板前端、风险标签工具、定向测试和本开发文档,未纳入 `server/storage`、日志、临时截图等无关文件。

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

@@ -0,0 +1,38 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.notification_state import NotificationStateBatchPatch, NotificationStateListRead
from app.services.notification_states import NotificationStateService
router = APIRouter(prefix="/notification-states")
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@router.get(
"",
response_model=NotificationStateListRead,
summary="读取当前用户通知状态",
description="读取当前登录用户的小铃铛通知已读和隐藏状态,用于跨设备保持一致。",
)
def list_notification_states(db: DbSession, current_user: CurrentUser) -> NotificationStateListRead:
return NotificationStateService(db).list_states(current_user)
@router.post(
"",
response_model=NotificationStateListRead,
summary="批量保存当前用户通知状态",
description="批量保存当前登录用户的小铃铛通知已读和隐藏状态。",
)
def patch_notification_states(
payload: NotificationStateBatchPatch,
db: DbSession,
current_user: CurrentUser,
) -> NotificationStateListRead:
return NotificationStateService(db).patch_states(payload, current_user)

View File

@@ -20,6 +20,7 @@ from app.schemas.reimbursement import (
ExpenseClaimItemUpdate, ExpenseClaimItemUpdate,
ExpenseClaimRead, ExpenseClaimRead,
ExpenseClaimReturnPayload, ExpenseClaimReturnPayload,
ExpenseClaimStandardAdjustmentPayload,
ExpenseClaimUpdate, ExpenseClaimUpdate,
ReimbursementCreate, ReimbursementCreate,
ReimbursementRead, ReimbursementRead,
@@ -233,6 +234,43 @@ def update_expense_claim(
return claim return claim
@router.post(
"/claims/{claim_id}/standard-adjustment",
response_model=ExpenseClaimRead,
summary="接受职级报销标准重算",
description="在草稿报销单存在中高风险但提交人不补充异常说明时,按职级可报销标准重算实际报销金额。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "报销单状态不允许重算或入参不合法。",
},
},
)
def accept_expense_claim_standard_adjustment(
claim_id: str,
payload: ExpenseClaimStandardAdjustmentPayload,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimRead:
service = ExpenseClaimService(db)
try:
claim = service.accept_standard_adjustment(
claim_id=claim_id,
payload=payload,
current_user=current_user,
)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.patch( @router.patch(
"/claims/{claim_id}/items/{item_id}", "/claims/{claim_id}/items/{item_id}",
response_model=ExpenseClaimRead, response_model=ExpenseClaimRead,

View File

@@ -14,6 +14,7 @@ from app.api.v1.endpoints.employees import router as employees_router
from app.api.v1.endpoints.employee_profiles import router as employee_profiles_router from app.api.v1.endpoints.employee_profiles import router as employee_profiles_router
from app.api.v1.endpoints.health import router as health_router from app.api.v1.endpoints.health import router as health_router
from app.api.v1.endpoints.knowledge import router as knowledge_router from app.api.v1.endpoints.knowledge import router as knowledge_router
from app.api.v1.endpoints.notification_states import router as notification_states_router
from app.api.v1.endpoints.ocr import router as ocr_router from app.api.v1.endpoints.ocr import router as ocr_router
from app.api.v1.endpoints.ontology import router as ontology_router from app.api.v1.endpoints.ontology import router as ontology_router
from app.api.v1.endpoints.orchestrator import router as orchestrator_router from app.api.v1.endpoints.orchestrator import router as orchestrator_router
@@ -36,6 +37,7 @@ router.include_router(agent_traces_router, tags=["agent-traces"])
router.include_router(analytics_router, tags=["analytics"]) router.include_router(analytics_router, tags=["analytics"])
router.include_router(audit_logs_router, tags=["audit-logs"]) router.include_router(audit_logs_router, tags=["audit-logs"])
router.include_router(knowledge_router, tags=["knowledge"]) router.include_router(knowledge_router, tags=["knowledge"])
router.include_router(notification_states_router, tags=["notification-states"])
router.include_router(ocr_router, tags=["ocr"]) router.include_router(ocr_router, tags=["ocr"])
router.include_router(ontology_router, tags=["ontology"]) router.include_router(ontology_router, tags=["ontology"])
router.include_router(orchestrator_router, tags=["orchestrator"]) router.include_router(orchestrator_router, tags=["orchestrator"])

View File

@@ -23,6 +23,7 @@ from app.models.financial_record import (
) )
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
from app.models.hermes_report import HermesRiskReport from app.models.hermes_report import HermesRiskReport
from app.models.notification_state import NotificationState
from app.models.organization import OrganizationUnit from app.models.organization import OrganizationUnit
from app.models.reimbursement import ReimbursementRequest from app.models.reimbursement import ReimbursementRequest
from app.models.risk_observation import RiskObservation, RiskObservationFeedback from app.models.risk_observation import RiskObservation, RiskObservationFeedback
@@ -60,6 +61,7 @@ __all__ = [
"HermesTaskConfig", "HermesTaskConfig",
"HermesTaskExecutionLog", "HermesTaskExecutionLog",
"HermesRiskReport", "HermesRiskReport",
"NotificationState",
"OrganizationUnit", "OrganizationUnit",
"ReimbursementRequest", "ReimbursementRequest",
"RiskObservation", "RiskObservation",

View File

@@ -16,6 +16,7 @@ from app.models.financial_record import (
) )
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
from app.models.hermes_report import HermesRiskReport from app.models.hermes_report import HermesRiskReport
from app.models.notification_state import NotificationState
from app.models.organization import OrganizationUnit from app.models.organization import OrganizationUnit
from app.models.reimbursement import ReimbursementRequest from app.models.reimbursement import ReimbursementRequest
from app.models.risk_observation import RiskObservation, RiskObservationFeedback from app.models.risk_observation import RiskObservation, RiskObservationFeedback
@@ -51,6 +52,7 @@ __all__ = [
"HermesTaskConfig", "HermesTaskConfig",
"HermesTaskExecutionLog", "HermesTaskExecutionLog",
"HermesRiskReport", "HermesRiskReport",
"NotificationState",
"OrganizationUnit", "OrganizationUnit",
"ReimbursementRequest", "ReimbursementRequest",
"RiskObservation", "RiskObservation",

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

@@ -0,0 +1,32 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, Index, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.db.base_class import Base
class NotificationState(Base):
__tablename__ = "notification_states"
__table_args__ = (
UniqueConstraint("user_id", "notification_id", name="uq_notification_states_user_notification"),
Index("ix_notification_states_user_updated", "user_id", "updated_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id: Mapped[str] = mapped_column(String(100), index=True)
notification_id: Mapped[str] = mapped_column(String(180), index=True)
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
hidden_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
context_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
)

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
def _normalize_text(value: Any) -> str:
return str(value or "").strip()
class NotificationStatePatch(BaseModel):
notification_id: str = Field(min_length=1, max_length=180)
read: bool = False
hidden: bool = False
context_json: dict[str, Any] = Field(default_factory=dict)
@field_validator("notification_id", mode="before")
@classmethod
def normalize_notification_id(cls, value: Any) -> str:
return _normalize_text(value)
@field_validator("context_json", mode="before")
@classmethod
def normalize_context(cls, value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
class NotificationStateBatchPatch(BaseModel):
states: list[NotificationStatePatch] = Field(default_factory=list, max_length=100)
class NotificationStateRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
notification_id: str
read_at: datetime | None
hidden_at: datetime | None
context_json: dict[str, Any]
updated_at: datetime
class NotificationStateListRead(BaseModel):
states: list[NotificationStateRead] = Field(default_factory=list)

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
@@ -118,6 +121,19 @@ class ExpenseClaimUpdate(BaseModel):
reason: str | None = Field(default=None, max_length=500) reason: str | None = Field(default=None, max_length=500)
class ExpenseClaimStandardAdjustmentRisk(BaseModel):
risk_id: str | None = Field(default=None, max_length=120)
item_id: str | None = Field(default=None, max_length=120)
title: str | None = Field(default=None, max_length=120)
risk: str | None = Field(default=None, max_length=500)
original_amount: Decimal | None = None
reimbursable_amount: Decimal | None = None
class ExpenseClaimStandardAdjustmentPayload(BaseModel):
risks: list[ExpenseClaimStandardAdjustmentRisk] = Field(default_factory=list, max_length=20)
class ExpenseClaimRead(BaseModel): class ExpenseClaimRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -203,6 +219,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

@@ -26,6 +26,7 @@ EXPENSE_TYPE_LABELS = {
} }
MAX_DRAFT_CLAIMS_PER_USER = 3 MAX_DRAFT_CLAIMS_PER_USER = 3
EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned") EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned")
STANDARD_ADJUSTMENT_RISK_SOURCE = "reimbursement_standard_adjustment"
SYSTEM_GENERATED_ITEM_TYPES = {"travel_allowance"} SYSTEM_GENERATED_ITEM_TYPES = {"travel_allowance"}
OPTIONAL_ATTACHMENT_ITEM_TYPES = {"ride_ticket", "travel_allowance"} OPTIONAL_ATTACHMENT_ITEM_TYPES = {"ride_ticket", "travel_allowance"}
TRAVEL_DETAIL_ITEM_TYPES = { TRAVEL_DETAIL_ITEM_TYPES = {

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

@@ -110,6 +110,10 @@ from app.services.expense_rule_runtime import (
from app.services.ocr import OcrService from app.services.ocr import OcrService
APPROVED_APPLICATION_LINK_STATUSES = {"approved", "completed"}
INACTIVE_APPLICATION_LINK_REIMBURSEMENT_STATUSES = {"cancelled", "canceled", "deleted"}
class ExpenseClaimDraftFlowMixin: class ExpenseClaimDraftFlowMixin:
def upsert_draft_from_ontology( def upsert_draft_from_ontology(
self, self,
@@ -176,6 +180,12 @@ class ExpenseClaimDraftFlowMixin:
) )
is_new_claim = claim is None is_new_claim = claim is None
before_json = self._serialize_claim(claim) if claim is not None else None before_json = self._serialize_claim(claim) if claim is not None else None
application_link_block_result = self._build_application_link_block_result(
context_json=context_json,
target_claim=claim,
)
if application_link_block_result is not None:
return application_link_block_result
if is_new_claim: if is_new_claim:
existing_draft_count = self._count_draft_claims_for_owner( existing_draft_count = self._count_draft_claims_for_owner(
employee=employee, employee=employee,
@@ -517,6 +527,219 @@ class ExpenseClaimDraftFlowMixin:
return list(risk_flags or []) return list(risk_flags or [])
return [*list(risk_flags or []), link_flag] return [*list(risk_flags or []), link_flag]
def _build_application_link_block_result(
self,
*,
context_json: dict[str, Any],
target_claim: ExpenseClaim | None,
) -> dict[str, Any] | None:
link_flag = self._build_application_link_flag(context_json)
if link_flag is None:
return None
application_claim = self._find_application_claim_for_link(link_flag)
application_claim_no = str(link_flag.get("application_claim_no") or "").strip()
display_no = application_claim_no or "未编号申请单"
if application_claim is None or not self._is_expense_application_claim(application_claim):
return self._build_application_link_rejected_result(
f"未找到可关联的申请单 {display_no}。请先选择已审批通过的申请单。",
)
normalized_status = str(application_claim.status or "").strip().lower()
if normalized_status not in APPROVED_APPLICATION_LINK_STATUSES:
return self._build_application_link_rejected_result(
f"申请单 {application_claim.claim_no} 当前不是已审批通过状态,不能用于快速报销关联。",
application_claim=application_claim,
)
existing_reimbursement = self._find_existing_reimbursement_for_application_link(
application_claim=application_claim,
link_flag=link_flag,
target_claim=target_claim,
)
if existing_reimbursement is not None:
return self._build_application_link_rejected_result(
(
f"申请单 {application_claim.claim_no} 已经关联报销单 {existing_reimbursement.claim_no}"
"请进入该草稿或单据继续补充,不能重复生成。"
),
application_claim=application_claim,
existing_claim=existing_reimbursement,
)
return None
def _find_application_claim_for_link(self, link_flag: dict[str, Any]) -> ExpenseClaim | None:
application_claim_id = str(link_flag.get("application_claim_id") or "").strip()
application_claim_no = str(link_flag.get("application_claim_no") or "").strip()
if application_claim_id:
claim = self.db.get(ExpenseClaim, application_claim_id)
if claim is not None and self._is_expense_application_claim(claim):
return claim
if application_claim_no:
return self.db.scalar(
select(ExpenseClaim)
.where(ExpenseClaim.claim_no == application_claim_no)
.limit(1)
)
return None
def _find_existing_reimbursement_for_application_link(
self,
*,
application_claim: ExpenseClaim,
link_flag: dict[str, Any],
target_claim: ExpenseClaim | None,
) -> ExpenseClaim | None:
generated_draft = self._find_generated_reimbursement_from_application(
application_claim=application_claim,
target_claim=target_claim,
)
if generated_draft is not None:
return generated_draft
linked_ids, linked_nos = self._collect_application_link_reference_values(link_flag)
linked_ids.add(str(application_claim.id or "").strip())
linked_nos.add(str(application_claim.claim_no or "").strip().upper())
linked_ids.discard("")
linked_nos.discard("")
for claim in list(self.db.scalars(select(ExpenseClaim)).all()):
if self._is_same_target_claim(claim, target_claim):
continue
if self._is_expense_application_claim(claim):
continue
if self._is_inactive_application_link_reimbursement(claim):
continue
if self._claim_references_application(claim, linked_ids=linked_ids, linked_nos=linked_nos):
return claim
return None
def _find_generated_reimbursement_from_application(
self,
*,
application_claim: ExpenseClaim,
target_claim: ExpenseClaim | None,
) -> ExpenseClaim | None:
for flag in list(application_claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
generated_draft_id = str(
flag.get("generated_draft_claim_id")
or flag.get("generatedDraftClaimId")
or ""
).strip()
generated_draft_no = str(
flag.get("generated_draft_claim_no")
or flag.get("generatedDraftClaimNo")
or ""
).strip()
claim = self.db.get(ExpenseClaim, generated_draft_id) if generated_draft_id else None
if claim is None and generated_draft_no:
claim = self.db.scalar(
select(ExpenseClaim)
.where(ExpenseClaim.claim_no == generated_draft_no)
.limit(1)
)
if claim is None:
continue
if self._is_same_target_claim(claim, target_claim):
continue
if self._is_expense_application_claim(claim):
continue
if self._is_inactive_application_link_reimbursement(claim):
continue
return claim
return None
@staticmethod
def _is_same_target_claim(claim: ExpenseClaim, target_claim: ExpenseClaim | None) -> bool:
return bool(target_claim is not None and claim.id == target_claim.id)
@staticmethod
def _is_inactive_application_link_reimbursement(claim: ExpenseClaim) -> bool:
status = str(claim.status or "").strip().lower()
return status in INACTIVE_APPLICATION_LINK_REIMBURSEMENT_STATUSES
@classmethod
def _claim_references_application(
cls,
claim: ExpenseClaim,
*,
linked_ids: set[str],
linked_nos: set[str],
) -> bool:
for flag in list(claim.risk_flags_json or []):
flag_ids, flag_nos = cls._collect_application_link_reference_values(flag)
if flag_ids.intersection(linked_ids) or flag_nos.intersection(linked_nos):
return True
return False
@classmethod
def _collect_application_link_reference_values(cls, payload: Any) -> tuple[set[str], set[str]]:
ids: set[str] = set()
claim_nos: set[str] = set()
if not isinstance(payload, dict):
return ids, claim_nos
cls._add_application_link_reference(ids, claim_nos, payload)
for key in (
"application_detail",
"applicationDetail",
"review_form_values",
"reviewFormValues",
"expense_scene_selection",
"expenseSceneSelection",
):
nested_ids, nested_nos = cls._collect_application_link_reference_values(payload.get(key))
ids.update(nested_ids)
claim_nos.update(nested_nos)
ids.discard("")
claim_nos.discard("")
return ids, claim_nos
@staticmethod
def _add_application_link_reference(
ids: set[str],
claim_nos: set[str],
payload: dict[str, Any],
) -> None:
for key in ("application_claim_id", "applicationClaimId"):
ids.add(str(payload.get(key) or "").strip())
for key in ("application_claim_no", "applicationClaimNo"):
claim_nos.add(str(payload.get(key) or "").strip().upper())
@staticmethod
def _build_application_link_rejected_result(
message: str,
*,
application_claim: ExpenseClaim | None = None,
existing_claim: ExpenseClaim | None = None,
) -> dict[str, Any]:
result: dict[str, Any] = {
"message": message,
"draft_only": False,
"status": "blocked",
"application_link_blocked": True,
"submission_blocked": True,
"submission_blocked_reasons": [message],
"missing_fields": [message],
"risk_flags": ["application_link_blocked"],
}
if application_claim is not None:
result["application_claim_id"] = application_claim.id
result["application_claim_no"] = application_claim.claim_no
result["application_status"] = application_claim.status
if existing_claim is not None:
result["existing_claim_id"] = existing_claim.id
result["existing_claim_no"] = existing_claim.claim_no
result["existing_claim_status"] = existing_claim.status
return result
@staticmethod @staticmethod
def _build_application_link_flag(context_json: dict[str, Any]) -> dict[str, Any] | None: def _build_application_link_flag(context_json: dict[str, Any]) -> dict[str, Any] | None:
review_values = ExpenseClaimDraftFlowMixin._normalize_context_object( review_values = ExpenseClaimDraftFlowMixin._normalize_context_object(

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import re import re
from datetime import UTC, date, datetime, timedelta from datetime import UTC, date, datetime, timedelta
from decimal import Decimal from decimal import Decimal, InvalidOperation
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
@@ -24,6 +24,7 @@ from app.services.expense_claim_constants import (
DOCUMENT_FACT_ITEM_TYPES, DOCUMENT_FACT_ITEM_TYPES,
LOCATION_REQUIRED_EXPENSE_TYPES, LOCATION_REQUIRED_EXPENSE_TYPES,
OPTIONAL_ATTACHMENT_ITEM_TYPES, OPTIONAL_ATTACHMENT_ITEM_TYPES,
STANDARD_ADJUSTMENT_RISK_SOURCE,
SYSTEM_GENERATED_ITEM_TYPES, SYSTEM_GENERATED_ITEM_TYPES,
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES, TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN, TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
@@ -329,6 +330,45 @@ class ExpenseClaimItemSyncMixin:
return destination return destination
return "" return ""
@staticmethod
def _parse_standard_adjustment_amount(value: Any) -> Decimal | None:
try:
raw_value = "" if value is None else value
amount = Decimal(str(raw_value)).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return None
return amount if amount >= Decimal("0.00") else None
def _collect_standard_adjusted_amounts(self, claim: ExpenseClaim) -> dict[str, Decimal]:
adjusted_amounts: dict[str, Decimal] = {}
for flag in list(claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
if str(flag.get("source") or "").strip() != STANDARD_ADJUSTMENT_RISK_SOURCE:
continue
item_id = str(flag.get("item_id") or flag.get("itemId") or "").strip()
if not item_id:
continue
amount = self._parse_standard_adjustment_amount(
flag.get("reimbursable_amount") or flag.get("reimbursableAmount")
)
if amount is None:
continue
adjusted_amounts[item_id] = amount
return adjusted_amounts
def _resolve_item_amount_for_claim_total(
self,
item: ExpenseClaimItem,
adjusted_amounts: dict[str, Decimal],
) -> Decimal:
original_amount = Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01"))
item_id = str(item.id or "").strip()
adjusted_amount = adjusted_amounts.get(item_id)
if adjusted_amount is None:
return original_amount
return min(max(adjusted_amount, Decimal("0.00")), original_amount)
def _sync_claim_from_items(self, claim: ExpenseClaim) -> None: def _sync_claim_from_items(self, claim: ExpenseClaim) -> None:
self._sync_travel_allowance_item(claim) self._sync_travel_allowance_item(claim)
if not claim.items: if not claim.items:
@@ -346,7 +386,11 @@ class ExpenseClaimItemSyncMixin:
), ),
) )
primary_item = ordered_items[0] primary_item = ordered_items[0]
total_amount = sum((item.item_amount for item in ordered_items), Decimal("0.00")) adjusted_amounts = self._collect_standard_adjusted_amounts(claim)
total_amount = sum(
(self._resolve_item_amount_for_claim_total(item, adjusted_amounts) for item in ordered_items),
Decimal("0.00"),
)
claim.amount = total_amount.quantize(Decimal("0.01")) claim.amount = total_amount.quantize(Decimal("0.01"))
claim.invoice_count = sum(1 for item in ordered_items if str(item.invoice_id or "").strip()) claim.invoice_count = sum(1 for item in ordered_items if str(item.invoice_id or "").strip())

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

@@ -72,7 +72,11 @@ class ExpenseClaimPolicyReviewMixin:
limit_config=item_limit, limit_config=item_limit,
reason_text="\n".join( reason_text="\n".join(
part part
for part in [reason_corpus, str(item.item_reason or "").strip()] for part in [
reason_corpus,
str(item.item_reason or "").strip(),
str(item.item_note or "").strip(),
]
if part if part
), ),
) )
@@ -333,7 +337,12 @@ class ExpenseClaimPolicyReviewMixin:
f"{band_label} 职级在{standard_label}的住宿标准为 {cap} 元/晚," f"{band_label} 职级在{standard_label}的住宿标准为 {cap} 元/晚,"
f"当前酒店识别金额约 {nightly_amount} 元/晚。" f"当前酒店识别金额约 {nightly_amount} 元/晚。"
) )
item_reason = str(context["item"].item_reason or "").strip() item_reason = " ".join(
[
str(context["item"].item_reason or "").strip(),
str(context["item"].item_note or "").strip(),
]
).strip()
item_has_exception = self._text_contains_keywords(item_reason, policy.standard_exception_keywords) item_has_exception = self._text_contains_keywords(item_reason, policy.standard_exception_keywords)
if has_standard_exception or item_has_exception: if has_standard_exception or item_has_exception:
flags.append( flags.append(
@@ -368,7 +377,12 @@ class ExpenseClaimPolicyReviewMixin:
if allowed_level is None or class_level <= allowed_level: if allowed_level is None or class_level <= allowed_level:
continue continue
item_reason = str(context["item"].item_reason or "").strip() item_reason = " ".join(
[
str(context["item"].item_reason or "").strip(),
str(context["item"].item_note or "").strip(),
]
).strip()
item_has_exception = self._text_contains_keywords(item_reason, policy.standard_exception_keywords) item_has_exception = self._text_contains_keywords(item_reason, policy.standard_exception_keywords)
message = f"{band_label} 职级当前默认不可报销 {class_label}" message = f"{band_label} 职级当前默认不可报销 {class_label}"
if has_standard_exception or item_has_exception: if has_standard_exception or item_has_exception:
@@ -463,6 +477,7 @@ class ExpenseClaimPolicyReviewMixin:
parts = [str(claim.reason or "").strip(), str(claim.location or "").strip()] parts = [str(claim.reason or "").strip(), str(claim.location or "").strip()]
for item in claim.items: for item in claim.items:
parts.append(str(item.item_reason or "").strip()) parts.append(str(item.item_reason or "").strip())
parts.append(str(item.item_note or "").strip())
parts.append(str(item.item_location or "").strip()) parts.append(str(item.item_location or "").strip())
return "\n".join(part for part in parts if part) return "\n".join(part for part in parts if part)

View File

@@ -27,6 +27,7 @@ from app.schemas.ontology import OntologyEntity, OntologyParseResult
from app.schemas.reimbursement import ( from app.schemas.reimbursement import (
ExpenseClaimItemCreate, ExpenseClaimItemCreate,
ExpenseClaimItemUpdate, ExpenseClaimItemUpdate,
ExpenseClaimStandardAdjustmentPayload,
ExpenseClaimUpdate, ExpenseClaimUpdate,
TravelReimbursementCalculatorRequest, TravelReimbursementCalculatorRequest,
) )
@@ -36,6 +37,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 +59,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,
@@ -107,6 +110,7 @@ from app.services.expense_claim_constants import (
TRAVEL_POLICY_FLIGHT_CLASS_PATTERNS, TRAVEL_POLICY_FLIGHT_CLASS_PATTERNS,
TRAVEL_POLICY_TRAIN_CLASS_PATTERNS, TRAVEL_POLICY_TRAIN_CLASS_PATTERNS,
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN, TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
STANDARD_ADJUSTMENT_RISK_SOURCE,
) )
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
from app.services.expense_amounts import ( from app.services.expense_amounts import (
@@ -288,6 +292,126 @@ class ExpenseClaimService(
return claim return claim
@staticmethod
def _normalize_standard_adjustment_amount(value: Any) -> Decimal | None:
try:
raw_value = "" if value is None else value
amount = Decimal(str(raw_value)).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return None
return amount if amount >= Decimal("0.00") else None
@staticmethod
def _format_adjustment_money(value: Decimal) -> str:
normalized = Decimal(value or Decimal("0.00")).quantize(Decimal("0.01"))
return f"{normalized:.2f}"
def accept_standard_adjustment(
self,
*,
claim_id: str,
payload: ExpenseClaimStandardAdjustmentPayload,
current_user: CurrentUserContext,
) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
if claim is None:
return None
self._ensure_draft_claim(claim)
if self._is_expense_application_claim(claim):
raise ValueError("费用申请单不支持按报销标准重算。")
risk_entries = list(payload.risks or [])
if not risk_entries:
raise ValueError("请至少选择一条需要按职级标准重算的风险。")
before_json = self._serialize_claim(claim)
item_map = {str(item.id or "").strip(): item for item in list(claim.items or [])}
now_text = datetime.now(UTC).isoformat()
adjustment_flags: list[dict[str, Any]] = []
for index, entry in enumerate(risk_entries, start=1):
item_id = str(entry.item_id or "").strip()
item = item_map.get(item_id)
if item is None:
continue
original_amount = (
self._normalize_standard_adjustment_amount(entry.original_amount)
or Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01"))
)
reimbursable_amount = (
self._normalize_standard_adjustment_amount(entry.reimbursable_amount)
or original_amount
)
reimbursable_amount = min(max(reimbursable_amount, Decimal("0.00")), original_amount)
employee_absorbed_amount = (original_amount - reimbursable_amount).quantize(Decimal("0.01"))
item_label = (
str(item.item_reason or "").strip()
or str(entry.title or "").strip()
or f"费用明细第 {index}"
)
source_risk = str(entry.risk or entry.title or "原风险未补充异常说明").strip()
message = (
f"提交人已选择按职级最高报销标准审核:{item_label} 原票据金额 "
f"{self._format_adjustment_money(original_amount)} 元,实际报销金额 "
f"{self._format_adjustment_money(reimbursable_amount)} 元,超出 "
f"{self._format_adjustment_money(employee_absorbed_amount)} 元由员工自行承担。"
)
adjustment_flags.append(
with_risk_business_stage(
{
"source": STANDARD_ADJUSTMENT_RISK_SOURCE,
"event_type": "standard_adjustment_accepted",
"severity": "medium",
"label": "接受职级标准审核",
"title": "提交人接受职级最高报销标准",
"message": message,
"summary": "提交人未补充异常说明,已选择按职级最高报销标准重算实际报销金额。",
"suggestion": "领导和财务审批时请确认该差额由员工自行承担,并按实际报销金额入账。",
"risk_id": str(entry.risk_id or "").strip(),
"source_risk": source_risk,
"item_id": item_id,
"original_amount": self._format_adjustment_money(original_amount),
"reimbursable_amount": self._format_adjustment_money(reimbursable_amount),
"employee_absorbed_amount": self._format_adjustment_money(employee_absorbed_amount),
"risk_domain": "amount",
"actionability": "review_decision",
"visibility_scope": "leader",
"created_at": now_text,
},
"reimbursement",
)
)
if not adjustment_flags:
raise ValueError("未找到可按职级标准重算的费用明细。")
preserved_flags = [
flag
for flag in list(claim.risk_flags_json or [])
if not (
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == STANDARD_ADJUSTMENT_RISK_SOURCE
)
]
claim.risk_flags_json = [*preserved_flags, *adjustment_flags]
self._sync_claim_from_items(claim)
self.db.commit()
self.db.refresh(claim)
self.audit_service.log_action(
actor=current_user.name or current_user.username,
action="expense_claim.standard_adjustment_accept",
resource_type="expense_claim",
resource_id=claim.id,
before_json=before_json,
after_json=self._serialize_claim(claim),
)
return claim
def update_claim_item( def update_claim_item(
self, self,
*, *,
@@ -320,6 +444,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 +502,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 +589,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 +708,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()
@@ -744,14 +877,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

@@ -0,0 +1,88 @@
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.db.base import Base
from app.models.notification_state import NotificationState
from app.schemas.notification_state import (
NotificationStateBatchPatch,
NotificationStateListRead,
NotificationStateRead,
)
class NotificationStateService:
def __init__(self, db: Session) -> None:
self.db = db
def ensure_storage_ready(self) -> None:
Base.metadata.create_all(bind=self.db.get_bind(), tables=[NotificationState.__table__])
def list_states(self, current_user: CurrentUserContext) -> NotificationStateListRead:
self.ensure_storage_ready()
stmt = (
select(NotificationState)
.where(NotificationState.user_id == self._user_key(current_user))
.order_by(NotificationState.updated_at.desc())
)
states = list(self.db.scalars(stmt).all())
return NotificationStateListRead(
states=[NotificationStateRead.model_validate(item) for item in states]
)
def patch_states(
self,
payload: NotificationStateBatchPatch,
current_user: CurrentUserContext,
) -> NotificationStateListRead:
self.ensure_storage_ready()
user_id = self._user_key(current_user)
patches = [item for item in payload.states if item.notification_id]
if not patches:
return self.list_states(current_user)
ids = {item.notification_id for item in patches}
existing_rows = list(
self.db.scalars(
select(NotificationState).where(
NotificationState.user_id == user_id,
NotificationState.notification_id.in_(ids),
)
).all()
)
existing_by_id = {item.notification_id: item for item in existing_rows}
now = datetime.now(UTC)
for patch in patches:
row = existing_by_id.get(patch.notification_id)
if row is None:
row = NotificationState(
user_id=user_id,
notification_id=patch.notification_id,
context_json={},
)
self.db.add(row)
existing_by_id[patch.notification_id] = row
if patch.read and row.read_at is None:
row.read_at = now
if patch.hidden and row.hidden_at is None:
row.hidden_at = now
if patch.context_json:
row.context_json = self._merge_context(row.context_json, patch.context_json)
self.db.commit()
return self.list_states(current_user)
@staticmethod
def _user_key(current_user: CurrentUserContext) -> str:
return str(current_user.username or current_user.name or "anonymous").strip() or "anonymous"
@staticmethod
def _merge_context(current: dict | None, patch: dict) -> dict:
base = current if isinstance(current, dict) else {}
return {**base, **patch}

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,188 @@
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",
"original_amount",
"reimbursable_amount",
"employee_absorbed_amount",
}
)
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

@@ -1,121 +0,0 @@
{
"id": "057901b1-d38a-4e0c-9d53-44d14244317e",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-06-01T06:39:27.813933+00:00",
"status": "unlinked",
"linked_claim_id": "",
"linked_claim_no": "",
"linked_item_id": "",
"linked_at": "",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580968717734019,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "武汉"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "上海虹桥"
},
{
"key": "train_no",
"label": "车次",
"value": "G458"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6580061086021391007342026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "06车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "01B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

View File

@@ -1,121 +0,0 @@
{
"id": "0abbdfaf-7952-4854-a5b2-bc34298fa1c4",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-06-01T06:08:52.697458+00:00",
"status": "linked",
"linked_claim_id": "19a8eaf2-13f2-45fc-b389-a292fadfd6d8",
"linked_claim_no": "RE-20260601060546-EE2PHJRK",
"linked_item_id": "b1b343b0-3564-4d35-919a-0e4220a9fceb",
"linked_at": "2026-06-01T06:08:52.697458+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580968717734019,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "武汉"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "上海虹桥"
},
{
"key": "train_no",
"label": "车次",
"value": "G458"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6580061086021391007342026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "06车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "01B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,66 +0,0 @@
{
"id": "29ec7c4c-abc0-46f0-8eae-3136c2d6fef7",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-05-30T07:00:12.286631+00:00",
"status": "unlinked",
"linked_claim_id": "",
"linked_claim_no": "",
"linked_item_id": "",
"linked_at": "",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580968717734019,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,66 +0,0 @@
{
"id": "2fd4856f-e918-4d29-a0b2-340cc1fdec03",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-05-30T07:00:40.560540+00:00",
"status": "linked",
"linked_claim_id": "6b8e29c9-8bd6-453b-b594-ed0e5b59b91f",
"linked_claim_no": "RE-20260530065944-M94FAPB9",
"linked_item_id": "329f477a-d926-4101-8ec8-4c8a95150f22",
"linked_at": "2026-05-30T07:00:40.560540+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580968717734019,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,16 +1,16 @@
{ {
"id": "e8d4f21f-846f-4321-a341-52cd3dfb5acc", "id": "33a1c4b9-56e1-49d1-823e-5cb4680f5a40",
"owner_key": "caoxiaozhu_xf.com", "owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf", "file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf", "source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf", "media_type": "application/pdf",
"size_bytes": 24995, "size_bytes": 24995,
"uploaded_at": "2026-06-01T06:57:44.644255+00:00", "uploaded_at": "2026-06-03T08:39:19.288158+00:00",
"status": "linked", "status": "linked",
"linked_claim_id": "19a8eaf2-13f2-45fc-b389-a292fadfd6d8", "linked_claim_id": "2ad80b25-b153-407e-91be-ed2651045fb1",
"linked_claim_no": "RE-20260601060546-EE2PHJRK", "linked_claim_no": "RE-20260603083825-876B85XW",
"linked_item_id": "26ccabf8-7e69-4812-acc6-fa18899ec5b2", "linked_item_id": "eb1e9fde-b7e8-4f6e-823f-d8252489e7f9",
"linked_at": "2026-06-01T06:57:44.644255+00:00", "linked_at": "2026-06-03T08:39:19.288158+00:00",
"engine": "paddleocr_mobile", "engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile", "model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快", "ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",

View File

@@ -1,121 +0,0 @@
{
"id": "64b90764-b957-4a54-b231-0646ee60d1cd",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-06-01T06:08:07.688668+00:00",
"status": "unlinked",
"linked_claim_id": "",
"linked_claim_no": "",
"linked_item_id": "",
"linked_at": "",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580968717734019,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "武汉"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "上海虹桥"
},
{
"key": "train_no",
"label": "车次",
"value": "G458"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6580061086021391007342026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "06车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "01B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,121 +0,0 @@
{
"id": "67dc947b-be67-444d-9a91-897190170021",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-06-01T06:40:08.599943+00:00",
"status": "linked",
"linked_claim_id": "19a8eaf2-13f2-45fc-b389-a292fadfd6d8",
"linked_claim_no": "RE-20260601060546-EE2PHJRK",
"linked_item_id": "c1693c4a-dcbd-4a8b-941c-bb0abc6ec65a",
"linked_at": "2026-06-01T06:40:08.599943+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580968717734019,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "武汉"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "上海虹桥"
},
{
"key": "train_no",
"label": "车次",
"value": "G458"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6580061086021391007342026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "06车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "01B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,16 +1,16 @@
{ {
"id": "fa80f870-10a3-4797-a151-39e702927eb5", "id": "67f51c17-a2bc-42bd-99a4-199ee32b18c3",
"owner_key": "caoxiaozhu_xf.com", "owner_key": "caoxiaozhu_xf.com",
"file_name": "2月23_上海-武汉.pdf", "file_name": "2月23_上海-武汉.pdf",
"source_file_name": "2月23_上海-武汉.pdf", "source_file_name": "2月23_上海-武汉.pdf",
"media_type": "application/pdf", "media_type": "application/pdf",
"size_bytes": 24940, "size_bytes": 24940,
"uploaded_at": "2026-06-01T06:40:32.249473+00:00", "uploaded_at": "2026-06-03T08:40:26.766004+00:00",
"status": "linked", "status": "linked",
"linked_claim_id": "19a8eaf2-13f2-45fc-b389-a292fadfd6d8", "linked_claim_id": "2ad80b25-b153-407e-91be-ed2651045fb1",
"linked_claim_no": "RE-20260601060546-EE2PHJRK", "linked_claim_no": "RE-20260603083825-876B85XW",
"linked_item_id": "b0b28405-30b5-4c35-9bd5-13abe4d2c4cd", "linked_item_id": "977f01f8-e7ab-487b-8055-db8864464784",
"linked_at": "2026-06-01T06:40:32.249473+00:00", "linked_at": "2026-06-03T08:40:26.766004+00:00",
"engine": "paddleocr_mobile", "engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile", "model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票\n州\n国家税务总局\n发票号码26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快", "ocr_text": "电子发票\n铁路电子客票\n州\n国家税务总局\n发票号码26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",

View File

@@ -1,121 +0,0 @@
{
"id": "6a7335e9-b36a-415c-b2d5-26f421ec72b0",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月23_上海-武汉.pdf",
"source_file_name": "2月23_上海-武汉.pdf",
"media_type": "application/pdf",
"size_bytes": 24940,
"uploaded_at": "2026-06-01T06:08:07.724830+00:00",
"status": "unlinked",
"linked_claim_id": "",
"linked_claim_no": "",
"linked_item_id": "",
"linked_at": "",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票\n州\n国家税务总局\n发票号码26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9620026834309101,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-23 13:54"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26319166100006175398"
},
{
"key": "route",
"label": "行程",
"value": "上海-武汉"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "上海虹桥"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "武汉"
},
{
"key": "train_no",
"label": "车次",
"value": "G456"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6610061086021394837402026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "12车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "08B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

View File

@@ -0,0 +1,55 @@
{
"id": "8678e169-d800-4846-9354-eb768a9b65f8",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "酒店3.jpg",
"source_file_name": "酒店3.jpg",
"media_type": "image/jpeg",
"size_bytes": 153582,
"uploaded_at": "2026-06-03T08:41:47.393654+00:00",
"status": "linked",
"linked_claim_id": "2ad80b25-b153-407e-91be-ed2651045fb1",
"linked_claim_no": "RE-20260603083825-876B85XW",
"linked_item_id": "42250571-3e84-4f27-b3e8-aa224b5cb2f7",
"linked_at": "2026-06-03T08:41:47.393654+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "上海喜来登酒店(样例)\n住宿费用单\n单据编号SH-SAMPLE-20260223-001\n开单期2026年223\n宾客姓名曹笑\n住期2026年220\n离店期2026年223\n住晚数3晚\n房型豪华床房\n房号1808\n项目\n日期\n数量\n单价\n金额\n备注\n住宿费\n2026-02-20至2026-02-22\n3晚\n¥362/晚\n¥1086\n豪华大床房\n金额大写壹仟零捌拾陆元整\n合计¥1086\n备注\n1.如有疑问请致电前台021-28958888。\n2.退房时间为中午1200超时退房将按酒店规定收取相关费用。\n3.感谢您的下榻,期待您的再次光临!\n酒店地址上海市浦东新区银城中路88号 邮编200120\n样例票据|仅供系统测试|无效凭证",
"summary": "上海喜来登酒店样例住宿费用单单据编号SH-SAMPLE-20260223-001",
"ocr_avg_score": 0.988790222009023,
"ocr_line_count": 30,
"page_count": 1,
"document_type": "hotel_invoice",
"document_type_label": "酒店住宿票据",
"scene_code": "hotel",
"scene_label": "住宿票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.71,
"ocr_classification_evidence": [
"住宿",
"离店",
"酒店"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "1086元"
},
{
"key": "date",
"label": "日期",
"value": "2026-02-20"
},
{
"key": "merchant_name",
"label": "商户",
"value": "上海喜来登酒店"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.jpg",
"preview_media_type": "image/jpeg"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

View File

@@ -1,121 +0,0 @@
{
"id": "87c5ddbf-15be-4d21-982d-808267902ab0",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月23_上海-武汉.pdf",
"source_file_name": "2月23_上海-武汉.pdf",
"media_type": "application/pdf",
"size_bytes": 24940,
"uploaded_at": "2026-06-01T06:39:27.857168+00:00",
"status": "unlinked",
"linked_claim_id": "",
"linked_claim_no": "",
"linked_item_id": "",
"linked_at": "",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票\n州\n国家税务总局\n发票号码26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9620026834309101,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-23 13:54"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26319166100006175398"
},
{
"key": "route",
"label": "行程",
"value": "上海-武汉"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "上海虹桥"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "武汉"
},
{
"key": "train_no",
"label": "车次",
"value": "G456"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6610061086021394837402026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "12车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "08B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,121 +0,0 @@
{
"id": "fd4f2229-bf01-48ae-99b9-b98458e2b632",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-06-01T06:56:59.257104+00:00",
"status": "unlinked",
"linked_claim_id": "",
"linked_claim_no": "",
"linked_item_id": "",
"linked_at": "",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580968717734019,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "武汉"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "上海虹桥"
},
{
"key": "train_no",
"label": "车次",
"value": "G458"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6580061086021391007342026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "06车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "01B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Some files were not shown because too many files have changed in this diff Show More