18 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
169 changed files with 9001 additions and 2763 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

@@ -105,3 +105,7 @@ $$
- 当前数据来自工作台前端汇总,历史维度受首页已加载单据范围影响;若后续需要跨年或分页全量统计,应补后端专用接口。 - 当前数据来自工作台前端汇总,历史维度受首页已加载单据范围影响;若后续需要跨年或分页全量统计,应补后端专用接口。
- 单据类型归类依赖标题、场景和备注,属于前端轻量归类;后续可与 ontology 费用类别字段打通。 - 单据类型归类依赖标题、场景和备注,属于前端轻量归类;后续可与 ontology 费用类别字段打通。
## 2026-06-03 饼图呈现修正
费用分布仍复用项目已有 `DonutChart`,但在费用统计详情弹窗内关闭组件自带图例,只保留一个环形饼图入口。费用类型、金额、笔数和占比改为右侧文字明细列表,避免环图主体和双列图例在同一卡片内被误认为出现两个饼图。

View File

@@ -13,6 +13,8 @@
- [x] 新增费用统计详情弹窗组件,展示三个详情区块和空状态。[CONCEPT: 功能能力] 证据:新增 `ExpenseStatsDetailModal.vue` - [x] 新增费用统计详情弹窗组件,展示三个详情区块和空状态。[CONCEPT: 功能能力] 证据:新增 `ExpenseStatsDetailModal.vue`
- [x]`PersonalWorkbench.vue` 接入弹窗状态与费用统计“查看详情”按钮。[CONCEPT: 方案设计] 证据:新增 `expenseStatsModalOpen``openExpenseStatsModal` - [x]`PersonalWorkbench.vue` 接入弹窗状态与费用统计“查看详情”按钮。[CONCEPT: 方案设计] 证据:新增 `expenseStatsModalOpen``openExpenseStatsModal`
- [x] 将费用分布区从条形列表改为 `DonutChart` 饼图展示。[CONCEPT: 功能能力] 证据:`ExpenseStatsDetailModal.vue` 已接入 `DonutChart``distributionChartItems` - [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` 状态。
## 测试与验证 ## 测试与验证
@@ -21,8 +23,9 @@
- [x] 补充弹窗源码测试,覆盖费用分布、处理时间、系统操作详情区块。[CONCEPT: 测试方案] 证据:`node web/tests/expense-stats-detail-modal.test.mjs` 通过。 - [x] 补充弹窗源码测试,覆盖费用分布、处理时间、系统操作详情区块。[CONCEPT: 测试方案] 证据:`node web/tests/expense-stats-detail-modal.test.mjs` 通过。
- [x] 运行前端定向测试和构建验证。[CONCEPT: 指标与验收] 证据:以上定向测试和 `npm.cmd --prefix web run build` 均通过。 - [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: 测试方案] 证据:`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] 复查本次暂存范围,避免纳入无关工作区改动。[CONCEPT: 风险与开放问题] 证据:`git diff --cached --name-only` 仅包含本次工作台弹窗、样式、汇总测试和开发文档。
- [ ] 提交并 push 本次功能分支。[CONCEPT: 指标与验收] - [x] 提交并 push 本次功能分支。[CONCEPT: 指标与验收] 证据:本次单饼图修复完成后提交并推送当前分支。

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

@@ -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
}, },
"severity": "high", "components": {
"risk_score": 84, "impact": 78,
"risk_level": "high" "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": "medium",
"risk_score": 60,
"risk_level": "medium",
"risk_level_label": "中风险",
"risk_score_detail": {
"score": 60,
"level": "medium",
"level_label": "中风险",
"model": "risk_score_v3",
"weights": {
"impact": 0.35,
"certainty": 0.25,
"evidence": 0.15,
"exception": 0.1,
"action": 0.1,
"sensitivity": 0.05
},
"components": {
"impact": 78,
"certainty": 58,
"evidence": 62,
"exception": 35,
"action": 35,
"sensitivity": 45
},
"calibration": {
"raw_score": 60,
"rules": []
},
"ai_evidence": {},
"basis": {
"template_key": "keyword_match_v1",
"field_count": 12,
"condition_count": 0,
"expense_category": null,
"expense_category_label": "预算管控",
"requires_attachment": false
}
}
} }

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,8 +236,8 @@ 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)
@@ -281,8 +279,8 @@ 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
@@ -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)
try:
budget_flags = self._reserve_budget_for_submission( budget_flags = self._reserve_budget_for_submission(
claim, claim,
current_user, current_user,
is_application_claim=is_application_claim, 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]]:
today = now.date()
key = str(trend_range or "").strip()
if key in {"custom", "自定义"} and fallback_start and fallback_end:
start_day = fallback_start.date()
end_day = (fallback_end - timedelta(days=1)).date()
elif key == "今日":
start_day = today
end_day = today
elif key == "本周":
start_day = today - timedelta(days=today.weekday())
end_day = today
elif key == "本月":
start_day = today.replace(day=1)
end_day = today
else:
days = self._days_from_label(trend_range, default=12) days = self._days_from_label(trend_range, default=12)
end_day = now.date() end_day = today
start_day = end_day - timedelta(days=days - 1) 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,8 +152,7 @@ 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
@@ -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,8 +240,7 @@ 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( return self._build_slot_value(
value=value, value=value,
@@ -281,16 +250,6 @@ class UserAgentReviewSlotMixin:
evidence="来源于用户修改后的结构化表单。", evidence="来源于用户修改后的结构化表单。",
) )
application_location = str(review_form_values.get("application_location") or "").strip()
if application_location:
return self._build_slot_value(
value=application_location,
normalized_value=application_location,
source="detail_context",
confidence=0.86,
evidence="来源于已关联申请单,作为本次报销草稿的地点依据。",
)
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")
if isinstance(request_context, dict): if isinstance(request_context, dict):
@@ -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"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -202,7 +202,7 @@ def test_non_standard_finance_rule_spreadsheets_are_not_seeded() -> None:
assert asset is None or asset.config_json["tag"] == "废弃规则" assert asset is None or asset.config_json["tag"] == "废弃规则"
def test_demo_budget_risk_rules_sync_with_finance_rule_references() -> None: def test_demo_budget_risk_rules_are_excluded_from_risk_rule_center() -> None:
with build_session() as db: with build_session() as db:
service = AgentAssetService(db) service = AgentAssetService(db)
service.list_assets(asset_type=AgentAssetType.RULE.value) service.list_assets(asset_type=AgentAssetType.RULE.value)
@@ -218,16 +218,7 @@ def test_demo_budget_risk_rules_sync_with_finance_rule_references() -> None:
) )
) )
assert budget_rule is not None assert budget_rule is None
assert budget_rule.scenario_json == ["全部"]
assert budget_rule.config_json["budget_required"] is True
assert budget_rule.config_json["expense_types"] == ["all"]
assert budget_rule.config_json["business_stage"] == [
"expense_application",
"reimbursement",
"budget_execution",
]
assert budget_rule.config_json["finance_rule_code"] == "budget.execution.policy"
assert communication_rule is not None assert communication_rule is not None
assert communication_rule.scenario_json == ["通信费"] assert communication_rule.scenario_json == ["通信费"]
@@ -237,6 +228,44 @@ def test_demo_budget_risk_rules_sync_with_finance_rule_references() -> None:
assert communication_rule.config_json["budget_required"] is True assert communication_rule.config_json["budget_required"] is True
def test_existing_budget_risk_assets_are_hidden_from_rule_lists() -> None:
with build_session() as db:
db.add(
AgentAsset(
asset_type=AgentAssetType.RULE.value,
code="risk.budget.legacy.visible",
name="历史预算风险",
description="旧数据中已经存在的预算风险规则。",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=["全部"],
owner="pytest",
status=AgentAssetStatus.ACTIVE.value,
config_json={
"detail_mode": "json_risk",
"finance_rule_code": "budget.execution.policy",
"rule_document": {"file_name": "risk.budget.available_balance_insufficient.json"},
},
)
)
db.commit()
service = AgentAssetService(db)
listed_codes = {
item.code for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
}
page = service.list_assets_page(
asset_type=AgentAssetType.RULE.value,
status=None,
domain=None,
keyword=None,
page=1,
page_size=100,
)
assert "risk.budget.legacy.visible" not in listed_codes
assert "risk.budget.legacy.visible" not in {item.code for item in page.items}
def test_agent_asset_service_can_activate_rule_after_review() -> None: def test_agent_asset_service_can_activate_rule_after_review() -> None:
with build_session() as db: with build_session() as db:
service = AgentAssetService(db) service = AgentAssetService(db)

View File

@@ -14,11 +14,12 @@ from app.models.organization import OrganizationUnit
from app.models.risk_observation import RiskObservation from app.models.risk_observation import RiskObservation
from app.services.budget import BudgetService from app.services.budget import BudgetService
from app.services.demo_company_simulation_seed import ( from app.services.demo_company_simulation_seed import (
SIM_CLAIM_PREFIX,
SIM_EMPLOYEE_PREFIX, SIM_EMPLOYEE_PREFIX,
HalfYearExpenseSimulationSeeder, HalfYearExpenseSimulationSeeder,
SimulationConfig, SimulationConfig,
) )
from app.services.demo_company_simulation_catalog import SIM_PROJECT_CODE
from app.services.demo_company_simulation_rebalance import HalfYearExpenseSimulationRebalancer
def build_session() -> Session: def build_session() -> Session:
@@ -133,7 +134,7 @@ def test_half_year_simulation_feeds_budget_summary() -> None:
summary = BudgetService(db).get_summary(fiscal_year=2026, period_key="2026Q2") summary = BudgetService(db).get_summary(fiscal_year=2026, period_key="2026Q2")
sim_claim_count = db.scalar( sim_claim_count = db.scalar(
select(func.count()).select_from(ExpenseClaim).where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%")) select(func.count()).select_from(ExpenseClaim).where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
) )
sim_employee_count = db.scalar( sim_employee_count = db.scalar(
select(func.count()).select_from(Employee).where(Employee.employee_no.like(f"{SIM_EMPLOYEE_PREFIX}%")) select(func.count()).select_from(Employee).where(Employee.employee_no.like(f"{SIM_EMPLOYEE_PREFIX}%"))
@@ -178,25 +179,128 @@ def test_half_year_simulation_excludes_admin_and_visible_month_has_real_volume()
visible_claim_count = db.scalar( visible_claim_count = db.scalar(
select(func.count()) select(func.count())
.select_from(ExpenseClaim) .select_from(ExpenseClaim)
.where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%")) .where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
.where(ExpenseClaim.occurred_at >= datetime(2026, 6, 1, tzinfo=UTC)) .where(ExpenseClaim.occurred_at >= datetime(2026, 6, 1, tzinfo=UTC))
.where(ExpenseClaim.occurred_at < datetime(2026, 6, 3, tzinfo=UTC)) .where(ExpenseClaim.occurred_at < datetime(2026, 6, 3, tzinfo=UTC))
) )
total_claim_count = db.scalar(
select(func.count())
.select_from(ExpenseClaim)
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
)
daily_counts = [
row[0]
for row in db.execute(
select(func.count())
.select_from(ExpenseClaim)
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
.where(ExpenseClaim.occurred_at >= datetime(2026, 6, 1, tzinfo=UTC))
.where(ExpenseClaim.occurred_at < datetime(2026, 6, 3, tzinfo=UTC))
.group_by(func.date(ExpenseClaim.occurred_at))
).all()
]
max_daily_count = max(daily_counts) if daily_counts else 0
earliest_claim_day = db.scalar( earliest_claim_day = db.scalar(
select(func.min(ExpenseClaim.occurred_at)).where( select(func.min(ExpenseClaim.occurred_at)).where(
ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%") ExpenseClaim.project_code == SIM_PROJECT_CODE
) )
) )
latest_claim_day = db.scalar( latest_claim_day = db.scalar(
select(func.max(ExpenseClaim.occurred_at)).where( select(func.max(ExpenseClaim.occurred_at)).where(
ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%") ExpenseClaim.project_code == SIM_PROJECT_CODE
) )
) )
assert admin_claim_count == 0 assert admin_claim_count == 0
assert total_claim_count is not None
assert 400 <= total_claim_count <= 500
assert visible_claim_count is not None assert visible_claim_count is not None
assert 400 <= visible_claim_count <= 500 assert 12 <= visible_claim_count <= 30
assert max_daily_count <= 16
assert earliest_claim_day is not None assert earliest_claim_day is not None
assert latest_claim_day is not None assert latest_claim_day is not None
assert earliest_claim_day.date() >= date(2026, 1, 1) assert earliest_claim_day.date() >= date(2026, 1, 1)
assert latest_claim_day.date() <= date(2026, 6, 2) assert latest_claim_day.date() <= date(2026, 6, 2)
def test_half_year_simulation_rebalance_spreads_existing_rows_without_deleting() -> None:
with build_session() as db:
seed_company(db)
config = SimulationConfig(
target_employees=100,
start_date=date(2026, 1, 1),
months=6,
seed=20260602,
)
HalfYearExpenseSimulationSeeder(db, config).apply()
db.commit()
claims = list(
db.scalars(
select(ExpenseClaim)
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
.order_by(ExpenseClaim.claim_no.asc())
).all()
)
for claim in claims:
claim.occurred_at = datetime(2026, 6, 1, 10, tzinfo=UTC)
claim.submitted_at = datetime(2026, 6, 1, 11, tzinfo=UTC)
claim.created_at = claim.occurred_at
claim.updated_at = claim.submitted_at
for item in claim.items:
item.item_date = date(2026, 6, 1)
db.commit()
before_count = db.scalar(
select(func.count()).select_from(ExpenseClaim).where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
)
preview = HalfYearExpenseSimulationRebalancer(db).preview()
applied = HalfYearExpenseSimulationRebalancer(db).apply()
db.commit()
after_count = db.scalar(
select(func.count()).select_from(ExpenseClaim).where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
)
daily_counts = [
row[0]
for row in db.execute(
select(func.count())
.select_from(ExpenseClaim)
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
.group_by(func.date(ExpenseClaim.occurred_at))
).all()
]
month_keys = {
(claim.occurred_at.year, claim.occurred_at.month)
for claim in db.scalars(
select(ExpenseClaim).where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
).all()
}
sample_claim = db.scalar(
select(ExpenseClaim)
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
.where(ExpenseClaim.status != "draft")
.order_by(ExpenseClaim.claim_no.asc())
.limit(1)
)
sample_transaction = db.scalar(
select(BudgetTransaction)
.where(BudgetTransaction.source_id == sample_claim.id)
.limit(1)
)
sample_observation = db.scalar(
select(RiskObservation)
.where(RiskObservation.claim_id == sample_claim.id)
.limit(1)
)
assert before_count == after_count
assert preview.claims == applied.claims == after_count
assert applied.recent_claims <= 24
assert max(daily_counts) <= 16
assert {(2026, month) for month in range(1, 7)}.issubset(month_keys)
if sample_transaction is not None:
assert sample_transaction.source_no == sample_claim.claim_no
assert sample_transaction.created_at.date() == sample_claim.submitted_at.date()
if sample_observation is not None:
assert sample_observation.claim_no == sample_claim.claim_no
assert sample_observation.created_at.date() == sample_claim.submitted_at.date()

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json
from datetime import UTC, date, datetime from datetime import UTC, date, datetime
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
@@ -15,6 +16,7 @@ from app.models.agent_asset import AgentAsset
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claims import ExpenseClaimService from app.services.expense_claims import ExpenseClaimService
from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY
@@ -111,6 +113,63 @@ def _add_active_rule_asset(
) )
def _add_vague_goods_rule_asset(
db: Session,
manager: AgentAssetRuleLibraryManager,
) -> None:
rule_code = "risk.travel.low.vague_ticket_content"
file_name = f"{rule_code}.json"
payload = {
"schema_version": "2.0",
"rule_code": rule_code,
"name": "差旅票据服务内容笼统低风险",
"description": "票据商品或服务名称过于笼统,提醒补充明细。",
"evaluator": "vague_goods_description",
"enabled": True,
"requires_attachment": True,
"applies_to": {
"domains": ["expense", "travel"],
"expense_types": ["travel"],
"business_stages": ["reimbursement"],
},
"outcomes": {"fail": {"severity": "low", "action": "warning"}},
}
manager.write_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=file_name,
payload=payload,
)
db.add(
AgentAsset(
asset_type=AgentAssetType.RULE.value,
code=rule_code,
name="差旅票据服务内容笼统低风险",
description="",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=["差旅费"],
owner="pytest",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
published_version="v1.0.0",
config_json={
"detail_mode": "json_risk",
"rule_library": RISK_RULES_LIBRARY,
"rule_document": {"file_name": file_name},
},
)
)
def _write_attachment_meta(storage_root, invoice_id: str, meta: dict[str, Any]) -> None:
file_path = storage_root / invoice_id
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_bytes(b"attachment")
file_path.with_name(f"{file_path.name}.meta.json").write_text(
f"{json.dumps(meta, ensure_ascii=False, indent=2)}\n",
encoding="utf-8",
)
def _build_claim(*, claim_no: str, expense_type: str, status: str = "draft") -> ExpenseClaim: def _build_claim(*, claim_no: str, expense_type: str, status: str = "draft") -> ExpenseClaim:
return ExpenseClaim( return ExpenseClaim(
claim_no=claim_no, claim_no=claim_no,
@@ -162,6 +221,13 @@ def test_platform_risk_rules_are_filtered_by_business_stage_and_category(
business_stage="reimbursement", business_stage="reimbursement",
message="报账环节规则命中", message="报账环节规则命中",
) )
_add_active_rule_asset(
db,
manager,
rule_code="risk.budget.sample.reimbursement.rule",
business_stage="reimbursement",
message="预算风险规则不应进入行为风险检测",
)
_add_active_rule_asset( _add_active_rule_asset(
db, db,
manager, manager,
@@ -297,3 +363,122 @@ def test_reimbursement_item_sync_persists_rule_center_risk_preview(
assert rule_flags[0]["business_stage"] == "reimbursement" assert rule_flags[0]["business_stage"] == "reimbursement"
assert rule_flags[0]["visibility_scope"] == "submitter" assert rule_flags[0]["visibility_scope"] == "submitter"
assert rule_flags[0]["actionability"] == "fixable_by_submitter" assert rule_flags[0]["actionability"] == "fixable_by_submitter"
def test_vague_ticket_content_ignores_clear_hotel_receipt_text(
tmp_path,
monkeypatch,
) -> None:
with build_session() as db:
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
storage_root = tmp_path / "attachments"
_patch_rule_manager(monkeypatch, manager)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: storage_root)
_add_vague_goods_rule_asset(db, manager)
invoice_id = "claim-clear-hotel/item-hotel/hotel.jpg"
claim = _build_claim(claim_no="RE-CLEAR-HOTEL-001", expense_type="travel")
claim.invoice_count = 1
claim.items = [
ExpenseClaimItem(
item_date=date(2026, 2, 23),
item_type="hotel_ticket",
item_reason="上海喜来登酒店",
item_location="上海",
item_amount=Decimal("828.00"),
invoice_id=invoice_id,
)
]
db.add(claim)
db.commit()
_write_attachment_meta(
storage_root,
invoice_id,
{
"document_info": {
"document_type": "hotel_invoice",
"document_type_label": "酒店住宿票据",
"scene_code": "hotel",
"scene_label": "住宿票据",
"fields": [
{"key": "amount", "label": "金额", "value": "828元"},
{"key": "date", "label": "日期", "value": "2026-02-23"},
{"key": "merchant_name", "label": "商户", "value": "上海喜来登酒店"},
],
},
"ocr_summary": "上海喜来登酒店;住宿发票",
"ocr_text": "本发票仅含住宿费,不含其他增值服务费。",
},
)
review = ExpenseClaimService(db).evaluate_platform_risk_rules(
claim,
business_stage="reimbursement",
)
assert not [
flag
for flag in review["flags"]
if isinstance(flag, dict)
and flag.get("rule_code") == "risk.travel.low.vague_ticket_content"
]
def test_vague_ticket_content_still_flags_unclear_goods_name(
tmp_path,
monkeypatch,
) -> None:
with build_session() as db:
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
storage_root = tmp_path / "attachments"
_patch_rule_manager(monkeypatch, manager)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: storage_root)
_add_vague_goods_rule_asset(db, manager)
invoice_id = "claim-vague/item-other/other.pdf"
claim = _build_claim(claim_no="RE-VAGUE-001", expense_type="travel")
claim.invoice_count = 1
claim.items = [
ExpenseClaimItem(
item_date=date(2026, 2, 23),
item_type="other",
item_reason="差旅相关补充票据",
item_location="上海",
item_amount=Decimal("200.00"),
invoice_id=invoice_id,
)
]
db.add(claim)
db.commit()
_write_attachment_meta(
storage_root,
invoice_id,
{
"document_info": {
"document_type": "other",
"document_type_label": "其他单据",
"scene_code": "other",
"scene_label": "其他票据",
"fields": [
{"key": "goods_name", "label": "商品或服务名称", "value": "服务费"},
],
},
"ocr_summary": "费用发票",
"ocr_text": "项目:服务费。",
},
)
review = ExpenseClaimService(db).evaluate_platform_risk_rules(
claim,
business_stage="reimbursement",
)
rule_flags = [
flag
for flag in review["flags"]
if isinstance(flag, dict)
and flag.get("rule_code") == "risk.travel.low.vague_ticket_content"
]
assert len(rule_flags) == 1
assert rule_flags[0]["severity"] == "low"
assert rule_flags[0]["evidence"]["matched_keywords"] == ["服务费"]

View File

@@ -19,7 +19,12 @@ from app.models.organization import OrganizationUnit
from app.models.role import Role from app.models.role import Role
from app.schemas.ontology import OntologyParseRequest from app.schemas.ontology import OntologyParseRequest
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate, ExpenseClaimUpdate from app.schemas.reimbursement import (
ExpenseClaimItemCreate,
ExpenseClaimItemUpdate,
ExpenseClaimStandardAdjustmentPayload,
ExpenseClaimUpdate,
)
from app.services.agent_conversations import AgentConversationService from app.services.agent_conversations import AgentConversationService
from app.services.budget import BudgetService from app.services.budget import BudgetService
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
@@ -69,6 +74,36 @@ def build_claim(*, expense_type: str, location: str) -> ExpenseClaim:
return claim return claim
def build_application_claim(
*,
id: str,
claim_no: str,
employee: Employee,
status: str = "approved",
amount: Decimal = Decimal("3000.00"),
) -> ExpenseClaim:
return ExpenseClaim(
id=id,
claim_no=claim_no,
employee_id=employee.id,
employee_name=employee.name,
department_id=employee.organization_unit_id,
department_name="Tech",
project_code=None,
expense_type="travel_application",
reason="support deployment",
location="Shanghai",
amount=amount,
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
submitted_at=datetime(2026, 2, 18, tzinfo=UTC),
status=status,
approval_stage=APPROVAL_DONE_STAGE if status in {"approved", "completed"} else "Pending",
risk_flags_json=[],
)
def build_session() -> Session: def build_session() -> Session:
engine = create_engine( engine = create_engine(
"sqlite+pysqlite:///:memory:", "sqlite+pysqlite:///:memory:",
@@ -322,6 +357,12 @@ def test_upsert_draft_from_ontology_persists_linked_application_context() -> Non
email=user_id, email=user_id,
) )
db.add(employee) db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-linked-1",
claim_no="AP-202605-001",
employee=employee,
))
db.commit() db.commit()
ontology = SemanticOntologyService(db).parse( ontology = SemanticOntologyService(db).parse(
OntologyParseRequest( OntologyParseRequest(
@@ -384,6 +425,12 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
grade="P5", grade="P5",
) )
db.add(employee) db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-linked-no-receipt",
claim_no="AP-202606-001",
employee=employee,
))
db.commit() db.commit()
ontology = SemanticOntologyService(db).parse( ontology = SemanticOntologyService(db).parse(
OntologyParseRequest( OntologyParseRequest(
@@ -474,6 +521,11 @@ def test_upsert_linked_application_draft_clears_existing_placeholder_item() -> N
) )
db.add(employee) db.add(employee)
db.flush() db.flush()
db.add(build_application_claim(
id="application-linked-existing-placeholder",
claim_no="AP-202606-002",
employee=employee,
))
existing_claim = ExpenseClaim( existing_claim = ExpenseClaim(
claim_no="RE-202606020001-PLACEHOLDER", claim_no="RE-202606020001-PLACEHOLDER",
employee_id=employee.id, employee_id=employee.id,
@@ -550,6 +602,123 @@ def test_upsert_linked_application_draft_clears_existing_placeholder_item() -> N
assert claim.items == [] assert claim.items == []
def test_upsert_linked_application_requires_approved_application() -> None:
user_id = "linked-application-status-block@example.com"
message = "save reimbursement draft from linked travel application"
with build_session() as db:
employee = Employee(employee_no="E5108", name="Linked Employee", email=user_id)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-returned-blocked",
claim_no="AP-202606-STATUS",
employee=employee,
status="returned",
))
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(query=message, user_id=user_id)
)
result = ExpenseClaimService(db).upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message=message,
ontology=ontology,
context_json={
"name": "Linked Employee",
"user_input_text": message,
"review_action": "save_draft",
"review_form_values": {
"expense_type": "travel",
"application_claim_id": "application-returned-blocked",
"application_claim_no": "AP-202606-STATUS",
},
"expense_scene_selection": {
"expense_type": "travel",
"application_claim_id": "application-returned-blocked",
"application_claim_no": "AP-202606-STATUS",
},
},
)
assert result["status"] == "blocked"
assert result["application_link_blocked"] is True
assert result["application_claim_no"] == "AP-202606-STATUS"
assert _count_claims(db) == 1
def test_upsert_linked_application_rejects_duplicate_reimbursement_draft() -> None:
user_id = "linked-application-duplicate-block@example.com"
message = "save another reimbursement draft from linked travel application"
with build_session() as db:
employee = Employee(employee_no="E5109", name="Linked Employee", email=user_id)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-duplicate-blocked",
claim_no="AP-202606-DUP",
employee=employee,
))
existing_claim = ExpenseClaim(
claim_no="RE-202606-DUP-DRAFT",
employee_id=employee.id,
employee_name=employee.name,
department_name="Tech",
project_code=None,
expense_type="travel",
reason="support deployment",
location="Shanghai",
amount=Decimal("0.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
status="draft",
approval_stage="Pending",
risk_flags_json=[
{
"source": "application_link",
"application_claim_id": "application-duplicate-blocked",
"application_claim_no": "AP-202606-DUP",
}
],
)
db.add(existing_claim)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(query=message, user_id=user_id)
)
result = ExpenseClaimService(db).upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message=message,
ontology=ontology,
context_json={
"name": "Linked Employee",
"user_input_text": message,
"review_action": "save_draft",
"review_form_values": {
"expense_type": "travel",
"application_claim_id": "application-duplicate-blocked",
"application_claim_no": "AP-202606-DUP",
},
"expense_scene_selection": {
"expense_type": "travel",
"application_claim_id": "application-duplicate-blocked",
"application_claim_no": "AP-202606-DUP",
},
},
)
assert result["status"] == "blocked"
assert result["application_link_blocked"] is True
assert result["existing_claim_no"] == "RE-202606-DUP-DRAFT"
assert _count_claims(db) == 2
def test_sync_travel_allowance_uses_linked_application_range_days() -> None: def test_sync_travel_allowance_uses_linked_application_range_days() -> None:
with build_session() as db: with build_session() as db:
employee = Employee( employee = Employee(
@@ -1375,6 +1544,7 @@ def test_update_claim_item_allows_placeholder_date_reason_and_amount() -> None:
payload=ExpenseClaimItemUpdate( payload=ExpenseClaimItemUpdate(
item_reason="", item_reason="",
item_location="", item_location="",
item_note="票据行程存在改签,已核对业务真实发生。",
item_amount=Decimal("0.00"), item_amount=Decimal("0.00"),
), ),
current_user=current_user, current_user=current_user,
@@ -1385,6 +1555,7 @@ def test_update_claim_item_allows_placeholder_date_reason_and_amount() -> None:
assert claim.items[0].item_date == date(2026, 5, 13) assert claim.items[0].item_date == date(2026, 5, 13)
assert claim.items[0].item_reason == "" assert claim.items[0].item_reason == ""
assert claim.items[0].item_location == "" assert claim.items[0].item_location == ""
assert claim.items[0].item_note == "票据行程存在改签,已核对业务真实发生。"
assert claim.items[0].item_amount == Decimal("0.00") assert claim.items[0].item_amount == Decimal("0.00")
@@ -1606,7 +1777,7 @@ def test_create_claim_item_adds_blank_draft_row_without_forcing_attachment() ->
service = ExpenseClaimService(db) service = ExpenseClaimService(db)
updated = service.create_claim_item( updated = service.create_claim_item(
claim_id=claim.id, claim_id=claim.id,
payload=ExpenseClaimItemCreate(), payload=ExpenseClaimItemCreate(item_note="待上传异常票据说明"),
current_user=current_user, current_user=current_user,
) )
@@ -1619,6 +1790,7 @@ def test_create_claim_item_adds_blank_draft_row_without_forcing_attachment() ->
assert new_item.item_type == "office" assert new_item.item_type == "office"
assert new_item.item_reason == "" assert new_item.item_reason == ""
assert new_item.item_location == "" assert new_item.item_location == ""
assert new_item.item_note == "待上传异常票据说明"
assert new_item.item_amount == Decimal("0.00") assert new_item.item_amount == Decimal("0.00")
assert new_item.invoice_id is None assert new_item.invoice_id is None
@@ -2448,6 +2620,77 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
assert submitted.submitted_at is not None assert submitted.submitted_at is not None
def test_accept_standard_adjustment_recalculates_claim_amount_and_preserves_on_submit() -> None:
current_user = CurrentUserContext(
username="emp-standard@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E7030",
name="李经理",
email="manager-standard@example.com",
)
employee = Employee(
employee_no="E7031",
name="张三",
email="emp-standard@example.com",
manager=manager,
)
claim = build_claim(expense_type="hotel", location="北京")
claim.employee = employee
claim.employee_id = employee.id
claim.amount = Decimal("880.00")
claim.items[0].item_type = "hotel_ticket"
claim.items[0].item_reason = "北京住宿"
claim.items[0].item_amount = Decimal("880.00")
db.add_all([manager, employee, claim])
db.commit()
service = ExpenseClaimService(db)
adjusted = service.accept_standard_adjustment(
claim_id=claim.id,
payload=ExpenseClaimStandardAdjustmentPayload(
risks=[
{
"risk_id": "risk-hotel-1",
"item_id": claim.items[0].id,
"title": "住宿超标待说明",
"risk": "住宿标准为 600 元/晚,当前酒店识别金额约 880 元/晚。",
"original_amount": Decimal("880.00"),
"reimbursable_amount": Decimal("600.00"),
}
]
),
current_user=current_user,
)
assert adjusted is not None
assert adjusted.amount == Decimal("600.00")
standard_flag = next(
flag
for flag in adjusted.risk_flags_json
if isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
)
assert standard_flag["original_amount"] == "880.00"
assert standard_flag["reimbursable_amount"] == "600.00"
assert standard_flag["employee_absorbed_amount"] == "280.00"
assert standard_flag["visibility_scope"] == "leader"
submitted = service.submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.amount == Decimal("600.00")
assert any(
isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
for flag in submitted.risk_flags_json
)
def test_pre_review_claim_records_ai_result_without_submitting() -> None: def test_pre_review_claim_records_ai_result_without_submitting() -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
username="emp-pre-review@example.com", username="emp-pre-review@example.com",
@@ -2808,6 +3051,154 @@ def test_submit_claim_routes_travel_route_mismatch_to_approval_with_review_flag(
) )
def test_submit_claim_allows_round_trip_ticket_origin_inferred_from_route(
monkeypatch,
tmp_path,
) -> None:
current_user = CurrentUserContext(
username="emp-round-trip@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
documents: list[OcrRecognizeDocumentRead] = []
for filename, _, media_type in files:
if filename == "outbound.png":
documents.append(
OcrRecognizeDocumentRead(
filename=filename,
media_type=media_type or "image/png",
text="铁路电子客票 2026-02-20 武汉-上海 二等座 票价 ¥354.00",
summary="武汉到上海高铁票",
avg_score=0.98,
line_count=1,
page_count=1,
document_type="train_ticket",
document_type_label="铁路电子客票",
scene_code="travel",
scene_label="差旅票据",
document_fields=[
{"key": "route", "label": "行程", "value": "武汉-上海"},
{"key": "amount", "label": "金额", "value": "354元"},
{"key": "date", "label": "日期", "value": "2026-02-20"},
],
warnings=[],
)
)
elif filename == "return.png":
documents.append(
OcrRecognizeDocumentRead(
filename=filename,
media_type=media_type or "image/png",
text="铁路电子客票 2026-02-23 上海-武汉 二等座 票价 ¥354.00",
summary="上海到武汉高铁票",
avg_score=0.98,
line_count=1,
page_count=1,
document_type="train_ticket",
document_type_label="铁路电子客票",
scene_code="travel",
scene_label="差旅票据",
document_fields=[
{"key": "route", "label": "行程", "value": "上海-武汉"},
{"key": "amount", "label": "金额", "value": "354元"},
{"key": "date", "label": "日期", "value": "2026-02-23"},
],
warnings=[],
)
)
return OcrRecognizeBatchRead(
total_file_count=len(files),
success_count=len(documents),
documents=documents,
)
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
with build_session() as db:
manager = Employee(
employee_no="E7210",
name="李经理",
email="manager-round-trip@example.com",
)
employee = Employee(
employee_no="E7211",
name="张三",
email="emp-round-trip@example.com",
grade="P4",
location="上海",
manager=manager,
)
db.add_all([manager, employee])
db.flush()
claim = build_claim(expense_type="travel", location="上海")
claim.reason = "支撑国网仿生产环境部署"
claim.employee = employee
claim.employee_id = employee.id
claim.items = [
ExpenseClaimItem(
id="round-trip-item-1",
claim_id=claim.id,
item_date=date(2026, 2, 20),
item_type="travel",
item_reason="支撑国网仿生产环境部署",
item_location="上海",
item_amount=Decimal("354.00"),
invoice_id=None,
),
ExpenseClaimItem(
id="round-trip-item-2",
claim_id=claim.id,
item_date=date(2026, 2, 23),
item_type="travel",
item_reason="支撑国网仿生产环境部署",
item_location="上海",
item_amount=Decimal("354.00"),
invoice_id=None,
),
]
claim.amount = Decimal("708.00")
claim.invoice_count = 0
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
service.upload_claim_item_attachment(
claim_id=claim.id,
item_id="round-trip-item-1",
filename="outbound.png",
content=b"outbound-image",
media_type="image/png",
current_user=current_user,
)
service.upload_claim_item_attachment(
claim_id=claim.id,
item_id="round-trip-item-2",
filename="return.png",
content=b"return-image",
media_type="image/png",
current_user=current_user,
)
submitted = service.submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.approval_stage == "直属领导审批"
assert not any(
isinstance(flag, dict)
and str(flag.get("rule_code") or "").strip() == "risk.travel.high.city_mismatch"
for flag in list(submitted.risk_flags_json or [])
)
def test_submit_claim_routes_hotel_amount_over_travel_policy_to_approval_with_review_flag( def test_submit_claim_routes_hotel_amount_over_travel_policy_to_approval_with_review_flag(
monkeypatch, monkeypatch,
tmp_path, tmp_path,
@@ -4051,6 +4442,44 @@ def test_application_submit_blocks_when_budget_insufficient_without_state_change
assert db.query(BudgetTransaction).count() == 0 assert db.query(BudgetTransaction).count() == 0
def test_reimbursement_submit_keeps_budget_insufficient_as_review_risk() -> None:
current_user = CurrentUserContext(
username="reimbursement-budget-risk@example.com",
name="张三",
role_codes=["employee"],
is_admin=True,
)
with build_session() as db:
_seed_budget_allocation(
db,
department_id="dept-1",
department_name="市场部",
subject_code="office",
amount=Decimal("1000.00"),
)
claim = build_claim(expense_type="office", location="待补充")
claim.amount = Decimal("1200.00")
claim.items[0].item_amount = Decimal("1200.00")
db.add(claim)
db.commit()
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.submitted_at is not None
assert any(
isinstance(flag, dict)
and flag.get("source") == "budget_control"
and flag.get("event_type") == "budget_insufficient"
and flag.get("business_stage") == "reimbursement"
for flag in submitted.risk_flags_json
)
assert db.query(BudgetReservation).count() == 0
assert db.query(BudgetTransaction).count() == 0
def test_application_submit_skips_budget_for_non_demo_subject() -> None: def test_application_submit_skips_budget_for_non_demo_subject() -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
username="application-budget-skip@example.com", username="application-budget-skip@example.com",

View File

@@ -332,6 +332,8 @@ def test_finance_dashboard_uses_financial_terms_instead_of_approval_terms() -> N
assert "budget pressure" not in str(dashboard.exception_mix).lower() assert "budget pressure" not in str(dashboard.exception_mix).lower()
assert dashboard.trend["claimCount"][-1] == 1 assert dashboard.trend["claimCount"][-1] == 1
assert dashboard.trend["claimAmount"][-1] == 700.0 assert dashboard.trend["claimAmount"][-1] == 700.0
assert sum(series["data"][-1] for series in dashboard.trend["categoryAmountSeries"]) == 700.0
assert "travel_application" not in str(dashboard.trend["categoryAmountSeries"])
assert dashboard.trend["applications"] == dashboard.trend["claimCount"] assert dashboard.trend["applications"] == dashboard.trend["claimCount"]
assert dashboard.department_ranking[0]["name"] == "Market" assert dashboard.department_ranking[0]["name"] == "Market"
assert dashboard.department_ranking[0]["amount"] == 700.0 assert dashboard.department_ranking[0]["amount"] == 700.0

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