From ae3f02c35a26a204fe2e893e9994f371c601bdb0 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Thu, 16 Jul 2026 10:23:23 +0800 Subject: [PATCH] feat(expense): add persistent zero-entry receipt association --- .../CONCEPT.md | 26 +- .../TODO.md | 19 +- .../zero-entry-receipt-tenant-and-rollback.md | 12 + ...260716_0006_attachment_association_jobs.py | 118 ++ .../endpoints/attachment_association_jobs.py | 19 +- server/src/app/db/base.py | 2 + server/src/app/db/migration_preflight.py | 17 +- server/src/app/db/schema_ownership.py | 1 + server/src/app/models/__init__.py | 2 + .../app/models/attachment_association_job.py | 110 ++ .../app/schemas/attachment_association_job.py | 34 +- .../attachment_association_job_store.py | 267 +++ .../services/attachment_association_jobs.py | 630 ++----- server/src/app/services/expense_cases.py | 107 ++ .../expense_claim_attachment_operations.py | 153 +- server/src/app/services/expense_claims.py | 152 +- .../services/expense_receipt_association.py | 746 ++++++++ .../app/services/expense_receipt_matcher.py | 604 +++++++ server/src/app/services/receipt_folder.py | 36 +- server/tests/test_alembic_migrations.py | 33 +- .../tests/test_attachment_association_jobs.py | 1519 ++++++++++++++++- server/tests/test_migration_preflight.py | 7 +- server/tests/test_schema_ownership.py | 1 + ...useWorkbenchAiAttachmentAssociationFlow.js | 4 +- ...useWorkbenchAiAttachmentAssociationJobs.js | 71 +- .../workbenchAiMessageModel.js | 25 +- web/src/services/attachmentAssociationJobs.js | 14 +- web/src/utils/aiAttachmentAssociationModel.js | 133 +- .../utils/attachmentAssociationJobModel.js | 162 ++ ...attachment-association-job-result.test.mjs | 236 +++ 30 files changed, 4450 insertions(+), 810 deletions(-) create mode 100644 document/development/2026-07-16/dev-logs/bugs/zero-entry-receipt-tenant-and-rollback.md create mode 100644 server/alembic/versions/20260716_0006_attachment_association_jobs.py create mode 100644 server/src/app/models/attachment_association_job.py create mode 100644 server/src/app/services/attachment_association_job_store.py create mode 100644 server/src/app/services/expense_receipt_association.py create mode 100644 server/src/app/services/expense_receipt_matcher.py create mode 100644 web/src/utils/attachmentAssociationJobModel.js create mode 100644 web/tests/attachment-association-job-result.test.mjs diff --git a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md index 890829a..ec3166b 100644 --- a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md +++ b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md @@ -1,6 +1,6 @@ # AI 费用闭环与价值证明 概念文档 -更新时间:2026-07-14 +更新时间:2026-07-16 文档路径:document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md @@ -113,6 +113,15 @@ - 每个 AI 填充字段显示来源、置信度和修改入口;低置信度字段集中进入“需要确认”区。 - 退回、补件和断点续办直接回到对应问题,不要求用户重新开始对话。 +#### 零录入报销首个切片 + +- 用户在小财管家上传已完成 OCR 的票据并发送后,附件关联任务必须以当前租户、当前员工和 `Expense Case` 为边界,优先寻找已审批申请自动生成的可编辑报销草稿。 +- 匹配信号首期使用费用事件关系、申请状态、票据日期、行程城市、费用场景和草稿状态;返回结构化分数、置信度和命中原因,不把模型自由文本作为授权依据。 +- 只有唯一高置信候选允许自动归集。低置信、多个接近候选、申请缺少系统生成草稿、票据已属于其他单据或证据不足时,任务以 `requires_confirmation=true` 成功返回候选和异常,不修改任何报销单或票据关系。 +- 自动归集成功后直接返回费用事件、申请、报销草稿、归集数量、跳过数量、风险项、缺失项和可解释原因;前端只展示完成结果或“需要确认”异常,不要求用户再次上传同一票据。 +- 自动归集是 L3 可逆动作,只允许写入草稿、票据关系和业务事件;不自动提交、审批、付款或重建历史遗留申请缺失的草稿。 +- 相同租户、用户、票据和目标草稿重试必须幂等;已归集到同一草稿的票据计入跳过数,不重复创建费用明细、附件或业务事件。 + #### 移动端 - 把拍照、相册选票、报销列表、审批列表和 AI 助手接入真实后端。 @@ -144,6 +153,8 @@ - 新增 `ExpenseCaseService` 作为跨阶段编排入口,避免继续扩大万能 `ExpenseClaimService`。 - `ExpenseCaseService` 只负责阶段协调,申请、票据、报销、审批、支付、入账、记忆和节省由独立协作者负责。 - 复用统一 AI 场景注册与 LangGraph 编排,前端只按后端返回的 plan/action 渲染,不再增加业务门控。 +- 零录入票据归集由独立匹配器计算候选和证据,由独立关联编排器执行可逆写入;后台 Job 只负责身份绑定、状态保存和结果投影,不继续堆评分、附件和事件逻辑。 +- 票据存储命名空间必须至少包含租户与用户;后台任务查看和执行同时校验租户及用户,平台管理员也不能跨租户读取任务结果。 #### 业务事件与 AI 决策 @@ -226,6 +237,14 @@ - `savings_opportunities` / `savings_realizations`:节省机会和实现结果。 - `usage_meter_events`:租户、模块、模型、OCR、文档和分析用量。 +#### 零录入附件关联任务契约 + +- 保留现有 `status`、`message`、`claim_id`、`claim_no`、`uploaded_count`、`skipped_count` 字段,追加字段保持前端向后兼容。 +- 任务追加 `resolution`、`requires_confirmation`、`expense_case_id`、`application_claim_id`、`application_claim_no`、`confidence`、`confidence_score`、`match_reasons`、`exceptions`、`missing_fields`、`risk_items` 和 `candidates`。 +- `status=succeeded` 只表示匹配流程完成;`resolution=auto_associated` 表示已经完成可逆归集,`resolution=requires_confirmation` 表示没有业务写入,需要用户选择候选或处理异常。 +- 候选最少返回目标类型、费用事件 ID、申请 ID/编号、草稿 ID/编号、分数、置信度和命中原因;不得返回其他租户或当前员工数据范围之外的候选。 +- `receipt_received` 和 `attachment_associated` 使用票据 ID 与目标草稿组成稳定幂等键,并与票据 Link、附件写入在同一数据库事务中完成;若文件存储写入失败,数据库事务回滚且任务进入失败态。 + #### 最小事件词典 - `expense_case_created` @@ -556,3 +575,8 @@ docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv \ - 2026-07-14(应用、抑制与遗忘):个人记忆只补空白出行方式,任何当前显式值都优先。命中后服务端重新计算交通与总额估算再签发 canonical decision;记忆异常降级为无记忆,不阻塞预览。反向或非白名单纠正抑制 active,用户忘记后状态改为 revoked、清空可恢复值和指纹,旧请求不可复活。 - 2026-07-14(前端记忆解释):申请核对表新增独立 `TravelReimbursementMemoryPanel`,展示已应用的常用出行方式、证据数量、学习回执和“忘记此偏好”入口;本地会话快照支持跨刷新恢复,忘记偏好不会篡改当前申请字段。 - 2026-07-14(个人记忆验证):容器内个人记忆专项 16 项、记忆/预览决策/迁移/所有权组合回归 56 项通过且 1 项条件跳过、前端申请与记忆组合 83 项通过;一次性 PostgreSQL 迁移循环、Ruff、Vite 生产构建和 `git diff --check` 均通过。当前规则中心尚不存在交通方式禁用维度,制度冲突守卫作为后续规则扩展的前置门禁保留,不以恒真占位判断冒充已实现。 +- 2026-07-16(零录入票据首个闭环):附件后台任务从“大而全”的内存编排中拆出只读 `ExpenseReceiptMatcher` 与可逆 `ExpenseReceiptAssociationService`。系统按当前租户、员工、Expense Case、已审批申请、票据日期、城市、路线和场景选择草稿;只有唯一高置信草稿自动归集,低置信、多候选、无草稿及仅有申请时均以需要确认的安全终态返回并保持零业务写入。 +- 2026-07-16(事务、幂等与租户边界):票据 Link、`receipt_received`、附件写入和 `attachment_associated` 由同一关联编排器提交,数据库快照与文件目录备份共同补偿中途失败,事件或文件元数据异常时恢复 Claim/Case/Link/Event/票据及旧附件目录;相同票据任务按租户、owner 和票据集合持久去重。票据目录加入无碰撞租户摘要,任务、候选 Claim 和票据同时绑定租户与本人,平台管理员也不能跨租户读取任务。 +- 2026-07-16(持久任务与并发):新增 migration-owned `attachment_association_jobs` 和 `20260716_0006`。任务状态、结构化结果、owner 上下文、attempt、租约与 generation 写入数据库;GET 可恢复 queued 或租约过期任务,`attempt_count + running` 作为栅栏阻止旧 worker 或迟到回调覆盖新终态。同票据和同 Claim 分别使用进程锁与 PostgreSQL advisory lock 串行化,Claim 锁内清理旧事务并重新匹配,避免不同票据并发选择同一空明细。待确认或失败任务保留原代历史,再次发起创建新 generation 并重新评估;已自动关联成功的代际继续幂等复用。评分改为纯只读查询,每份票据必须独立达到最小证据,避免无关票据被同批强证据带入。 +- 2026-07-16(小财管家交互与验证):任务结果新增 Case、申请、置信度、原因、异常、缺失项、风险项、候选和草稿载荷;前端可跨会话恢复,自动完成直接查看草稿,仅申请候选查看申请,待确认不伪装成功;幂等重放显示“已关联”,成功结果仍展示风险和复核要求。容器内后端归集专项 20 项、归集与相邻服务/迁移所有权组合回归 71 项、前端关联链路组合回归 29 项、Ruff F/I/UP 和 Vite 生产构建通过;一次性 tmpfs PostgreSQL 17 的 0006 完整迁移循环 4 项通过并已清理,持久开发库未修改。既有大型报销服务与接口套件仍存在旧审批、删除和风控断言失败,未把这些基线问题误报为已解决。 +- 2026-07-16(保留边界):预算、项目、成本中心和个人记忆偏好尚未接入票据候选评分;任务已持久化并支持租约恢复,但尚未建设独立消息队列、运维重试面板和死信治理;完整 G2 与平台级异步任务治理仍未完成。 diff --git a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md index 5b96f7f..2d69a60 100644 --- a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md +++ b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md @@ -1,6 +1,6 @@ # AI 费用闭环与价值证明 开发 TODO -更新时间:2026-07-14 +更新时间:2026-07-16 文档路径:document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md @@ -94,7 +94,16 @@ - [ ] [CONCEPT: 数据与契约] 补齐撤回、取消、驳回、作废、补件、支付失败、对账异常和归档状态。 - [ ] [CONCEPT: 连接器] 实现统一连接器基类、幂等、重试、错误状态和回执事件。 - [ ] [CONCEPT: 连接器] 实现支付批次、回执、重复付款防护、ERP 凭证和对账的内部契约,首期允许 mock connector 但不得再只写单一“已付款”状态。 -- [ ] [CONCEPT: 降级策略] 将附件关联和关联报销草稿后台任务迁为可持久化、可恢复、可幂等的任务状态。 +- [x] [CONCEPT: 降级策略] 将附件关联和关联报销草稿后台任务迁为可持久化、可恢复、可幂等的任务状态。 + 证据:新增 migration-owned `attachment_association_jobs` 与 `20260716_0006`;任务使用租户、owner、票据集合和 generation 去重,运行态带租约与 `attempt_count + running` 栅栏,GET 可恢复 queued/租约过期任务。同票据和同 Claim 均由进程锁与 PostgreSQL advisory lock 串行化,Claim 锁内重新匹配;待确认或失败历史保留原代并以新 generation 重新评估,自动关联成功代继续幂等复用。进程状态清空、租约过期、旧 worker 回写、不同票据并发同 Claim 和代际重评估回归通过。 +- [x] [CONCEPT: 零录入附件关联任务契约] 扩展附件关联任务的向后兼容结果契约,区分自动完成与需要确认,并返回 Case、申请、置信度、原因、异常、缺失项、风险项和候选。 + 证据:`attachment_association_job.py` 与 `attachmentAssociationJobModel.js` 保留旧字段并追加结构化结果;`status=succeeded + resolution=requires_confirmation` 明确表示匹配完成但零业务写入。 +- [x] [CONCEPT: 零录入报销首个切片] 新增独立票据匹配器,按当前租户、员工、Expense Case、已审批申请、日期、城市和场景计算候选;唯一高置信才允许自动归集。 + 证据:`expense_receipt_matcher.py` 只读评分;覆盖唯一高置信、相近候选、无草稿、仅有已审批申请和已关联重放,申请缺少系统草稿时只返回 `approved_application` 候选。 +- [x] [CONCEPT: 零录入报销首个切片] 新增独立归集编排器,把票据 Link、`receipt_received`、附件写入和 `attachment_associated` 纳入同一事务,并保证同票据重试不重复建明细或事件。 + 证据:`expense_receipt_association.py` 统一编排数据库写入,文件系统使用元数据快照和附件目录补偿;稳定幂等键保证相同票据重试只增加跳过数。事件写入失败回归证明 Claim、Case、Link、Event、票据元数据和附件最终状态均回退。 +- [x] [CONCEPT: 权限与安全] 将票据夹存储命名空间和附件关联后台任务授权同时绑定租户与用户,补充同用户名跨租户隔离回归。 + 证据:`receipt_folder.py` 使用租户与用户联合命名空间,任务状态绑定 `owner_tenant_id + owner_username`;普通用户及平台管理员跨租户查询均返回 404,同用户名跨租户不能读取票据。 ## 5. P0 前端实现:一键报销与真实移动端 @@ -102,7 +111,13 @@ 证据:`TravelRequestExpenseCaseTimeline.vue`、`useExpenseCaseTimeline.js`、`expenseCases.js`、`expenseCaseTimeline.js`;保留原有横向进度,404 与接口异常均安全降级,容器内 99 条定向前端回归和 Vite 生产构建通过。 - [ ] [CONCEPT: 统一费用事件] 补齐计划、消费/票据、入账、对账和复盘事件,并在真实迁移数据库上跑通完整时间线。 - [ ] [CONCEPT: 统一费用事件] 自动匹配申请、预算、票据、费用类型、项目、成本中心和常用字段。 + 首个切片先交付申请、票据、费用事件、日期、城市和费用场景匹配;预算、项目、成本中心和记忆偏好随后接入同一候选契约。 - [ ] [CONCEPT: 统一费用事件] 增加“需要确认”区,只展示低置信、缺失或冲突字段。 + 首个切片在小财管家附件关联结果中复用统一异常模型;后续再收口全屏助手和票据夹入口。 +- [x] [CONCEPT: 零录入报销首个切片] 在小财管家实现“上传并发送 → 自动归集已审批申请生成的草稿 → 查看草稿”,不再次上传票据;低置信时只展示候选和异常卡片。 + 证据:附件任务 composable 将待确认视为安全终态,不发送伪成功通知;自动完成提供“查看草稿”,申请候选提供“查看候选申请”,不伪造报销草稿。 +- [x] [CONCEPT: 零录入附件关联任务契约] 会话刷新后恢复新任务结果、候选和异常状态,旧任务结果仍可正常展示。 + 证据:`workbenchAiMessageModel.js` 持久化新协议,规范化层兼容旧字段;容器内前端票据关联、协议、卡片、幂等重放、风险复核和会话恢复组合回归 29 项通过。 - [ ] [CONCEPT: 风险与预审] 风险卡提供一键补件、修正、解释和重新预审入口。 - [ ] [CONCEPT: 统一费用事件] 退回后直接定位问题字段,支持断点续办,不要求重新发起对话。 - [ ] [CONCEPT: 移动端] 接通真实登录恢复、路由守卫、报销列表、详情、草稿、上传和审批 API。 diff --git a/document/development/2026-07-16/dev-logs/bugs/zero-entry-receipt-tenant-and-rollback.md b/document/development/2026-07-16/dev-logs/bugs/zero-entry-receipt-tenant-and-rollback.md new file mode 100644 index 0000000..70c359b --- /dev/null +++ b/document/development/2026-07-16/dev-logs/bugs/zero-entry-receipt-tenant-and-rollback.md @@ -0,0 +1,12 @@ +# 零录入票据归集跨租户隔离与失败回滚 + +日期:2026-07-16 +文档路径:document/development/2026-07-16/dev-logs/bugs/zero-entry-receipt-tenant-and-rollback.md + +## 修复记录 +- 09:35:记录 bug 修复:零录入票据归集跨租户隔离与失败回滚。(bug-log:54754b55) + - Git 提交检查:2026-07-16 09:35 CST 执行 `git fetch --all --prune` 成功;`origin/main` 没有远端新提交;本地 ahead 11 条,依次是 `54754b55` 个人费用申请记忆、`211f85d9` 统一核验申请工作流、`5b246307` 申请预览决策、`a662cfe6` 申请反馈账本、`5ed34c2b` 历史费用单回填、`11275e4b` 迁移所有权安全、`1347366b` 费用时间线与草稿事件安全、`22669a90` 统一费用事件时间线、`a616b30c` AI 申请提交事务、`653eda05` Bearer 会话、`661990b2` 事务化费用事件;这些提交均为本轮开始前已有的本地检查点,本次未合并或改写历史。 + - 修改:`receipt_folder.py` 把票据命名空间从仅用户名收口为租户与用户联合边界,并用稳定摘要消除有损清洗碰撞;`attachment_association_jobs.py` 同时校验任务租户和 owner,平台管理员也不能跨租户读取。`expense_receipt_matcher.py` 改用纯只读本人 Claim 查询并要求每份票据独立满足证据门槛。`expense_receipt_association.py` 将 Claim、Case、Link、业务事件、票据元数据和新旧附件目录纳入统一失败补偿,避免事件或文件中途异常留下半关联状态。 + - 操作:新增持久化 `attachment_association_jobs` 模型、仓储和 `20260716_0006` 迁移;任务按租户、owner、票据集合和 generation 去重,运行态使用租约、`attempt_count + running` 栅栏、进程锁和 PostgreSQL advisory lock。同票据与同 Claim 分别串行化,Claim 锁内清理旧事务并重新匹配;待确认或失败任务保留原代审计历史,以新 generation 重新评估,自动关联成功代继续幂等复用。低置信、相近候选、混入无关票据、无草稿和仅有申请统一返回需要确认的零写入结果;同步扩展前端任务协议、会话恢复、候选卡片、幂等重放和成功风险复核提示,并更新功能 CONCEPT/TODO。 + - 验证:所有后端验证均在项目主容器内执行并设置 60 秒超时。后端归集专项 20 项、归集与相邻服务及迁移所有权组合回归 71 项通过,覆盖纯只读确认、逐票据证据、事件失败、文件元数据失败、既有申请 Case 保留、同票据并发、不同票据并发同 Claim、租约过期恢复、旧 worker/迟到回调栅栏、待确认重评估、失败代际历史保留、普通用户及管理员跨租户隔离;前端关联链路组合回归 29 项通过;相关 Python 文件 `ruff --select F,I,UP` 通过;Vite 生产构建通过。一次性 tmpfs PostgreSQL 17 中完整迁移循环 4 项通过,临时数据库已清理,持久开发库未修改。 + - 影响:同用户名在不同租户的票据、任务和候选 Claim 不再互相可见;自动归集中途失败不会留下已改 Claim、孤立 Case/Link/Event、错误票据状态或被覆盖的旧附件目录;低证据和混合票据批次只提示确认,不扩大自动化权限;任务重启或多 worker 后仍可查询和恢复。非默认租户原先落在旧用户名目录中的历史票据改为 fail-closed,不做未经确认的跨目录迁移。 diff --git a/server/alembic/versions/20260716_0006_attachment_association_jobs.py b/server/alembic/versions/20260716_0006_attachment_association_jobs.py new file mode 100644 index 0000000..2494e7d --- /dev/null +++ b/server/alembic/versions/20260716_0006_attachment_association_jobs.py @@ -0,0 +1,118 @@ +"""add persistent attachment association jobs + +Revision ID: 20260716_0006 +Revises: 20260714_0005 +Create Date: 2026-07-16 09:55:00 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "20260716_0006" +down_revision: str | None = "20260714_0005" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "attachment_association_jobs", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("owner_username", sa.String(length=255), nullable=False), + sa.Column("owner_name", sa.String(length=255), server_default="", nullable=False), + sa.Column("owner_context_json", sa.JSON(), nullable=False), + sa.Column("dedupe_key", sa.String(length=64), nullable=False), + sa.Column("generation", sa.Integer(), server_default="1", nullable=False), + sa.Column("receipt_ids_json", sa.JSON(), nullable=False), + sa.Column("prompt", sa.Text(), server_default="", nullable=False), + sa.Column("conversation_id", sa.String(length=120), server_default="", nullable=False), + sa.Column("status", sa.String(length=20), server_default="queued", nullable=False), + sa.Column("message", sa.Text(), server_default="", nullable=False), + sa.Column("claim_id", sa.String(length=36), server_default="", nullable=False), + sa.Column("claim_no", sa.String(length=80), server_default="", nullable=False), + sa.Column("uploaded_count", sa.Integer(), server_default="0", nullable=False), + sa.Column("skipped_count", sa.Integer(), server_default="0", nullable=False), + sa.Column("resolution", sa.String(length=40), server_default="pending", nullable=False), + sa.Column( + "requires_confirmation", + sa.Boolean(), + server_default=sa.false(), + nullable=False, + ), + sa.Column("expense_case_id", sa.String(length=36), server_default="", nullable=False), + sa.Column("application_claim_id", sa.String(length=36), server_default="", nullable=False), + sa.Column("application_claim_no", sa.String(length=80), server_default="", nullable=False), + sa.Column("confidence", sa.String(length=20), server_default="", nullable=False), + sa.Column("confidence_score", sa.Float(), server_default="0", nullable=False), + sa.Column("match_reasons_json", sa.JSON(), nullable=False), + sa.Column("exceptions_json", sa.JSON(), nullable=False), + sa.Column("missing_fields_json", sa.JSON(), nullable=False), + sa.Column("risk_items_json", sa.JSON(), nullable=False), + sa.Column("candidates_json", sa.JSON(), nullable=False), + sa.Column("draft_payload_json", sa.JSON(), nullable=True), + sa.Column("error", sa.Text(), server_default="", nullable=False), + sa.Column("attempt_count", sa.Integer(), server_default="0", nullable=False), + sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.CheckConstraint( + "status IN ('queued', 'running', 'succeeded', 'failed')", + name="ck_attachment_association_jobs_status", + ), + sa.CheckConstraint( + "attempt_count >= 0", + name="ck_attachment_association_jobs_attempt_count", + ), + sa.CheckConstraint( + "generation >= 1", + name="ck_attachment_association_jobs_generation", + ), + sa.CheckConstraint( + "status != 'running' OR lease_expires_at IS NOT NULL", + name="ck_attachment_association_jobs_running_lease", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "tenant_id", + "owner_username", + "dedupe_key", + "generation", + name="uq_attachment_association_jobs_owner_dedupe", + ), + ) + op.create_index( + "ix_attachment_association_jobs_owner_time", + "attachment_association_jobs", + ["tenant_id", "owner_username", "created_at"], + ) + op.create_index( + "ix_attachment_association_jobs_status_lease", + "attachment_association_jobs", + ["status", "lease_expires_at"], + ) + + +def downgrade() -> None: + op.drop_index( + "ix_attachment_association_jobs_status_lease", + table_name="attachment_association_jobs", + ) + op.drop_index( + "ix_attachment_association_jobs_owner_time", + table_name="attachment_association_jobs", + ) + op.drop_table("attachment_association_jobs") diff --git a/server/src/app/api/v1/endpoints/attachment_association_jobs.py b/server/src/app/api/v1/endpoints/attachment_association_jobs.py index aed3857..3e6e163 100644 --- a/server/src/app/api/v1/endpoints/attachment_association_jobs.py +++ b/server/src/app/api/v1/endpoints/attachment_association_jobs.py @@ -3,8 +3,9 @@ from __future__ import annotations from typing import Annotated from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status +from sqlalchemy.orm import Session -from app.api.deps import CurrentUserContext, get_current_user +from app.api.deps import CurrentUserContext, get_current_user, get_db from app.db.session import get_session_factory from app.schemas.attachment_association_job import ( AttachmentAssociationJobCreate, @@ -19,6 +20,7 @@ from app.services.attachment_association_jobs import ( router = APIRouter(prefix="/reimbursements/attachment-association-jobs") CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)] +Database = Annotated[Session, Depends(get_db)] @router.post( @@ -38,15 +40,15 @@ def create_attachment_association_job_endpoint( payload: AttachmentAssociationJobCreate, background_tasks: BackgroundTasks, current_user: CurrentUser, + db: Database, ) -> AttachmentAssociationJobRead: try: - job = create_attachment_association_job(payload, current_user) + job = create_attachment_association_job(payload, current_user, db) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc background_tasks.add_task( run_attachment_association_job, job.job_id, - current_user, get_session_factory(), ) return job @@ -66,10 +68,17 @@ def create_attachment_association_job_endpoint( ) def get_attachment_association_job_endpoint( job_id: str, + background_tasks: BackgroundTasks, current_user: CurrentUser, + db: Database, ) -> AttachmentAssociationJobRead: - job = get_attachment_association_job(job_id, current_user) + job = get_attachment_association_job(job_id, current_user, db) if job is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件关联任务不存在或已失效。") + if job.status in {"queued", "running"}: + background_tasks.add_task( + run_attachment_association_job, + job.job_id, + get_session_factory(), + ) return job - diff --git a/server/src/app/db/base.py b/server/src/app/db/base.py index cf91f0e..e607d35 100644 --- a/server/src/app/db/base.py +++ b/server/src/app/db/base.py @@ -13,6 +13,7 @@ from app.models.ai_application_preview import AIApplicationPreviewDecision from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink from app.models.approval import ApprovalRecord +from app.models.attachment_association_job import AttachmentAssociationJob from app.models.audit_log import AuditLog from app.models.auth_session import AuthSession from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction @@ -59,6 +60,7 @@ __all__ = [ "AIDecision", "AIDecisionFeedback", "ApprovalRecord", + "AttachmentAssociationJob", "AuditLog", "AuthSession", "BudgetAllocation", diff --git a/server/src/app/db/migration_preflight.py b/server/src/app/db/migration_preflight.py index 81ad94a..cfa1fdb 100644 --- a/server/src/app/db/migration_preflight.py +++ b/server/src/app/db/migration_preflight.py @@ -64,8 +64,23 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = { "workflow_outcomes", } ), + "20260716_0006": frozenset( + { + "expense_cases", + "expense_case_links", + "business_events", + "auth_sessions", + "attachment_association_jobs", + "ai_application_preview_decisions", + "ai_decisions", + "ai_decision_feedback", + "memory_entries", + "memory_evidence_links", + "workflow_outcomes", + } + ), } -if MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0005"] != MIGRATION_OWNED_TABLES: +if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0006"] != MIGRATION_OWNED_TABLES: raise RuntimeError("latest Alembic revision must own the centralized migration table set") diff --git a/server/src/app/db/schema_ownership.py b/server/src/app/db/schema_ownership.py index 3fc2ef9..79edf4e 100644 --- a/server/src/app/db/schema_ownership.py +++ b/server/src/app/db/schema_ownership.py @@ -7,6 +7,7 @@ from app.db.base import Base MIGRATION_OWNED_TABLES: frozenset[str] = frozenset( { "auth_sessions", + "attachment_association_jobs", "ai_application_preview_decisions", "ai_decisions", "ai_decision_feedback", diff --git a/server/src/app/models/__init__.py b/server/src/app/models/__init__.py index c77e0a7..0d0fcf2 100644 --- a/server/src/app/models/__init__.py +++ b/server/src/app/models/__init__.py @@ -11,6 +11,7 @@ from app.models.ai_application_preview import AIApplicationPreviewDecision from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink from app.models.approval import ApprovalRecord +from app.models.attachment_association_job import AttachmentAssociationJob from app.models.audit_log import AuditLog from app.models.auth_session import AuthSession from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction @@ -53,6 +54,7 @@ __all__ = [ "AgentTraceEvent", "AIApplicationPreviewDecision", "ApprovalRecord", + "AttachmentAssociationJob", "AuditLog", "AuthSession", "AIDecision", diff --git a/server/src/app/models/attachment_association_job.py b/server/src/app/models/attachment_association_job.py new file mode 100644 index 0000000..86701b5 --- /dev/null +++ b/server/src/app/models/attachment_association_job.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import ( + Boolean, + CheckConstraint, + DateTime, + Float, + Index, + Integer, + String, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.db.base_class import Base + + +class AttachmentAssociationJob(Base): + """可跨进程恢复的票据归集任务状态。""" + + __tablename__ = "attachment_association_jobs" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "owner_username", + "dedupe_key", + "generation", + name="uq_attachment_association_jobs_owner_dedupe", + ), + CheckConstraint( + "status IN ('queued', 'running', 'succeeded', 'failed')", + name="ck_attachment_association_jobs_status", + ), + CheckConstraint( + "attempt_count >= 0", + name="ck_attachment_association_jobs_attempt_count", + ), + CheckConstraint( + "generation >= 1", + name="ck_attachment_association_jobs_generation", + ), + CheckConstraint( + "status != 'running' OR lease_expires_at IS NOT NULL", + name="ck_attachment_association_jobs_running_lease", + ), + Index( + "ix_attachment_association_jobs_owner_time", + "tenant_id", + "owner_username", + "created_at", + ), + Index( + "ix_attachment_association_jobs_status_lease", + "status", + "lease_expires_at", + ), + ) + + id: Mapped[str] = mapped_column(String(80), primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), nullable=False) + owner_username: Mapped[str] = mapped_column(String(255), nullable=False) + owner_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + owner_context_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict) + dedupe_key: Mapped[str] = mapped_column(String(64), nullable=False) + generation: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + receipt_ids_json: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) + prompt: Mapped[str] = mapped_column(Text, nullable=False, default="") + conversation_id: Mapped[str] = mapped_column(String(120), nullable=False, default="") + status: Mapped[str] = mapped_column(String(20), nullable=False, default="queued") + message: Mapped[str] = mapped_column(Text, nullable=False, default="") + claim_id: Mapped[str] = mapped_column(String(36), nullable=False, default="") + claim_no: Mapped[str] = mapped_column(String(80), nullable=False, default="") + uploaded_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + skipped_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + resolution: Mapped[str] = mapped_column(String(40), nullable=False, default="pending") + requires_confirmation: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + expense_case_id: Mapped[str] = mapped_column(String(36), nullable=False, default="") + application_claim_id: Mapped[str] = mapped_column(String(36), nullable=False, default="") + application_claim_no: Mapped[str] = mapped_column(String(80), nullable=False, default="") + confidence: Mapped[str] = mapped_column(String(20), nullable=False, default="") + confidence_score: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + match_reasons_json: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) + exceptions_json: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) + missing_fields_json: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) + risk_items_json: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) + candidates_json: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False, default=list) + draft_payload_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + error: Mapped[str] = mapped_column(Text, nullable=False, default="") + attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + lease_expires_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) diff --git a/server/src/app/schemas/attachment_association_job.py b/server/src/app/schemas/attachment_association_job.py index 7d3b0dc..21d94a6 100644 --- a/server/src/app/schemas/attachment_association_job.py +++ b/server/src/app/schemas/attachment_association_job.py @@ -8,21 +8,33 @@ from pydantic import BaseModel, Field, field_validator class AttachmentAssociationJobCreate(BaseModel): receipt_ids: list[str] = Field(default_factory=list, description="票据夹持久化票据 ID。") prompt: str = Field(default="", max_length=1000, description="用户发送时的上下文说明。") - conversation_id: str = Field(default="", max_length=120, description="前端会话 ID,用于状态恢复。") + conversation_id: str = Field( + default="", max_length=120, description="前端会话 ID,用于状态恢复。" + ) @field_validator("receipt_ids") @classmethod def validate_receipt_ids(cls, value: list[str]) -> list[str]: receipt_ids = [ - str(item or "").strip() - for item in list(value or []) - if str(item or "").strip() + str(item or "").strip() for item in list(value or []) if str(item or "").strip() ] if not receipt_ids: raise ValueError("请先完成附件 OCR 识别,再发起自动关联。") return list(dict.fromkeys(receipt_ids)) +class AttachmentAssociationCandidateRead(BaseModel): + target_type: str = "reimbursement_draft" + expense_case_id: str = "" + application_claim_id: str = "" + application_claim_no: str = "" + claim_id: str = "" + claim_no: str = "" + confidence: str = "low" + score: float = 0.0 + match_reasons: list[str] = Field(default_factory=list) + + class AttachmentAssociationJobRead(BaseModel): job_id: str status: str @@ -32,9 +44,21 @@ class AttachmentAssociationJobRead(BaseModel): claim_no: str = "" uploaded_count: int = 0 skipped_count: int = 0 + resolution: str = "pending" + requires_confirmation: bool = False + expense_case_id: str = "" + application_claim_id: str = "" + application_claim_no: str = "" + confidence: str = "" + confidence_score: float = 0.0 + match_reasons: list[str] = Field(default_factory=list) + exceptions: list[str] = Field(default_factory=list) + missing_fields: list[str] = Field(default_factory=list) + risk_items: list[str] = Field(default_factory=list) + candidates: list[AttachmentAssociationCandidateRead] = Field(default_factory=list) + draft_payload: dict[str, object] | None = None error: str = "" prompt: str = "" conversation_id: str = "" created_at: datetime updated_at: datetime - diff --git a/server/src/app/services/attachment_association_job_store.py b/server/src/app/services/attachment_association_job_store.py new file mode 100644 index 0000000..6680a47 --- /dev/null +++ b/server/src/app/services/attachment_association_job_store.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Any +from uuid import uuid4 + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.api.deps import CurrentUserContext +from app.models.attachment_association_job import AttachmentAssociationJob +from app.schemas.attachment_association_job import ( + AttachmentAssociationJobCreate, + AttachmentAssociationJobRead, +) + +JOB_LEASE_SECONDS = 300 + + +@dataclass(slots=True) +class ClaimedAttachmentAssociationJob: + job_id: str + receipt_ids: list[str] + current_user: CurrentUserContext + attempt_count: int + + +def create_persistent_job( + db: Session, + payload: AttachmentAssociationJobCreate, + current_user: CurrentUserContext, +) -> AttachmentAssociationJobRead: + tenant_id = normalize_tenant_id(current_user.tenant_id) + owner_username = str(current_user.username or current_user.name or "").strip() + receipt_ids = list(payload.receipt_ids) + dedupe_key = build_job_dedupe_key(receipt_ids) + existing = db.scalar( + _latest_generation_query( + tenant_id=tenant_id, + owner_username=owner_username, + dedupe_key=dedupe_key, + ) + ) + if existing is not None and _can_reuse_job(existing): + return job_to_read(existing) + + generation = int(existing.generation or 0) + 1 if existing is not None else 1 + job = AttachmentAssociationJob( + id=f"attachment-association-{uuid4()}", + tenant_id=tenant_id, + owner_username=owner_username, + owner_name=str(current_user.name or "").strip(), + owner_context_json=serialize_current_user(current_user), + dedupe_key=dedupe_key, + generation=generation, + receipt_ids_json=receipt_ids, + prompt=str(payload.prompt or "").strip(), + conversation_id=str(payload.conversation_id or "").strip(), + status="queued", + message="已创建附件关联任务,等待后台处理。", + ) + db.add(job) + try: + db.commit() + except IntegrityError: + db.rollback() + existing = db.scalar( + _latest_generation_query( + tenant_id=tenant_id, + owner_username=owner_username, + dedupe_key=dedupe_key, + ) + ) + if existing is None: + raise + return job_to_read(existing) + db.refresh(job) + return job_to_read(job) + + +def get_authorized_persistent_job( + db: Session, + job_id: str, + current_user: CurrentUserContext, +) -> AttachmentAssociationJob | None: + job = db.get(AttachmentAssociationJob, str(job_id or "").strip()) + if job is None or job.tenant_id != normalize_tenant_id(current_user.tenant_id): + return None + if current_user.is_admin: + return job + return job if job.owner_username == str(current_user.username or "").strip() else None + + +def claim_persistent_job( + db: Session, + job_id: str, +) -> ClaimedAttachmentAssociationJob | None: + job = db.scalar( + select(AttachmentAssociationJob) + .where(AttachmentAssociationJob.id == str(job_id or "").strip()) + .with_for_update() + ) + if job is None or job.status in {"succeeded", "failed"}: + db.rollback() + return None + now = datetime.now(UTC) + lease_expires_at = as_utc(job.lease_expires_at) + if job.status == "running" and lease_expires_at is not None and lease_expires_at > now: + db.rollback() + return None + job.status = "running" + job.message = "正在匹配费用事件和可关联草稿..." + job.attempt_count = int(job.attempt_count or 0) + 1 + job.lease_expires_at = now + timedelta(seconds=JOB_LEASE_SECONDS) + job.updated_at = now + claimed = ClaimedAttachmentAssociationJob( + job_id=job.id, + receipt_ids=[str(item) for item in list(job.receipt_ids_json or []) if str(item)], + current_user=deserialize_current_user(job.owner_context_json), + attempt_count=job.attempt_count, + ) + db.commit() + return claimed + + +def update_persistent_job( + db: Session, + job_id: str, + *, + expected_attempt_count: int, + **updates: Any, +) -> None: + job = db.scalar( + select(AttachmentAssociationJob) + .where( + AttachmentAssociationJob.id == str(job_id or "").strip(), + AttachmentAssociationJob.attempt_count == expected_attempt_count, + AttachmentAssociationJob.status == "running", + ) + .with_for_update() + ) + if job is None: + db.rollback() + return + for key, value in updates.items(): + if hasattr(job, key): + setattr(job, key, value) + job.lease_expires_at = None + job.updated_at = datetime.now(UTC) + db.commit() + + +def job_to_read(job: AttachmentAssociationJob) -> AttachmentAssociationJobRead: + return AttachmentAssociationJobRead( + job_id=job.id, + status=job.status, + message=job.message, + receipt_ids=list(job.receipt_ids_json or []), + claim_id=job.claim_id, + claim_no=job.claim_no, + uploaded_count=job.uploaded_count, + skipped_count=job.skipped_count, + resolution=job.resolution, + requires_confirmation=job.requires_confirmation, + expense_case_id=job.expense_case_id, + application_claim_id=job.application_claim_id, + application_claim_no=job.application_claim_no, + confidence=job.confidence, + confidence_score=job.confidence_score, + match_reasons=list(job.match_reasons_json or []), + exceptions=list(job.exceptions_json or []), + missing_fields=list(job.missing_fields_json or []), + risk_items=list(job.risk_items_json or []), + candidates=list(job.candidates_json or []), + draft_payload=dict(job.draft_payload_json) if job.draft_payload_json else None, + error=job.error, + prompt=job.prompt, + conversation_id=job.conversation_id, + created_at=job.created_at, + updated_at=job.updated_at, + ) + + +def _latest_generation_query( + *, + tenant_id: str, + owner_username: str, + dedupe_key: str, +): + return ( + select(AttachmentAssociationJob) + .where( + AttachmentAssociationJob.tenant_id == tenant_id, + AttachmentAssociationJob.owner_username == owner_username, + AttachmentAssociationJob.dedupe_key == dedupe_key, + ) + .order_by( + AttachmentAssociationJob.generation.desc(), + AttachmentAssociationJob.created_at.desc(), + ) + .limit(1) + ) + + +def _can_reuse_job(job: AttachmentAssociationJob) -> bool: + if job.status in {"queued", "running"}: + return True + return ( + job.status == "succeeded" + and not bool(job.requires_confirmation) + and str(job.resolution or "").strip() == "auto_associated" + ) + + +def build_job_dedupe_key(receipt_ids: list[str]) -> str: + normalized = "\n".join(sorted({str(item or "").strip() for item in receipt_ids if item})) + return hashlib.sha256(normalized.encode("utf-8")).hexdigest() + + +def normalize_tenant_id(value: str | None) -> str: + return str(value or "default").strip() or "default" + + +def as_utc(value: datetime | None) -> datetime | None: + if value is None: + return None + return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + + +def serialize_current_user(current_user: CurrentUserContext) -> dict[str, Any]: + return { + "username": current_user.username, + "name": current_user.name, + "role_codes": list(current_user.role_codes or []), + "is_admin": bool(current_user.is_admin), + "tenant_id": normalize_tenant_id(current_user.tenant_id), + "department_name": current_user.department_name, + "cost_center": current_user.cost_center, + "position": current_user.position, + "grade": current_user.grade, + "employee_no": current_user.employee_no, + "manager_name": current_user.manager_name, + "employee_id": current_user.employee_id, + "auth_session_id": current_user.auth_session_id, + } + + +def deserialize_current_user(payload: dict[str, Any] | None) -> CurrentUserContext: + data = dict(payload or {}) + return CurrentUserContext( + username=str(data.get("username") or "").strip(), + name=str(data.get("name") or "").strip(), + role_codes=[str(item) for item in list(data.get("role_codes") or []) if str(item)], + is_admin=bool(data.get("is_admin")), + tenant_id=normalize_tenant_id(str(data.get("tenant_id") or "default")), + department_name=str(data.get("department_name") or ""), + cost_center=str(data.get("cost_center") or ""), + position=str(data.get("position") or ""), + grade=str(data.get("grade") or ""), + employee_no=str(data.get("employee_no") or ""), + manager_name=str(data.get("manager_name") or ""), + employee_id=str(data.get("employee_id") or ""), + auth_session_id=str(data.get("auth_session_id") or ""), + ) diff --git a/server/src/app/services/attachment_association_jobs.py b/server/src/app/services/attachment_association_jobs.py index a25b4ea..c0cd4d9 100644 --- a/server/src/app/services/attachment_association_jobs.py +++ b/server/src/app/services/attachment_association_jobs.py @@ -1,222 +1,124 @@ from __future__ import annotations -import re -from dataclasses import dataclass, field -from datetime import UTC, date, datetime -from decimal import Decimal +import hashlib +from collections.abc import Callable +from contextlib import contextmanager from threading import Lock -from typing import Any, Callable -from uuid import uuid4 +from typing import Any +from sqlalchemy import text from sqlalchemy.orm import Session, sessionmaker from app.api.deps import CurrentUserContext -from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.schemas.attachment_association_job import ( AttachmentAssociationJobCreate, AttachmentAssociationJobRead, ) from app.schemas.receipt_folder import ReceiptFolderDetailRead -from app.schemas.reimbursement import ExpenseClaimItemCreate -from app.services.expense_claim_constants import ( - DOCUMENT_TYPE_ITEM_TYPE_MAP, - EDITABLE_CLAIM_STATUSES, +from app.services.attachment_association_job_store import ( + ClaimedAttachmentAssociationJob, + claim_persistent_job, + create_persistent_job, + get_authorized_persistent_job, + job_to_read, + update_persistent_job, ) -from app.services.expense_claims import ExpenseClaimService +from app.services.expense_receipt_association import ExpenseReceiptAssociationService from app.services.receipt_folder import ReceiptFolderService +SessionFactory = sessionmaker[Session] | Callable[[], Session] -CITY_NAMES = ( - "北京", - "上海", - "广州", - "深圳", - "武汉", - "南京", - "杭州", - "成都", - "重庆", - "西安", - "天津", - "苏州", - "长沙", - "郑州", - "青岛", - "厦门", - "宁波", - "无锡", - "合肥", - "福州", - "昆明", - "大连", - "沈阳", - "济南", - "哈尔滨", - "长春", - "南昌", - "太原", - "贵阳", - "南宁", - "石家庄", - "兰州", - "银川", - "西宁", - "海口", - "拉萨", -) - -TERMINAL_STATUSES = {"succeeded", "failed"} - - -@dataclass(slots=True) -class AttachmentAssociationJobState: - job_id: str - owner_username: str - owner_name: str - receipt_ids: list[str] - prompt: str = "" - conversation_id: str = "" - status: str = "queued" - message: str = "已创建附件关联任务,等待后台处理。" - claim_id: str = "" - claim_no: str = "" - uploaded_count: int = 0 - skipped_count: int = 0 - error: str = "" - created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) - updated_at: datetime = field(default_factory=lambda: datetime.now(UTC)) - - def to_read(self) -> AttachmentAssociationJobRead: - return AttachmentAssociationJobRead( - job_id=self.job_id, - status=self.status, - message=self.message, - receipt_ids=list(self.receipt_ids), - claim_id=self.claim_id, - claim_no=self.claim_no, - uploaded_count=self.uploaded_count, - skipped_count=self.skipped_count, - error=self.error, - prompt=self.prompt, - conversation_id=self.conversation_id, - created_at=self.created_at, - updated_at=self.updated_at, - ) - - -@dataclass(slots=True) -class AttachmentAssociationCandidate: - claim: ExpenseClaim - score: int - reasons: list[str] - - -_jobs: dict[str, AttachmentAssociationJobState] = {} -_jobs_lock = Lock() +_receipt_locks: dict[str, Lock] = {} +_job_claim_locks: dict[str, Lock] = {} +_receipt_locks_guard = Lock() def clear_attachment_association_jobs_for_tests() -> None: - with _jobs_lock: - _jobs.clear() + """测试数据库按用例隔离;这里只清理进程级互斥锁。""" + with _receipt_locks_guard: + _receipt_locks.clear() + _job_claim_locks.clear() def create_attachment_association_job( payload: AttachmentAssociationJobCreate, current_user: CurrentUserContext, + db: Session, ) -> AttachmentAssociationJobRead: - job_id = f"attachment-association-{uuid4()}" - state = AttachmentAssociationJobState( - job_id=job_id, - owner_username=str(current_user.username or "").strip(), - owner_name=str(current_user.name or "").strip(), - receipt_ids=list(payload.receipt_ids), - prompt=str(payload.prompt or "").strip(), - conversation_id=str(payload.conversation_id or "").strip(), - ) - with _jobs_lock: - _jobs[job_id] = state - return state.to_read() + return create_persistent_job(db, payload, current_user) def get_attachment_association_job( job_id: str, current_user: CurrentUserContext, + db: Session, ) -> AttachmentAssociationJobRead | None: - state = _get_authorized_state(job_id, current_user) - return state.to_read() if state is not None else None + job = get_authorized_persistent_job(db, job_id, current_user) + return job_to_read(job) if job is not None else None def run_attachment_association_job( job_id: str, - current_user: CurrentUserContext, - session_factory: sessionmaker[Session] | Callable[[], Session], + session_factory: SessionFactory, ) -> None: - state = _get_authorized_state(job_id, current_user) - if state is None or state.status in TERMINAL_STATUSES: + with _receipt_locks_guard: + claim_lock = _job_claim_locks.setdefault(str(job_id or "").strip(), Lock()) + with claim_lock: + with session_factory() as claim_db: + claimed = claim_persistent_job(claim_db, job_id) + if claimed is None: return - _update_job(job_id, status="running", message="正在匹配可关联的报销草稿...") try: with session_factory() as db: - result = AttachmentAssociationJobRunner(db).run( - receipt_ids=state.receipt_ids, - current_user=current_user, + with _receipt_execution_locks(db, claimed): + result = AttachmentAssociationJobRunner(db).run( + receipt_ids=claimed.receipt_ids, + current_user=claimed.current_user, + ) + requires_confirmation = bool(result.get("requires_confirmation")) + if requires_confirmation: + exceptions = [ + str(item) for item in list(result.get("exceptions") or []) if str(item).strip() + ] + message = exceptions[0] if exceptions else "匹配结果需要确认,系统未修改任何报销数据。" + else: + uploaded_count = int(result.get("uploaded_count") or 0) + skipped_count = int(result.get("skipped_count") or 0) + if uploaded_count == 0 and skipped_count > 0: + message = "票据此前已经归集到目标草稿,本次未重复写入。" + else: + message = ( + f"已自动关联到 {result.get('claim_no') or '报销草稿'}," + f"成功归集 {uploaded_count} 份附件。" + ) + with session_factory() as update_db: + update_persistent_job( + update_db, + job_id, + expected_attempt_count=claimed.attempt_count, + status="succeeded", + message=message, + error="", + **_job_result_updates(result), ) - _update_job( - job_id, - status="succeeded", - message=f"已自动关联到 {result['claim_no']},成功归集 {result['uploaded_count']} 份附件。", - claim_id=str(result["claim_id"]), - claim_no=str(result["claim_no"]), - uploaded_count=int(result["uploaded_count"]), - skipped_count=int(result["skipped_count"]), - error="", - ) except Exception as exc: message = str(exc).strip() or "自动关联任务执行失败,请稍后重试。" - _update_job( - job_id, - status="failed", - message=message, - error=message, - ) - - -def _get_authorized_state( - job_id: str, - current_user: CurrentUserContext, -) -> AttachmentAssociationJobState | None: - normalized_job_id = str(job_id or "").strip() - with _jobs_lock: - state = _jobs.get(normalized_job_id) - if state is None: - return None - if current_user.is_admin: - return state - username = str(current_user.username or "").strip() - name = str(current_user.name or "").strip() - if username and username == state.owner_username: - return state - if name and name == state.owner_name: - return state - return None - - -def _update_job(job_id: str, **updates: Any) -> None: - with _jobs_lock: - state = _jobs.get(str(job_id or "").strip()) - if state is None: - return - for key, value in updates.items(): - if hasattr(state, key): - setattr(state, key, value) - state.updated_at = datetime.now(UTC) + with session_factory() as update_db: + update_persistent_job( + update_db, + job_id, + expected_attempt_count=claimed.attempt_count, + status="failed", + message=message, + error=message, + resolution="failed", + ) class AttachmentAssociationJobRunner: def __init__(self, db: Session) -> None: self.db = db - self.claim_service = ExpenseClaimService(db) self.receipt_service = ReceiptFolderService() def run( @@ -226,57 +128,23 @@ class AttachmentAssociationJobRunner: current_user: CurrentUserContext, ) -> dict[str, Any]: receipts = self._load_receipts(receipt_ids, current_user) - candidates = self._rank_claims(receipts, current_user) - if not candidates: - raise ValueError("没有找到可自动关联的报销草稿,请先新建草稿或补充说明。") - - recommended = candidates[0] - runner_up = candidates[1] if len(candidates) > 1 else None - if recommended.score < 5 or (runner_up is not None and recommended.score - runner_up.score < 2): - raise ValueError("找到多个可能关联的报销草稿,请补充说明或手动选择后再归集。") - - uploaded_count = 0 - skipped_count = 0 - for receipt in receipts: - if self._is_linked_to_other_claim(receipt, recommended.claim.id): - skipped_count += 1 - continue - target_item = self._resolve_target_item( - claim_id=recommended.claim.id, - receipt=receipt, - current_user=current_user, - ) - source_path, media_type, file_name = self.receipt_service.resolve_source(receipt.id, current_user) - result = self.claim_service.upload_claim_item_attachment( - claim_id=recommended.claim.id, - item_id=target_item.id, - filename=file_name, - content=source_path.read_bytes(), - media_type=media_type, - current_user=current_user, - source_receipt_id=receipt.id, - ) - if result is None: - skipped_count += 1 - else: - uploaded_count += 1 - - if uploaded_count <= 0: - raise ValueError("未能归集任何附件,请进入报销单详情手动核对。") - return { - "claim_id": recommended.claim.id, - "claim_no": recommended.claim.claim_no, - "uploaded_count": uploaded_count, - "skipped_count": skipped_count, - } + return ExpenseReceiptAssociationService(self.db).associate( + receipts=receipts, + current_user=current_user, + ) def _load_receipts( self, receipt_ids: list[str], current_user: CurrentUserContext, ) -> list[ReceiptFolderDetailRead]: - receipts = [] - for receipt_id in list(dict.fromkeys(str(item or "").strip() for item in receipt_ids if str(item or "").strip())): + receipts: list[ReceiptFolderDetailRead] = [] + normalized_ids = list( + dict.fromkeys( + str(item or "").strip() for item in receipt_ids if str(item or "").strip() + ) + ) + for receipt_id in normalized_ids: try: receipts.append(self.receipt_service.get_receipt(receipt_id, current_user)) except FileNotFoundError as exc: @@ -285,265 +153,87 @@ class AttachmentAssociationJobRunner: raise ValueError("当前附件没有持久化票据记录,请重新上传后再试。") return receipts - def _rank_claims( - self, - receipts: list[ReceiptFolderDetailRead], - current_user: CurrentUserContext, - ) -> list[AttachmentAssociationCandidate]: - signals = _collect_receipt_signals(receipts) - claims = [ - claim - for claim in self.claim_service.list_claims(current_user) - if self._is_auto_association_candidate(claim) - ] - ranked = [ - candidate - for candidate in ( - self._score_claim(claim, signals) - for claim in claims - ) - if candidate.score > 0 - ] - return sorted(ranked, key=lambda item: item.score, reverse=True) - def _is_auto_association_candidate(self, claim: ExpenseClaim) -> bool: - status = str(claim.status or "").strip().lower() - if status not in EDITABLE_CLAIM_STATUSES: - return False - return not self.claim_service._is_expense_application_claim(claim) +@contextmanager +def _receipt_execution_locks( + db: Session, + claimed: ClaimedAttachmentAssociationJob, +): + lock_keys = sorted( + { + f"{claimed.current_user.tenant_id}:{receipt_id}" + for receipt_id in claimed.receipt_ids + if receipt_id + } + ) + with _receipt_locks_guard: + process_locks = [_receipt_locks.setdefault(key, Lock()) for key in lock_keys] + for process_lock in process_locks: + process_lock.acquire() - def _score_claim( - self, - claim: ExpenseClaim, - signals: dict[str, Any], - ) -> AttachmentAssociationCandidate: - claim_text = _build_claim_text(claim) - compact_claim_text = _normalize_text(claim_text) - claim_dates = _extract_date_tokens(claim_text) - claim_cities = _unique([*_extract_city_tokens(claim_text), *_extract_city_tokens(claim.location)]) - reasons: list[str] = [] - score = 0 - - if _dates_overlap(signals["dates"], claim_dates): - score += 4 - reasons.append("票据日期与报销单日期一致") - - matched_cities = [city for city in signals["cities"] if city in compact_claim_text] - if matched_cities: - score += min(4, len(matched_cities) * 2) - reasons.append(f"地点或行程包含 {'、'.join(matched_cities)}") - - if len(claim_cities) >= 2 and len(matched_cities) >= 2: - score += 2 - reasons.append("票据往返城市与报销事由吻合") - - if str(claim.status or "").strip().lower() == "draft": - score += 1 - reasons.append("当前单据仍是可归集草稿") - - return AttachmentAssociationCandidate(claim=claim, score=score, reasons=reasons) - - @staticmethod - def _is_linked_to_other_claim(receipt: ReceiptFolderDetailRead, claim_id: str) -> bool: - linked_claim_id = str(receipt.linked_claim_id or "").strip() - return bool(str(receipt.status or "").strip() == "linked" and linked_claim_id and linked_claim_id != claim_id) - - def _resolve_target_item( - self, - *, - claim_id: str, - receipt: ReceiptFolderDetailRead, - current_user: CurrentUserContext, - ) -> ExpenseClaimItem: - claim = self.claim_service.get_claim(claim_id, current_user) - if claim is None: - raise ValueError("匹配到的报销草稿不存在,请刷新后再试。") - - preferred_type = _resolve_receipt_item_type(receipt) - empty_items = [ - item - for item in list(claim.items or []) - if not str(item.invoice_id or "").strip() and not item.is_system_generated - ] - for item in empty_items: - if preferred_type and str(item.item_type or "").strip() == preferred_type: - return item - if empty_items: - return empty_items[0] - - before_ids = {str(item.id) for item in list(claim.items or [])} - created_claim = self.claim_service.create_claim_item( - claim_id=claim.id, - payload=_build_item_payload_from_receipt(claim, receipt, preferred_type), - current_user=current_user, - ) - if created_claim is None: - raise ValueError("无法创建票据归集明细,请进入详情页手动处理。") - for item in list(created_claim.items or []): - if str(item.id) not in before_ids and not str(item.invoice_id or "").strip(): - return item - raise ValueError("无法找到可归集的费用明细,请进入详情页手动处理。") + advisory_keys: list[int] = [] + try: + if db.get_bind().dialect.name == "postgresql": + advisory_keys = [_advisory_lock_key(key) for key in lock_keys] + for advisory_key in advisory_keys: + db.execute( + text("SELECT pg_advisory_lock(:lock_key)"), + {"lock_key": advisory_key}, + ) + yield + finally: + try: + if db.get_bind().dialect.name == "postgresql": + # 业务异常可能让当前事务失效;会话级 advisory lock 必须在可用事务中解锁。 + db.rollback() + for advisory_key in reversed(advisory_keys): + db.execute( + text("SELECT pg_advisory_unlock(:lock_key)"), + {"lock_key": advisory_key}, + ) + except Exception: + # 连接关闭也会释放会话级锁,不能让解锁异常覆盖原始业务异常。 + db.rollback() + finally: + for process_lock in reversed(process_locks): + process_lock.release() -def _normalize_text(value: Any) -> str: - return re.sub(r"\s+", "", str(value or "").strip()) +def _advisory_lock_key(value: str) -> int: + return int.from_bytes( + hashlib.sha256(value.encode("utf-8")).digest()[:8], + byteorder="big", + signed=True, + ) -def _unique(values: list[str] | tuple[str, ...]) -> list[str]: - return list(dict.fromkeys(str(item or "").strip() for item in values if str(item or "").strip())) - - -def _extract_date_tokens(text: Any) -> list[str]: - source = str(text or "") - matches = [ - *re.finditer(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}", source), - *re.finditer(r"\d{1,2}月\d{1,2}", source), - ] - return _unique([_normalize_date_token(match.group(0)) for match in matches]) - - -def _normalize_date_token(value: Any) -> str: - if isinstance(value, (date, datetime)): - return value.isoformat()[:10] - text = str(value or "").strip() - full_match = re.search(r"(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})", text) - if full_match: - year, month, day = full_match.groups() - return f"{year}-{month.zfill(2)}-{day.zfill(2)}" - short_match = re.search(r"(\d{1,2})月(\d{1,2})", text) - if short_match: - month, day = short_match.groups() - return f"{month.zfill(2)}-{day.zfill(2)}" - return "" - - -def _extract_city_tokens(text: Any) -> list[str]: - compact = _normalize_text(text) - if not compact: - return [] - return [city for city in CITY_NAMES if city in compact] - - -def _dates_overlap(left: list[str], right: list[str]) -> bool: - for left_date in left: - if not left_date: - continue - for right_date in right: - if right_date and (left_date == right_date or left_date.endswith(right_date) or right_date.endswith(left_date)): - return True - return False - - -def _collect_receipt_signals(receipts: list[ReceiptFolderDetailRead]) -> dict[str, Any]: - text = "\n".join(_build_receipt_text(receipt) for receipt in receipts) - dates = _unique([ - *_extract_date_tokens(text), - *[str(receipt.document_date or "").strip() for receipt in receipts], - ]) +def _job_result_updates(result: dict[str, Any]) -> dict[str, Any]: return { - "text": text, - "dates": dates, - "cities": _unique(_extract_city_tokens(text)), + "claim_id": str(result.get("claim_id") or ""), + "claim_no": str(result.get("claim_no") or ""), + "uploaded_count": int(result.get("uploaded_count") or 0), + "skipped_count": int(result.get("skipped_count") or 0), + "resolution": str(result.get("resolution") or ""), + "requires_confirmation": bool(result.get("requires_confirmation")), + "expense_case_id": str(result.get("expense_case_id") or ""), + "application_claim_id": str(result.get("application_claim_id") or ""), + "application_claim_no": str(result.get("application_claim_no") or ""), + "confidence": str(result.get("confidence") or ""), + "confidence_score": float(result.get("confidence_score") or 0.0), + "match_reasons_json": [ + str(item) for item in list(result.get("match_reasons") or []) + ], + "exceptions_json": [str(item) for item in list(result.get("exceptions") or [])], + "missing_fields_json": [ + str(item) for item in list(result.get("missing_fields") or []) + ], + "risk_items_json": [str(item) for item in list(result.get("risk_items") or [])], + "candidates_json": [ + dict(item) for item in list(result.get("candidates") or []) if isinstance(item, dict) + ], + "draft_payload_json": ( + dict(result["draft_payload"]) + if isinstance(result.get("draft_payload"), dict) + else None + ), } - - -def _build_receipt_text(receipt: ReceiptFolderDetailRead) -> str: - fields_text = "\n".join( - f"{field.label} {field.value}" - for field in list(receipt.fields or []) - if str(field.label or field.value or "").strip() - ) - return "\n".join( - value - for value in ( - receipt.file_name, - receipt.summary, - receipt.ocr_text, - receipt.document_date, - receipt.merchant_name, - fields_text, - ) - if str(value or "").strip() - ) - - -def _build_claim_text(claim: ExpenseClaim) -> str: - item_text = "\n".join( - " ".join( - str(value or "").strip() - for value in ( - item.item_date.isoformat() if item.item_date else "", - item.item_type, - item.item_reason, - item.item_location, - item.item_note, - ) - if str(value or "").strip() - ) - for item in list(claim.items or []) - ) - occurred_at = claim.occurred_at.isoformat()[:10] if claim.occurred_at else "" - return "\n".join( - value - for value in ( - claim.claim_no, - claim.expense_type, - claim.status, - claim.reason, - claim.location, - occurred_at, - item_text, - ) - if str(value or "").strip() - ) - - -def _resolve_receipt_item_type(receipt: ReceiptFolderDetailRead) -> str: - document_type = str(receipt.document_type or "").strip() - if document_type in DOCUMENT_TYPE_ITEM_TYPE_MAP: - return DOCUMENT_TYPE_ITEM_TYPE_MAP[document_type] - scene_code = str(receipt.scene_code or "").strip() - if scene_code == "travel": - return "travel" - return scene_code or "other" - - -def _build_item_payload_from_receipt( - claim: ExpenseClaim, - receipt: ReceiptFolderDetailRead, - preferred_type: str, -) -> ExpenseClaimItemCreate: - item_date = _resolve_receipt_item_date(receipt) or (claim.occurred_at.date() if claim.occurred_at else None) - return ExpenseClaimItemCreate( - item_date=item_date, - item_type=preferred_type or str(claim.expense_type or "").strip() or "other", - item_reason=str(receipt.summary or receipt.file_name or "").strip(), - item_location=_resolve_receipt_item_location(receipt) or str(claim.location or "").strip(), - item_amount=Decimal("0.00"), - ) - - -def _resolve_receipt_item_date(receipt: ReceiptFolderDetailRead) -> date | None: - for value in [ - *[field.value for field in list(receipt.fields or []) if "日期" in str(field.label or "") or "时间" in str(field.label or "")], - receipt.document_date, - ]: - token = _normalize_date_token(value) - if len(token) == 10: - try: - return date.fromisoformat(token) - except ValueError: - continue - return None - - -def _resolve_receipt_item_location(receipt: ReceiptFolderDetailRead) -> str: - for field in list(receipt.fields or []): - label = str(field.label or "") - value = str(field.value or "").strip() - if value and ("行程" in label or "到达" in label or "地点" in label or "城市" in label): - cities = _extract_city_tokens(value) - return cities[-1] if cities else value[:40] - cities = _extract_city_tokens(_build_receipt_text(receipt)) - return cities[-1] if cities else "" - diff --git a/server/src/app/services/expense_cases.py b/server/src/app/services/expense_cases.py index a5b4a25..83052ce 100644 --- a/server/src/app/services/expense_cases.py +++ b/server/src/app/services/expense_cases.py @@ -135,6 +135,113 @@ class ExpenseCaseService: self.db.flush() return link + def link_resource( + self, + expense_case: ExpenseCase, + *, + resource_type: str, + resource_id: str, + relation_type: str, + tenant_id: str | None = None, + ) -> ExpenseCaseLink: + """把非 Claim 资源幂等关联到费用事件,不负责提交事务。""" + normalized_tenant = self.normalize_tenant_id(tenant_id or expense_case.tenant_id) + normalized_type = str(resource_type or "").strip() + normalized_id = str(resource_id or "").strip() + normalized_relation = str(relation_type or "").strip() + if expense_case.tenant_id != normalized_tenant: + raise PermissionError("不能把资源关联到其他租户的费用事件。") + if not normalized_type or not normalized_id or not normalized_relation: + raise ValueError("费用事件资源关联缺少必要字段。") + + existing_link = self.db.scalar( + select(ExpenseCaseLink).where( + ExpenseCaseLink.resource_type == normalized_type, + ExpenseCaseLink.resource_id == normalized_id, + ) + ) + if existing_link is not None: + if ( + existing_link.tenant_id != normalized_tenant + or existing_link.expense_case_id != expense_case.id + ): + raise PermissionError("资源已经关联到其他费用事件。") + return existing_link + + link = ExpenseCaseLink( + id=str(uuid.uuid4()), + tenant_id=normalized_tenant, + expense_case_id=expense_case.id, + resource_type=normalized_type, + resource_id=normalized_id, + relation_type=normalized_relation, + ) + self.db.add(link) + self.db.flush() + return link + + def record_resource_event( + self, + expense_case: ExpenseCase, + *, + aggregate_type: str, + aggregate_id: str, + event_type: str, + actor_id: str, + idempotency_key: str, + tenant_id: str | None = None, + correlation_id: str | None = None, + causation_id: str | None = None, + payload: dict[str, Any] | None = None, + delivery_status: str = "pending", + ) -> BusinessEvent: + """为票据等通用资源写入幂等业务事件,不负责提交事务。""" + normalized_tenant = self.normalize_tenant_id(tenant_id or expense_case.tenant_id) + normalized_type = str(aggregate_type or "").strip() + normalized_id = str(aggregate_id or "").strip() + normalized_event_type = str(event_type or "").strip() + if expense_case.tenant_id != normalized_tenant: + raise PermissionError("不能向其他租户的费用事件写入业务事件。") + if not normalized_type or not normalized_id or not normalized_event_type: + raise ValueError("业务事件缺少聚合或事件类型。") + + normalized_idempotency_key = self._normalize_idempotency_key(idempotency_key) + existing_event = self.db.scalar( + select(BusinessEvent).where( + BusinessEvent.tenant_id == normalized_tenant, + BusinessEvent.aggregate_type == normalized_type, + BusinessEvent.aggregate_id == normalized_id, + BusinessEvent.event_type == normalized_event_type, + BusinessEvent.idempotency_key == normalized_idempotency_key, + ) + ) + if existing_event is not None: + if existing_event.expense_case_id != expense_case.id: + raise PermissionError("业务事件已经属于其他费用事件。") + return existing_event + + normalized_correlation_id = self.normalize_correlation_id(correlation_id) + event = BusinessEvent( + id=str(uuid.uuid4()), + tenant_id=normalized_tenant, + expense_case_id=expense_case.id, + aggregate_type=normalized_type, + aggregate_id=normalized_id, + event_type=normalized_event_type, + event_version=1, + idempotency_key=normalized_idempotency_key, + correlation_id=normalized_correlation_id, + causation_id=self.normalize_correlation_id(causation_id) if causation_id else None, + actor_id=str(actor_id or "system").strip() or "system", + actor_type="system" if str(actor_id or "").strip() == "system" else "user", + payload_json=dict(payload or {}), + delivery_status=str(delivery_status or "pending").strip() or "pending", + occurred_at=datetime.now(UTC), + ) + self.db.add(event) + self.db.flush() + return event + def record_claim_event( self, claim: ExpenseClaim, diff --git a/server/src/app/services/expense_claim_attachment_operations.py b/server/src/app/services/expense_claim_attachment_operations.py index 99df4b6..3c57a24 100644 --- a/server/src/app/services/expense_claim_attachment_operations.py +++ b/server/src/app/services/expense_claim_attachment_operations.py @@ -1,113 +1,14 @@ from __future__ import annotations -import json -import re import shutil -import uuid -from collections import defaultdict -from datetime import UTC, date, datetime, timedelta -from decimal import Decimal, InvalidOperation +from datetime import UTC, datetime from pathlib import Path from types import SimpleNamespace from typing import Any -from sqlalchemy import func, or_, select -from sqlalchemy import inspect as sqlalchemy_inspect -from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Session, selectinload - from app.api.deps import CurrentUserContext -from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType -from app.models.agent_asset import AgentAsset -from app.models.employee import Employee from app.models.financial_record import ExpenseClaim, ExpenseClaimItem -from app.schemas.ontology import OntologyEntity, OntologyParseResult -from app.schemas.reimbursement import ( - ExpenseClaimItemCreate, - ExpenseClaimItemUpdate, - ExpenseClaimUpdate, - TravelReimbursementCalculatorRequest, -) -from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager -from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY -from app.services.agent_foundation import AgentFoundationService -from app.services.audit import AuditLogService from app.services.document_preview import DocumentPreviewAssets -from app.services.document_intelligence import build_document_insight -from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy -from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation -from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage -from app.services.expense_claim_constants import ( - EXPENSE_TYPE_LABELS, - MAX_DRAFT_CLAIMS_PER_USER, - EDITABLE_CLAIM_STATUSES, - SYSTEM_GENERATED_ITEM_TYPES, - TRAVEL_DETAIL_ITEM_TYPES, - TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES, - DOCUMENT_TYPE_ITEM_TYPE_MAP, - DOCUMENT_TYPE_SCENE_MAP, - DOCUMENT_FACT_ITEM_TYPES, - ROUTE_DESCRIPTION_ITEM_TYPES, - DOCUMENT_TRIP_DATE_LABELS, - DOCUMENT_TRIP_DATE_REQUIREMENT_LABELS, - DOCUMENT_TRIP_DATE_KEYS, - DOCUMENT_GENERIC_DATE_KEYS, - DOCUMENT_INVOICE_DATE_KEYS, - DOCUMENT_TRIP_DATE_LABEL_TOKENS, - DOCUMENT_GENERIC_DATE_LABEL_TOKENS, - DOCUMENT_INVOICE_DATE_LABEL_TOKENS, - DOCUMENT_ROUTE_FORMAT_PATTERN, - DOCUMENT_ROUTE_TEXT_PATTERN, - DOCUMENT_ROUTE_ORIGIN_LABELS, - DOCUMENT_ROUTE_DESTINATION_LABELS, - GENERIC_ATTACHMENT_BACKFILL_ITEM_TYPES, - LOCATION_REQUIRED_EXPENSE_TYPES, - EXPENSE_SCENE_KEYWORDS, - EXPENSE_TYPE_ALLOWED_DOCUMENT_SCENES, - DOCUMENT_SCENE_LABELS, - DOCUMENT_ASSOCIATION_REVIEW_ACTIONS, - PERSISTENT_EXPENSE_REVIEW_ACTIONS, - RETURN_REASON_OPTIONS, - MAX_CLAIM_NO_RETRY_ATTEMPTS, - DOCUMENT_DATE_PATTERN, - SYSTEM_GENERATED_REASON_PREFIXES, - LEADING_REASON_TIME_PATTERNS, - AI_REVIEW_LOOKBACK_DAYS, - AI_REVIEW_REPEAT_RISK_WARNING_COUNT, - AI_REVIEW_REPEAT_RISK_BLOCK_COUNT, - TRAVEL_REVIEW_RELEVANT_EXPENSE_TYPES, - TRAVEL_REVIEW_LONG_DISTANCE_DOCUMENT_TYPES, - TRAVEL_POLICY_CITY_TIERS, - TRAVEL_POLICY_CITY_MATCH_ORDER, - TRAVEL_POLICY_BAND_LABELS, - TRAVEL_POLICY_HOTEL_LIMITS, - TRAVEL_POLICY_ALLOWED_TRANSPORT_LEVELS, - TRAVEL_POLICY_ROUTE_EXCEPTION_KEYWORDS, - TRAVEL_POLICY_STANDARD_EXCEPTION_KEYWORDS, - TRAVEL_POLICY_FLIGHT_CLASS_PATTERNS, - TRAVEL_POLICY_TRAIN_CLASS_PATTERNS, - TRAVEL_POLICY_HOTEL_NIGHT_PATTERN, -) -from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin -from app.services.expense_amounts import ( - extract_amount_candidates, - format_decimal_amount, - is_amount_match_date_fragment, - is_date_like_amount_candidate, - is_probable_year_amount, - parse_document_amount_value, - parse_plain_document_amount_value, - resolve_document_field_amount, - resolve_document_item_amount, - resolve_document_text_amount, -) -from app.services.expense_rule_runtime import ( - DEFAULT_SCENE_RULE_ASSET_CODE, - ExpenseRuleRuntimeService, - RuntimeTravelPolicy, - build_default_expense_rule_catalog, - resolve_document_type_label, -) from app.services.ocr import OcrService from app.services.receipt_folder import ReceiptFolderService @@ -123,6 +24,10 @@ class ExpenseClaimAttachmentOperationsMixin: media_type: str | None, current_user: CurrentUserContext, source_receipt_id: str = "", + commit: bool = True, + link_source_receipt: bool = True, + write_audit: bool = True, + refresh_pre_review: bool = True, ) -> dict[str, Any] | None: claim, item = self._get_claim_item_or_raise( claim_id=claim_id, @@ -260,30 +165,36 @@ class ExpenseClaimAttachmentOperationsMixin: "source_receipt_id": str(source_receipt_id or "").strip(), } self._attachment_storage.write_meta(file_path, meta) - ReceiptFolderService().save_linked_attachment( - file_path=file_path, - media_type=resolved_media_type, - document=ocr_document, - current_user=current_user, - claim_id=claim.id, - claim_no=claim.claim_no, - item_id=item.id, - source_receipt_id=source_receipt_id, - ) + if link_source_receipt: + ReceiptFolderService().save_linked_attachment( + file_path=file_path, + media_type=resolved_media_type, + document=ocr_document, + current_user=current_user, + claim_id=claim.id, + claim_no=claim.claim_no, + item_id=item.id, + source_receipt_id=source_receipt_id, + ) self._sync_claim_from_items(claim) - self._refresh_claim_pre_review_flags(claim, is_application_claim=False) - self.db.commit() - self.db.refresh(claim) + if refresh_pre_review: + self._refresh_claim_pre_review_flags(claim, is_application_claim=False) + if commit: + self.db.commit() + self.db.refresh(claim) + else: + self.db.flush() - self.audit_service.log_action( - actor=current_user.name or current_user.username, - action="expense_claim.attachment_upload", - resource_type="expense_claim", - resource_id=claim.id, - before_json=before_json, - after_json=self._serialize_claim(claim), - ) + if write_audit: + self.audit_service.log_action( + actor=current_user.name or current_user.username, + action="expense_claim.attachment_upload", + resource_type="expense_claim", + resource_id=claim.id, + before_json=before_json, + after_json=self._serialize_claim(claim), + ) return { "message": f"{normalized_name} 已上传并关联到当前费用明细。", diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index 07b32a1..47d1f9c 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -1,31 +1,21 @@ from __future__ import annotations -import json import re -import shutil import uuid from collections.abc import Callable -from collections import defaultdict -from datetime import UTC, date, datetime, timedelta +from datetime import UTC, datetime from decimal import Decimal, InvalidOperation -from pathlib import Path -from types import SimpleNamespace from typing import Any -from sqlalchemy import delete, func, or_, select -from sqlalchemy import inspect as sqlalchemy_inspect -from sqlalchemy.exc import IntegrityError +from sqlalchemy import delete, select from sqlalchemy.orm import Session, selectinload from app.api.deps import CurrentUserContext -from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType -from app.models.agent_asset import AgentAsset from app.models.employee import Employee from app.models.expense_case import BusinessEvent from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.hermes_report import HermesRiskReport from app.models.risk_observation import RiskObservation, RiskObservationFeedback -from app.schemas.ontology import OntologyEntity, OntologyParseResult from app.schemas.reimbursement import ( ExpenseClaimItemCreate, ExpenseClaimItemUpdate, @@ -33,114 +23,40 @@ from app.schemas.reimbursement import ( ExpenseClaimUpdate, TravelReimbursementCalculatorRequest, ) -from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager -from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY -from app.services.agent_foundation import AgentFoundationService from app.services.audit import AuditLogService -from app.services.document_intelligence import build_document_insight -from app.services.document_numbering import is_application_claim_no from app.services.budget_types import BudgetControlError +from app.services.document_numbering import is_application_claim_no +from app.services.expense_cases import ExpenseCaseService from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy +from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin from app.services.expense_claim_approval_routing import ExpenseClaimApprovalRoutingMixin -from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation -from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage -from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin from app.services.expense_claim_attachment_analysis import ExpenseClaimAttachmentAnalysisMixin from app.services.expense_claim_attachment_document import ExpenseClaimAttachmentDocumentMixin from app.services.expense_claim_attachment_operations import ExpenseClaimAttachmentOperationsMixin +from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation +from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin -from app.services.expense_claim_workflow_constants import DIRECT_MANAGER_APPROVAL_STAGE -from app.services.expense_claim_workflow_repair import ExpenseClaimWorkflowRepairMixin +from app.services.expense_claim_constants import ( + RETURN_REASON_OPTIONS, + STANDARD_ADJUSTMENT_RISK_SOURCE, +) from app.services.expense_claim_document_item_builder import ExpenseClaimDocumentItemBuilderMixin from app.services.expense_claim_document_parsing import ExpenseClaimDocumentParsingMixin from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin from app.services.expense_claim_draft_persistence import ExpenseClaimDraftPersistenceMixin from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError +from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyResolverMixin from app.services.expense_claim_pagination import ExpenseClaimPaginationMixin from app.services.expense_claim_pre_review import ExpenseClaimPreReviewMixin -from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyResolverMixin from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin -from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags -from app.services.expense_claim_risk_stage import with_risk_business_stage from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin -from app.services.receipt_folder import ReceiptFolderService -from app.services.expense_claim_constants import ( - EXPENSE_TYPE_LABELS, - MAX_DRAFT_CLAIMS_PER_USER, - EDITABLE_CLAIM_STATUSES, - SYSTEM_GENERATED_ITEM_TYPES, - TRAVEL_DETAIL_ITEM_TYPES, - TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES, - DOCUMENT_TYPE_ITEM_TYPE_MAP, - DOCUMENT_TYPE_SCENE_MAP, - DOCUMENT_FACT_ITEM_TYPES, - ROUTE_DESCRIPTION_ITEM_TYPES, - DOCUMENT_TRIP_DATE_LABELS, - DOCUMENT_TRIP_DATE_REQUIREMENT_LABELS, - DOCUMENT_TRIP_DATE_KEYS, - DOCUMENT_GENERIC_DATE_KEYS, - DOCUMENT_INVOICE_DATE_KEYS, - DOCUMENT_TRIP_DATE_LABEL_TOKENS, - DOCUMENT_GENERIC_DATE_LABEL_TOKENS, - DOCUMENT_INVOICE_DATE_LABEL_TOKENS, - DOCUMENT_ROUTE_FORMAT_PATTERN, - DOCUMENT_ROUTE_TEXT_PATTERN, - DOCUMENT_ROUTE_ORIGIN_LABELS, - DOCUMENT_ROUTE_DESTINATION_LABELS, - GENERIC_ATTACHMENT_BACKFILL_ITEM_TYPES, - LOCATION_REQUIRED_EXPENSE_TYPES, - EXPENSE_SCENE_KEYWORDS, - EXPENSE_TYPE_ALLOWED_DOCUMENT_SCENES, - DOCUMENT_SCENE_LABELS, - DOCUMENT_ASSOCIATION_REVIEW_ACTIONS, - PERSISTENT_EXPENSE_REVIEW_ACTIONS, - RETURN_REASON_OPTIONS, - MAX_CLAIM_NO_RETRY_ATTEMPTS, - DOCUMENT_DATE_PATTERN, - SYSTEM_GENERATED_REASON_PREFIXES, - LEADING_REASON_TIME_PATTERNS, - AI_REVIEW_LOOKBACK_DAYS, - AI_REVIEW_REPEAT_RISK_WARNING_COUNT, - AI_REVIEW_REPEAT_RISK_BLOCK_COUNT, - TRAVEL_REVIEW_RELEVANT_EXPENSE_TYPES, - TRAVEL_REVIEW_LONG_DISTANCE_DOCUMENT_TYPES, - TRAVEL_POLICY_CITY_TIERS, - TRAVEL_POLICY_CITY_MATCH_ORDER, - TRAVEL_POLICY_BAND_LABELS, - TRAVEL_POLICY_HOTEL_LIMITS, - TRAVEL_POLICY_ALLOWED_TRANSPORT_LEVELS, - TRAVEL_POLICY_ROUTE_EXCEPTION_KEYWORDS, - TRAVEL_POLICY_STANDARD_EXCEPTION_KEYWORDS, - TRAVEL_POLICY_FLIGHT_CLASS_PATTERNS, - TRAVEL_POLICY_TRAIN_CLASS_PATTERNS, - TRAVEL_POLICY_HOTEL_NIGHT_PATTERN, - STANDARD_ADJUSTMENT_RISK_SOURCE, -) -from app.services.expense_cases import ExpenseCaseService +from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin -from app.services.expense_amounts import ( - extract_amount_candidates, - format_decimal_amount, - is_amount_match_date_fragment, - is_date_like_amount_candidate, - is_probable_year_amount, - parse_document_amount_value, - parse_plain_document_amount_value, - resolve_document_field_amount, - resolve_document_item_amount, - resolve_document_text_amount, -) -from app.services.expense_rule_runtime import ( - DEFAULT_SCENE_RULE_ASSET_CODE, - ExpenseRuleRuntimeService, - RuntimeTravelPolicy, - build_default_expense_rule_catalog, - resolve_document_type_label, -) -from app.services.ocr import OcrService - - +from app.services.expense_claim_risk_stage import with_risk_business_stage +from app.services.expense_claim_workflow_constants import DIRECT_MANAGER_APPROVAL_STAGE +from app.services.expense_claim_workflow_repair import ExpenseClaimWorkflowRepairMixin +from app.services.receipt_folder import ReceiptFolderService class ExpenseClaimStandardAdjustmentMixin: @@ -260,7 +176,9 @@ class ExpenseClaimStandardAdjustmentMixin: return None try: - from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService + from app.services.travel_reimbursement_calculator import ( + TravelReimbursementCalculatorService, + ) result = TravelReimbursementCalculatorService(self.db).calculate( TravelReimbursementCalculatorRequest( @@ -473,6 +391,9 @@ class ExpenseClaimItemActionMixin: claim_id: str, payload: ExpenseClaimItemCreate | None, current_user: CurrentUserContext, + commit: bool = True, + write_audit: bool = True, + refresh_pre_review: bool = True, ) -> ExpenseClaim | None: claim = self.get_claim(claim_id, current_user) if claim is None: @@ -507,18 +428,23 @@ class ExpenseClaimItemActionMixin: self.db.add(item) self._sync_claim_from_items(claim) - self._refresh_claim_pre_review_flags(claim, is_application_claim=False) - self.db.commit() - self.db.refresh(claim) + if refresh_pre_review: + self._refresh_claim_pre_review_flags(claim, is_application_claim=False) + if commit: + self.db.commit() + self.db.refresh(claim) + else: + self.db.flush() - self.audit_service.log_action( - actor=current_user.name or current_user.username, - action="expense_claim.item_create", - resource_type="expense_claim", - resource_id=claim.id, - before_json=before_json, - after_json=self._serialize_claim(claim), - ) + if write_audit: + self.audit_service.log_action( + actor=current_user.name or current_user.username, + action="expense_claim.item_create", + resource_type="expense_claim", + resource_id=claim.id, + before_json=before_json, + after_json=self._serialize_claim(claim), + ) return claim diff --git a/server/src/app/services/expense_receipt_association.py b/server/src/app/services/expense_receipt_association.py new file mode 100644 index 0000000..880366a --- /dev/null +++ b/server/src/app/services/expense_receipt_association.py @@ -0,0 +1,746 @@ +from __future__ import annotations + +import copy +import hashlib +import shutil +import tempfile +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import date +from decimal import Decimal +from pathlib import Path +from threading import Lock +from typing import Any + +from sqlalchemy import select, text +from sqlalchemy.orm import Session + +from app.api.deps import CurrentUserContext +from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink +from app.models.financial_record import ExpenseClaim, ExpenseClaimItem +from app.schemas.receipt_folder import ReceiptFolderDetailRead +from app.schemas.reimbursement import ExpenseClaimItemCreate +from app.services.expense_cases import ExpenseCaseService +from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage +from app.services.expense_claim_constants import DOCUMENT_TYPE_ITEM_TYPE_MAP +from app.services.expense_claims import ExpenseClaimService +from app.services.expense_receipt_matcher import ( + ExpenseReceiptMatchCandidate, + ExpenseReceiptMatcher, + extract_city_tokens, + normalize_date_token, +) +from app.services.receipt_folder import ReceiptFolderService + +_claim_locks: dict[str, Lock] = {} +_claim_locks_guard = Lock() + + +@dataclass(slots=True) +class _ReceiptMutation: + receipt: ReceiptFolderDetailRead + item_id: str + original_meta: dict[str, Any] + attachment_dir: str = "" + attachment_backup_dir: str = "" + + +@dataclass(slots=True) +class _AssociationDbSnapshot: + tenant_id: str + case_ids: set[str] + link_ids: set[str] + event_ids: set[str] + claim_id: str + receipt_ids: list[str] + + +@dataclass(slots=True) +class _ClaimMutationSnapshot: + claim_id: str + amount: Decimal + invoice_count: int + risk_flags_json: list[Any] + approval_stage: str + submitted_at: Any + status: str + items: dict[str, dict[str, Any]] + + +class ExpenseReceiptAssociationService: + """高置信票据归集编排;低置信结果保持严格零写入。""" + + def __init__(self, db: Session) -> None: + self.db = db + self.claim_service = ExpenseClaimService(db) + self.case_service = ExpenseCaseService(db) + self.receipt_service = ReceiptFolderService() + self.matcher = ExpenseReceiptMatcher(db) + self.attachment_storage = ExpenseClaimAttachmentStorage() + + def associate( + self, + *, + receipts: list[ReceiptFolderDetailRead], + current_user: CurrentUserContext, + ) -> dict[str, Any]: + match = self.matcher.match(receipts=receipts, current_user=current_user) + if match.requires_confirmation or match.recommended is None: + return self._build_confirmation_result(match) + + recommended = match.recommended + claim = recommended.claim + if claim is None: + return self._build_confirmation_result(match) + initial_claim_id = str(claim.id or "") + with _claim_execution_lock( + self.db, + tenant_id=current_user.tenant_id, + claim_id=initial_claim_id, + ): + locked_match = self.matcher.match(receipts=receipts, current_user=current_user) + locked_recommended = locked_match.recommended + locked_claim = getattr(locked_recommended, "claim", None) + if locked_match.requires_confirmation or locked_claim is None: + return self._build_confirmation_result(locked_match) + if str(locked_claim.id or "") != initial_claim_id: + locked_match.resolution = "requires_confirmation" + locked_match.requires_confirmation = True + locked_match.exceptions.append( + "并发处理期间候选报销草稿发生变化,本次未写入,请重新确认。" + ) + return self._build_confirmation_result(locked_match) + return self._associate_matched( + receipts=receipts, + current_user=current_user, + match=locked_match, + recommended=locked_recommended, + claim=locked_claim, + ) + + def _associate_matched( + self, + *, + receipts: list[ReceiptFolderDetailRead], + current_user: CurrentUserContext, + match: Any, + recommended: ExpenseReceiptMatchCandidate, + claim: ExpenseClaim, + ) -> dict[str, Any]: + claim_snapshot = self._capture_claim_snapshot(claim) + db_snapshot = self._capture_db_snapshot( + tenant_id=current_user.tenant_id, + claim_id=claim.id, + receipt_ids=[receipt.id for receipt in receipts], + ) + expense_case = self._ensure_expense_case( + recommended, + claim=claim, + tenant_id=current_user.tenant_id, + ) + correlation_id = self.case_service.normalize_correlation_id( + "zero-entry:" + ":".join(sorted(receipt.id for receipt in receipts)) + ) + mutations: list[_ReceiptMutation] = [] + uploaded_count = 0 + skipped_count = 0 + try: + for receipt in receipts: + if self._is_linked_to_claim(receipt, claim.id): + skipped_count += 1 + item_id = str((receipt.raw_meta or {}).get("linked_item_id") or "").strip() + self._record_receipt_events( + expense_case, + receipt=receipt, + claim=claim, + item_id=item_id, + current_user=current_user, + correlation_id=correlation_id, + ) + continue + if self._is_linked_to_other_claim(receipt, claim.id): + raise ValueError("票据已归属其他报销单,自动归集已停止。") + + target_item = self._resolve_target_item( + claim_id=claim.id, + receipt=receipt, + current_user=current_user, + ) + source_path, media_type, file_name = self.receipt_service.resolve_source( + receipt.id, + current_user, + ) + mutation = self._prepare_receipt_mutation( + receipt=receipt, + claim_id=claim.id, + item_id=target_item.id, + ) + mutations.append(mutation) + result = self.claim_service.upload_claim_item_attachment( + claim_id=claim.id, + item_id=target_item.id, + filename=file_name, + content=source_path.read_bytes(), + media_type=media_type, + current_user=current_user, + source_receipt_id=receipt.id, + commit=False, + link_source_receipt=False, + write_audit=False, + refresh_pre_review=False, + ) + if result is None: + raise ValueError("无法把票据写入目标报销草稿。") + self.case_service.link_resource( + expense_case, + resource_type="receipt", + resource_id=receipt.id, + relation_type="receipt", + tenant_id=current_user.tenant_id, + ) + self._record_receipt_events( + expense_case, + receipt=receipt, + claim=claim, + item_id=target_item.id, + current_user=current_user, + correlation_id=correlation_id, + ) + self.receipt_service.mark_receipt_linked( + receipt_id=receipt.id, + current_user=current_user, + claim_id=claim.id, + claim_no=claim.claim_no, + item_id=target_item.id, + ) + uploaded_count += 1 + + self.db.commit() + self.db.refresh(claim) + self._discard_attachment_backups(mutations) + except Exception as exc: + self.db.rollback() + compensation_errors: list[str] = [] + try: + self._restore_claim_snapshot( + claim_snapshot, + db_snapshot=db_snapshot, + expense_case_id=expense_case.id, + ) + except Exception as compensation_exc: + compensation_errors.append(str(compensation_exc)) + for mutation in reversed(mutations): + try: + self.receipt_service.restore_receipt_meta( + receipt_id=mutation.receipt.id, + current_user=current_user, + meta=mutation.original_meta, + ) + except Exception as compensation_exc: + compensation_errors.append(str(compensation_exc)) + try: + self._restore_attachment_directory(mutation) + except Exception as compensation_exc: + compensation_errors.append(str(compensation_exc)) + if compensation_errors: + raise RuntimeError( + f"{exc};自动补偿未完全成功:{';'.join(compensation_errors)}" + ) from exc + raise + + return self._build_success_result( + match_candidate=recommended, + expense_case=expense_case, + claim=claim, + uploaded_count=uploaded_count, + skipped_count=skipped_count, + candidates=[candidate.to_payload() for candidate in match.candidates], + ) + + def _ensure_expense_case( + self, + candidate: ExpenseReceiptMatchCandidate, + *, + claim: ExpenseClaim, + tenant_id: str, + ) -> ExpenseCase: + if candidate.expense_case is not None: + self.case_service.link_claim( + candidate.expense_case, + claim, + tenant_id=tenant_id, + relation_type="generated_reimbursement" if candidate.application_claim else "claim", + ) + return candidate.expense_case + if candidate.application_claim is not None: + expense_case = self.case_service.ensure_case_for_claim( + candidate.application_claim, + tenant_id=tenant_id, + relation_type="application", + ) + self.case_service.link_claim( + expense_case, + claim, + tenant_id=tenant_id, + relation_type="generated_reimbursement", + ) + return expense_case + return self.case_service.ensure_case_for_claim(claim, tenant_id=tenant_id) + + @staticmethod + def _capture_claim_snapshot(claim: ExpenseClaim) -> _ClaimMutationSnapshot: + return _ClaimMutationSnapshot( + claim_id=str(claim.id or ""), + amount=Decimal(claim.amount or Decimal("0.00")), + invoice_count=int(claim.invoice_count or 0), + risk_flags_json=copy.deepcopy(list(claim.risk_flags_json or [])), + approval_stage=str(claim.approval_stage or ""), + submitted_at=claim.submitted_at, + status=str(claim.status or ""), + items={ + str(item.id): { + "item_date": item.item_date, + "item_type": item.item_type, + "item_reason": item.item_reason, + "item_location": item.item_location, + "item_note": item.item_note, + "item_amount": Decimal(item.item_amount or Decimal("0.00")), + "invoice_id": item.invoice_id, + } + for item in list(claim.items or []) + }, + ) + + def _capture_db_snapshot( + self, + *, + tenant_id: str, + claim_id: str, + receipt_ids: list[str], + ) -> _AssociationDbSnapshot: + normalized_tenant = self.case_service.normalize_tenant_id(tenant_id) + receipt_ids = list(dict.fromkeys(str(item or "").strip() for item in receipt_ids if item)) + links = list( + self.db.scalars( + select(ExpenseCaseLink).where( + ExpenseCaseLink.tenant_id == normalized_tenant, + ( + (ExpenseCaseLink.resource_type == "expense_claim") + & (ExpenseCaseLink.resource_id == claim_id) + ) + | ( + (ExpenseCaseLink.resource_type == "receipt") + & ExpenseCaseLink.resource_id.in_(receipt_ids) + ), + ) + ).all() + ) + events = list( + self.db.scalars( + select(BusinessEvent).where( + BusinessEvent.tenant_id == normalized_tenant, + BusinessEvent.aggregate_type == "receipt", + BusinessEvent.aggregate_id.in_(receipt_ids), + ) + ).all() + ) + return _AssociationDbSnapshot( + tenant_id=normalized_tenant, + case_ids=set( + self.db.scalars( + select(ExpenseCase.id).where(ExpenseCase.tenant_id == normalized_tenant) + ).all() + ), + link_ids={str(link.id) for link in links}, + event_ids={str(event.id) for event in events}, + claim_id=str(claim_id or ""), + receipt_ids=receipt_ids, + ) + + def _restore_claim_snapshot( + self, + snapshot: _ClaimMutationSnapshot, + *, + db_snapshot: _AssociationDbSnapshot, + expense_case_id: str, + ) -> None: + claim = self.db.get(ExpenseClaim, snapshot.claim_id) + if claim is None: + return + for item in list(claim.items or []): + item_id = str(item.id or "") + item_snapshot = snapshot.items.get(item_id) + if item_snapshot is None: + claim.items.remove(item) + self.db.delete(item) + continue + for key, value in item_snapshot.items(): + setattr(item, key, value) + claim.amount = snapshot.amount + claim.invoice_count = snapshot.invoice_count + claim.risk_flags_json = copy.deepcopy(snapshot.risk_flags_json) + claim.approval_stage = snapshot.approval_stage + claim.submitted_at = snapshot.submitted_at + claim.status = snapshot.status + for event in list( + self.db.scalars( + select(BusinessEvent).where( + BusinessEvent.tenant_id == db_snapshot.tenant_id, + BusinessEvent.aggregate_type == "receipt", + BusinessEvent.aggregate_id.in_(db_snapshot.receipt_ids), + ) + ).all() + ): + if str(event.id) not in db_snapshot.event_ids: + self.db.delete(event) + for link in list( + self.db.scalars( + select(ExpenseCaseLink).where( + ExpenseCaseLink.tenant_id == db_snapshot.tenant_id, + ( + (ExpenseCaseLink.resource_type == "expense_claim") + & (ExpenseCaseLink.resource_id == db_snapshot.claim_id) + ) + | ( + (ExpenseCaseLink.resource_type == "receipt") + & ExpenseCaseLink.resource_id.in_(db_snapshot.receipt_ids) + ), + ) + ).all() + ): + if str(link.id) not in db_snapshot.link_ids: + self.db.delete(link) + if expense_case_id not in db_snapshot.case_ids: + created_case = self.db.get(ExpenseCase, expense_case_id) + if created_case is not None: + self.db.delete(created_case) + self.db.commit() + + def _prepare_receipt_mutation( + self, + *, + receipt: ReceiptFolderDetailRead, + claim_id: str, + item_id: str, + ) -> _ReceiptMutation: + attachment_dir = self.attachment_storage.build_item_dir( + claim_id, + item_id, + ) + backup_root = "" + if attachment_dir.exists(): + backup_path = Path(tempfile.mkdtemp(prefix="x-financial-attachment-backup-")) + shutil.copytree(attachment_dir, backup_path / "item") + backup_root = str(backup_path) + return _ReceiptMutation( + receipt=receipt, + item_id=item_id, + original_meta=copy.deepcopy(dict(receipt.raw_meta or {})), + attachment_dir=str(attachment_dir), + attachment_backup_dir=backup_root, + ) + + @staticmethod + def _discard_attachment_backups(mutations: list[_ReceiptMutation]) -> None: + for mutation in mutations: + if mutation.attachment_backup_dir: + shutil.rmtree(mutation.attachment_backup_dir, ignore_errors=True) + + @staticmethod + def _restore_attachment_directory(mutation: _ReceiptMutation) -> None: + attachment_dir = mutation.attachment_dir + if not attachment_dir: + return + shutil.rmtree(attachment_dir, ignore_errors=True) + backup_dir = mutation.attachment_backup_dir + backup_item_dir = Path(backup_dir) / "item" if backup_dir else None + if backup_item_dir is not None and backup_item_dir.exists(): + shutil.copytree(backup_item_dir, attachment_dir) + if backup_dir: + shutil.rmtree(backup_dir, ignore_errors=True) + + def _record_receipt_events( + self, + expense_case: ExpenseCase, + *, + receipt: ReceiptFolderDetailRead, + claim: ExpenseClaim, + item_id: str, + current_user: CurrentUserContext, + correlation_id: str, + ) -> None: + self.case_service.link_resource( + expense_case, + resource_type="receipt", + resource_id=receipt.id, + relation_type="receipt", + tenant_id=current_user.tenant_id, + ) + received_event = self.case_service.record_resource_event( + expense_case, + aggregate_type="receipt", + aggregate_id=receipt.id, + event_type="receipt_received", + actor_id=current_user.username, + tenant_id=current_user.tenant_id, + correlation_id=correlation_id, + idempotency_key=f"receipt-received:{receipt.id}", + payload={ + "file_name": receipt.file_name, + "document_type": receipt.document_type, + "scene_code": receipt.scene_code, + }, + ) + self.case_service.record_resource_event( + expense_case, + aggregate_type="receipt", + aggregate_id=receipt.id, + event_type="attachment_associated", + actor_id=current_user.username, + tenant_id=current_user.tenant_id, + correlation_id=correlation_id, + causation_id=received_event.id, + idempotency_key=f"receipt-associated:{receipt.id}:{claim.id}", + payload={ + "claim_id": claim.id, + "claim_no": claim.claim_no, + "item_id": item_id, + }, + ) + + def _resolve_target_item( + self, + *, + claim_id: str, + receipt: ReceiptFolderDetailRead, + current_user: CurrentUserContext, + ) -> ExpenseClaimItem: + claim = self.claim_service.get_claim(claim_id, current_user) + if claim is None: + raise ValueError("匹配到的报销草稿不存在,请刷新后再试。") + + preferred_type = resolve_receipt_item_type(receipt) + empty_items = [ + item + for item in list(claim.items or []) + if not str(item.invoice_id or "").strip() and not item.is_system_generated + ] + for item in empty_items: + if preferred_type and str(item.item_type or "").strip() == preferred_type: + return item + if empty_items: + return empty_items[0] + + before_ids = {str(item.id) for item in list(claim.items or [])} + created_claim = self.claim_service.create_claim_item( + claim_id=claim.id, + payload=build_item_payload_from_receipt(claim, receipt, preferred_type), + current_user=current_user, + commit=False, + write_audit=False, + refresh_pre_review=False, + ) + if created_claim is None: + raise ValueError("无法创建票据归集明细,请进入详情页手动处理。") + for item in list(created_claim.items or []): + if str(item.id) not in before_ids and not str(item.invoice_id or "").strip(): + return item + raise ValueError("无法找到可归集的费用明细,请进入详情页手动处理。") + + @staticmethod + def _is_linked_to_claim(receipt: ReceiptFolderDetailRead, claim_id: str) -> bool: + return ( + str(receipt.status or "").strip().lower() == "linked" + and str(receipt.linked_claim_id or "").strip() == str(claim_id or "").strip() + ) + + @staticmethod + def _is_linked_to_other_claim(receipt: ReceiptFolderDetailRead, claim_id: str) -> bool: + linked_claim_id = str(receipt.linked_claim_id or "").strip() + return bool( + str(receipt.status or "").strip().lower() == "linked" + and linked_claim_id + and linked_claim_id != str(claim_id or "").strip() + ) + + @staticmethod + def _build_confirmation_result(match: Any) -> dict[str, Any]: + recommended = match.recommended + return { + "resolution": "requires_confirmation", + "requires_confirmation": True, + "claim_id": str(getattr(getattr(recommended, "claim", None), "id", "") or ""), + "claim_no": str(getattr(getattr(recommended, "claim", None), "claim_no", "") or ""), + "expense_case_id": str( + getattr(getattr(recommended, "expense_case", None), "id", "") or "" + ), + "application_claim_id": str( + getattr(getattr(recommended, "application_claim", None), "id", "") or "" + ), + "application_claim_no": str( + getattr(getattr(recommended, "application_claim", None), "claim_no", "") or "" + ), + "confidence": str(getattr(recommended, "confidence", "low") or "low"), + "confidence_score": float(getattr(recommended, "normalized_score", 0.0) or 0.0), + "match_reasons": list(getattr(recommended, "reasons", []) or []), + "exceptions": list(match.exceptions or []), + "missing_fields": [], + "risk_items": [], + "candidates": [candidate.to_payload() for candidate in match.candidates], + "uploaded_count": 0, + "skipped_count": 0, + "draft_payload": None, + } + + @staticmethod + def _build_success_result( + *, + match_candidate: ExpenseReceiptMatchCandidate, + expense_case: ExpenseCase, + claim: ExpenseClaim, + uploaded_count: int, + skipped_count: int, + candidates: list[dict[str, Any]], + ) -> dict[str, Any]: + risk_items = [ + str(flag.get("message") or flag.get("label") or "").strip() + for flag in list(claim.risk_flags_json or []) + if isinstance(flag, dict) + and str(flag.get("severity") or "").strip().lower() in {"warning", "high", "critical"} + and str(flag.get("message") or flag.get("label") or "").strip() + ] + application = match_candidate.application_claim + return { + "resolution": "auto_associated", + "requires_confirmation": False, + "claim_id": str(claim.id or ""), + "claim_no": str(claim.claim_no or ""), + "expense_case_id": str(expense_case.id or ""), + "application_claim_id": str(getattr(application, "id", "") or ""), + "application_claim_no": str(getattr(application, "claim_no", "") or ""), + "confidence": match_candidate.confidence, + "confidence_score": match_candidate.normalized_score, + "match_reasons": list(match_candidate.reasons), + "exceptions": [], + "missing_fields": [], + "risk_items": risk_items, + "candidates": candidates, + "uploaded_count": uploaded_count, + "skipped_count": skipped_count, + "draft_payload": { + "draft_type": "expense", + "title": f"费用草稿 {claim.claim_no}", + "body": f"已自动归集 {uploaded_count} 份票据。", + "confirmation_required": bool(risk_items), + "claim_id": claim.id, + "claim_no": claim.claim_no, + "status": claim.status, + "approval_stage": claim.approval_stage, + "expense_type": claim.expense_type, + }, + } + + +@contextmanager +def _claim_execution_lock( + db: Session, + *, + tenant_id: str, + claim_id: str, +): + lock_key = f"claim:{str(tenant_id or 'default').strip() or 'default'}:{claim_id}" + with _claim_locks_guard: + process_lock = _claim_locks.setdefault(lock_key, Lock()) + process_lock.acquire() + + advisory_key: int | None = None + try: + # 初次匹配产生的只读事务可能早于互斥锁;进入锁后必须读取最新提交状态。 + db.rollback() + db.expire_all() + if db.get_bind().dialect.name == "postgresql": + advisory_key = _stable_advisory_lock_key(lock_key) + db.execute( + text("SELECT pg_advisory_lock(:lock_key)"), + {"lock_key": advisory_key}, + ) + yield + finally: + try: + if advisory_key is not None: + db.rollback() + db.execute( + text("SELECT pg_advisory_unlock(:lock_key)"), + {"lock_key": advisory_key}, + ) + except Exception: + # 连接关闭也会释放会话级锁,解锁失败不能覆盖原始业务异常。 + db.rollback() + finally: + process_lock.release() + + +def _stable_advisory_lock_key(value: str) -> int: + return int.from_bytes( + hashlib.sha256(value.encode("utf-8")).digest()[:8], + byteorder="big", + signed=True, + ) + + +def resolve_receipt_item_type(receipt: ReceiptFolderDetailRead) -> str: + document_type = str(receipt.document_type or "").strip() + if document_type in DOCUMENT_TYPE_ITEM_TYPE_MAP: + return DOCUMENT_TYPE_ITEM_TYPE_MAP[document_type] + scene_code = str(receipt.scene_code or "").strip() + return "travel" if scene_code == "travel" else scene_code or "other" + + +def build_item_payload_from_receipt( + claim: ExpenseClaim, + receipt: ReceiptFolderDetailRead, + preferred_type: str, +) -> ExpenseClaimItemCreate: + item_date = resolve_receipt_item_date(receipt) or ( + claim.occurred_at.date() if claim.occurred_at else None + ) + return ExpenseClaimItemCreate( + item_date=item_date, + item_type=preferred_type or str(claim.expense_type or "").strip() or "other", + item_reason=str(receipt.summary or receipt.file_name or "").strip(), + item_location=resolve_receipt_item_location(receipt) or str(claim.location or "").strip(), + item_amount=Decimal("0.00"), + ) + + +def resolve_receipt_item_date(receipt: ReceiptFolderDetailRead) -> date | None: + for value in [ + *[ + field.value + for field in list(receipt.fields or []) + if "日期" in str(field.label or "") or "时间" in str(field.label or "") + ], + receipt.document_date, + ]: + token = normalize_date_token(value) + if len(token) == 10: + try: + return date.fromisoformat(token) + except ValueError: + continue + return None + + +def resolve_receipt_item_location(receipt: ReceiptFolderDetailRead) -> str: + for field in list(receipt.fields or []): + label = str(field.label or "") + value = str(field.value or "").strip() + if value and ("行程" in label or "到达" in label or "地点" in label or "城市" in label): + cities = extract_city_tokens(value) + return cities[-1] if cities else value[:40] + cities = extract_city_tokens( + "\n".join( + str(value or "") for value in (receipt.file_name, receipt.summary, receipt.ocr_text) + ) + ) + return cities[-1] if cities else "" diff --git a/server/src/app/services/expense_receipt_matcher.py b/server/src/app/services/expense_receipt_matcher.py new file mode 100644 index 0000000..f3b83b5 --- /dev/null +++ b/server/src/app/services/expense_receipt_matcher.py @@ -0,0 +1,604 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from datetime import date, datetime +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from app.api.deps import CurrentUserContext +from app.models.expense_case import ExpenseCase, ExpenseCaseLink +from app.models.financial_record import ExpenseClaim +from app.schemas.receipt_folder import ReceiptFolderDetailRead +from app.services.expense_cases import ExpenseCaseService +from app.services.expense_claim_constants import EDITABLE_CLAIM_STATUSES +from app.services.expense_claims import ExpenseClaimService + +CITY_NAMES = ( + "北京", + "上海", + "广州", + "深圳", + "武汉", + "南京", + "杭州", + "成都", + "重庆", + "西安", + "天津", + "苏州", + "长沙", + "郑州", + "青岛", + "厦门", + "宁波", + "无锡", + "合肥", + "福州", + "昆明", + "大连", + "沈阳", + "济南", + "哈尔滨", + "长春", + "南昌", + "太原", + "贵阳", + "南宁", + "石家庄", + "兰州", + "银川", + "西宁", + "海口", + "拉萨", +) + +HIGH_CONFIDENCE_SCORE = 7 +MINIMUM_SCORE_LEAD = 2 +MINIMUM_PER_RECEIPT_SIGNAL_SCORE = 4 +APPROVED_APPLICATION_STATUSES = {"approved", "completed"} + + +@dataclass(slots=True) +class ExpenseReceiptMatchCandidate: + target_type: str + claim: ExpenseClaim | None + application_claim: ExpenseClaim | None + expense_case: ExpenseCase | None + score: int + reasons: list[str] = field(default_factory=list) + + @property + def confidence(self) -> str: + if self.score >= HIGH_CONFIDENCE_SCORE: + return "high" + if self.score >= 4: + return "medium" + return "low" + + @property + def normalized_score(self) -> float: + return round(min(1.0, max(0.0, self.score / 15)), 4) + + def to_payload(self) -> dict[str, Any]: + return { + "target_type": self.target_type, + "expense_case_id": str(getattr(self.expense_case, "id", "") or ""), + "application_claim_id": str(getattr(self.application_claim, "id", "") or ""), + "application_claim_no": str(getattr(self.application_claim, "claim_no", "") or ""), + "claim_id": str(getattr(self.claim, "id", "") or ""), + "claim_no": str(getattr(self.claim, "claim_no", "") or ""), + "confidence": self.confidence, + "score": self.normalized_score, + "match_reasons": list(self.reasons), + } + + +@dataclass(slots=True) +class ExpenseReceiptMatchResult: + resolution: str + requires_confirmation: bool + recommended: ExpenseReceiptMatchCandidate | None = None + candidates: list[ExpenseReceiptMatchCandidate] = field(default_factory=list) + exceptions: list[str] = field(default_factory=list) + + +class ExpenseReceiptMatcher: + """只读票据匹配器;评分阶段绝不修改 Claim、Case 或票据。""" + + def __init__(self, db: Session) -> None: + self.db = db + self.claim_service = ExpenseClaimService(db) + self.case_service = ExpenseCaseService(db) + + def match( + self, + *, + receipts: list[ReceiptFolderDetailRead], + current_user: CurrentUserContext, + ) -> ExpenseReceiptMatchResult: + signals = collect_receipt_signals(receipts) + accessible_claims = self._filter_claims_for_tenant( + self._list_accessible_claims(current_user), + tenant_id=current_user.tenant_id, + ) + accessible_by_id = {str(claim.id): claim for claim in accessible_claims if claim.id} + linked_claim_ids = { + str(receipt.linked_claim_id or "").strip() + for receipt in receipts + if str(receipt.status or "").strip().lower() == "linked" + and str(receipt.linked_claim_id or "").strip() + } + if len(linked_claim_ids) > 1: + return ExpenseReceiptMatchResult( + resolution="requires_confirmation", + requires_confirmation=True, + exceptions=["所选票据已经分属多个报销单,请先核对票据归属。"], + ) + if linked_claim_ids: + linked_claim = accessible_by_id.get(next(iter(linked_claim_ids))) + if linked_claim is None or not self._is_editable_reimbursement(linked_claim): + return ExpenseReceiptMatchResult( + resolution="requires_confirmation", + requires_confirmation=True, + exceptions=["票据已经关联到不可编辑或无权访问的报销单。"], + ) + replay_candidate = self._score_claim( + linked_claim, + signals=signals, + tenant_id=current_user.tenant_id, + accessible_by_id=accessible_by_id, + ) + replay_candidate.score = max(replay_candidate.score, 15) + replay_candidate.reasons.append("票据已经归集到该草稿,本次按幂等重放处理") + if not self._all_receipts_support_claim(receipts, linked_claim): + return ExpenseReceiptMatchResult( + resolution="requires_confirmation", + requires_confirmation=True, + recommended=replay_candidate, + candidates=[replay_candidate], + exceptions=["所选票据并非都与已关联草稿具备独立匹配证据,请逐份确认。"], + ) + return ExpenseReceiptMatchResult( + resolution="auto_associated", + requires_confirmation=False, + recommended=replay_candidate, + candidates=[replay_candidate], + ) + draft_candidates = [ + self._score_claim( + claim, + signals=signals, + tenant_id=current_user.tenant_id, + accessible_by_id=accessible_by_id, + ) + for claim in accessible_claims + if self._is_editable_reimbursement(claim) + ] + ranked_drafts = self._rank( + candidate for candidate in draft_candidates if candidate.score > 0 + ) + if ranked_drafts: + recommended = ranked_drafts[0] + runner_up = ranked_drafts[1] if len(ranked_drafts) > 1 else None + has_required_score = recommended.score >= HIGH_CONFIDENCE_SCORE + has_clear_lead = ( + runner_up is None or recommended.score - runner_up.score >= MINIMUM_SCORE_LEAD + ) + has_consistent_receipts = self._all_receipts_support_claim( + receipts, + recommended.claim, + ) + if has_required_score and has_clear_lead and has_consistent_receipts: + return ExpenseReceiptMatchResult( + resolution="auto_associated", + requires_confirmation=False, + recommended=recommended, + candidates=ranked_drafts[:5], + ) + exception = ( + "同批票据并非每一份都与候选草稿具备独立匹配证据,请逐份确认。" + if not has_consistent_receipts + else "票据与多个报销草稿的匹配证据不足,请确认目标单据。" + ) + return ExpenseReceiptMatchResult( + resolution="requires_confirmation", + requires_confirmation=True, + recommended=recommended, + candidates=ranked_drafts[:5], + exceptions=[exception], + ) + + application_candidates = [ + self._score_application( + claim, + signals=signals, + tenant_id=current_user.tenant_id, + ) + for claim in accessible_claims + if self._is_approved_application(claim) + ] + ranked_applications = self._rank( + candidate for candidate in application_candidates if candidate.score > 0 + ) + if ranked_applications: + return ExpenseReceiptMatchResult( + resolution="requires_confirmation", + requires_confirmation=True, + recommended=ranked_applications[0], + candidates=ranked_applications[:5], + exceptions=["找到可能的已审批申请,但没有可安全归集的系统报销草稿。"], + ) + return ExpenseReceiptMatchResult( + resolution="requires_confirmation", + requires_confirmation=True, + exceptions=["没有找到与当前票据匹配的报销草稿,请确认申请或补充行程信息。"], + ) + + def _list_accessible_claims( + self, + current_user: CurrentUserContext, + ) -> list[ExpenseClaim]: + """复用访问范围条件做纯查询,不触发旧工作流修复或提交。""" + stmt = ( + select(ExpenseClaim) + .options(selectinload(ExpenseClaim.items)) + .order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc()) + ) + stmt = self.claim_service._access_policy.apply_claim_scope(stmt, current_user) + return [ + claim + for claim in list(self.db.scalars(stmt).all()) + if self.claim_service._access_policy.is_claim_owned_by_current_user( + claim, + current_user, + ) + ] + + @staticmethod + def _all_receipts_support_claim( + receipts: list[ReceiptFolderDetailRead], + claim: ExpenseClaim | None, + ) -> bool: + if claim is None: + return False + for receipt in receipts: + if ( + str(receipt.status or "").strip().lower() == "linked" + and str(receipt.linked_claim_id or "").strip() == str(claim.id or "").strip() + ): + continue + signal_score, _reasons = score_claim_signals( + claim, + collect_receipt_signals([receipt]), + ) + if signal_score < MINIMUM_PER_RECEIPT_SIGNAL_SCORE: + return False + return True + + def _filter_claims_for_tenant( + self, + claims: list[ExpenseClaim], + *, + tenant_id: str, + ) -> list[ExpenseClaim]: + """ExpenseClaim 尚无 tenant_id;非默认租户必须由同租户 Case Link 证明归属。""" + normalized_tenant = self.case_service.normalize_tenant_id(tenant_id) + claim_ids = [str(claim.id) for claim in claims if str(claim.id or "").strip()] + if not claim_ids: + return [] + links = list( + self.db.scalars( + select(ExpenseCaseLink).where( + ExpenseCaseLink.resource_type == "expense_claim", + ExpenseCaseLink.resource_id.in_(claim_ids), + ) + ).all() + ) + links_by_claim_id = {str(link.resource_id): link for link in links} + case_ids = list({str(link.expense_case_id) for link in links}) + cases_by_id = { + str(expense_case.id): expense_case + for expense_case in list( + self.db.scalars(select(ExpenseCase).where(ExpenseCase.id.in_(case_ids))).all() + ) + } + filtered: list[ExpenseClaim] = [] + for claim in claims: + link = links_by_claim_id.get(str(claim.id)) + if link is None: + if normalized_tenant == "default": + filtered.append(claim) + continue + expense_case = cases_by_id.get(str(link.expense_case_id)) + if ( + link.tenant_id == normalized_tenant + and expense_case is not None + and expense_case.tenant_id == normalized_tenant + ): + filtered.append(claim) + return filtered + + @staticmethod + def _rank(candidates: Any) -> list[ExpenseReceiptMatchCandidate]: + return sorted( + list(candidates), + key=lambda item: (-item.score, str(getattr(item.claim, "claim_no", ""))), + ) + + def _score_claim( + self, + claim: ExpenseClaim, + *, + signals: dict[str, Any], + tenant_id: str, + accessible_by_id: dict[str, ExpenseClaim], + ) -> ExpenseReceiptMatchCandidate: + expense_case, application_claim = self._resolve_case_and_application( + claim, + tenant_id=tenant_id, + accessible_by_id=accessible_by_id, + ) + score, reasons = score_claim_signals(claim, signals) + if str(claim.status or "").strip().lower() == "draft": + score += 1 + reasons.append("当前单据仍是可归集草稿") + if expense_case is not None: + score += 1 + reasons.append("报销草稿已纳入同一费用事件") + if application_claim is not None and self._is_approved_application(application_claim): + score += 4 + reasons.append(f"关联申请 {application_claim.claim_no} 已审批通过") + return ExpenseReceiptMatchCandidate( + target_type="reimbursement_draft", + claim=claim, + application_claim=application_claim, + expense_case=expense_case, + score=score, + reasons=reasons, + ) + + def _score_application( + self, + claim: ExpenseClaim, + *, + signals: dict[str, Any], + tenant_id: str, + ) -> ExpenseReceiptMatchCandidate: + score, reasons = score_claim_signals(claim, signals) + expense_case = self._resolve_case(claim, tenant_id=tenant_id) + if expense_case is not None: + score += 1 + reasons.append("申请已纳入费用事件") + score += 2 + reasons.append("申请已审批通过") + return ExpenseReceiptMatchCandidate( + target_type="approved_application", + claim=None, + application_claim=claim, + expense_case=expense_case, + score=score, + reasons=reasons, + ) + + def _resolve_case_and_application( + self, + claim: ExpenseClaim, + *, + tenant_id: str, + accessible_by_id: dict[str, ExpenseClaim], + ) -> tuple[ExpenseCase | None, ExpenseClaim | None]: + expense_case = self._resolve_case(claim, tenant_id=tenant_id) + if expense_case is None: + return None, self._resolve_flag_application(claim, accessible_by_id) + links = list( + self.db.scalars( + select(ExpenseCaseLink).where( + ExpenseCaseLink.tenant_id == self.case_service.normalize_tenant_id(tenant_id), + ExpenseCaseLink.expense_case_id == expense_case.id, + ExpenseCaseLink.resource_type == "expense_claim", + ) + ).all() + ) + for link in links: + linked_claim = accessible_by_id.get(str(link.resource_id)) + if linked_claim is not None and self.claim_service._is_expense_application_claim( + linked_claim + ): + return expense_case, linked_claim + return expense_case, self._resolve_flag_application(claim, accessible_by_id) + + def _resolve_case(self, claim: ExpenseClaim, *, tenant_id: str) -> ExpenseCase | None: + normalized_tenant = self.case_service.normalize_tenant_id(tenant_id) + link = self.db.scalar( + select(ExpenseCaseLink).where( + ExpenseCaseLink.resource_type == "expense_claim", + ExpenseCaseLink.resource_id == claim.id, + ) + ) + if link is None or link.tenant_id != normalized_tenant: + return None + return self.db.scalar( + select(ExpenseCase).where( + ExpenseCase.id == link.expense_case_id, + ExpenseCase.tenant_id == normalized_tenant, + ) + ) + + @staticmethod + def _resolve_flag_application( + claim: ExpenseClaim, + accessible_by_id: dict[str, ExpenseClaim], + ) -> ExpenseClaim | None: + for flag in list(claim.risk_flags_json or []): + if not isinstance(flag, dict): + continue + application_id = str( + flag.get("application_claim_id") or flag.get("applicationClaimId") or "" + ).strip() + application = accessible_by_id.get(application_id) + if application is not None: + return application + return None + + def _is_editable_reimbursement(self, claim: ExpenseClaim) -> bool: + status = str(claim.status or "").strip().lower() + return ( + status in EDITABLE_CLAIM_STATUSES + and not self.claim_service._is_expense_application_claim(claim) + ) + + def _is_approved_application(self, claim: ExpenseClaim) -> bool: + status = str(claim.status or "").strip().lower() + return ( + status in APPROVED_APPLICATION_STATUSES + and self.claim_service._is_expense_application_claim(claim) + ) + + +def normalize_text(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "").strip()) + + +def unique(values: list[str] | tuple[str, ...]) -> list[str]: + return list( + dict.fromkeys(str(item or "").strip() for item in values if str(item or "").strip()) + ) + + +def extract_date_tokens(text: Any) -> list[str]: + source = str(text or "") + matches = [ + *re.finditer(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}", source), + *re.finditer(r"\d{1,2}月\d{1,2}", source), + ] + return unique([normalize_date_token(match.group(0)) for match in matches]) + + +def normalize_date_token(value: Any) -> str: + if isinstance(value, (date, datetime)): + return value.isoformat()[:10] + text = str(value or "").strip() + full_match = re.search(r"(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})", text) + if full_match: + year, month, day = full_match.groups() + return f"{year}-{month.zfill(2)}-{day.zfill(2)}" + short_match = re.search(r"(\d{1,2})月(\d{1,2})", text) + if short_match: + month, day = short_match.groups() + return f"{month.zfill(2)}-{day.zfill(2)}" + return "" + + +def extract_city_tokens(text: Any) -> list[str]: + compact = normalize_text(text) + return [city for city in CITY_NAMES if city in compact] if compact else [] + + +def dates_overlap(left: list[str], right: list[str]) -> bool: + return any( + left_date + and right_date + and ( + left_date == right_date + or left_date.endswith(right_date) + or right_date.endswith(left_date) + ) + for left_date in left + for right_date in right + ) + + +def collect_receipt_signals(receipts: list[ReceiptFolderDetailRead]) -> dict[str, Any]: + text = "\n".join(build_receipt_text(receipt) for receipt in receipts) + return { + "text": text, + "dates": unique( + [ + *extract_date_tokens(text), + *[str(receipt.document_date or "").strip() for receipt in receipts], + ] + ), + "cities": unique(extract_city_tokens(text)), + "scenes": unique([str(receipt.scene_code or "").strip().lower() for receipt in receipts]), + } + + +def build_receipt_text(receipt: ReceiptFolderDetailRead) -> str: + fields_text = "\n".join( + f"{field.label} {field.value}" + for field in list(receipt.fields or []) + if str(field.label or field.value or "").strip() + ) + return "\n".join( + value + for value in ( + receipt.file_name, + receipt.summary, + receipt.ocr_text, + receipt.document_date, + receipt.merchant_name, + fields_text, + ) + if str(value or "").strip() + ) + + +def build_claim_text(claim: ExpenseClaim) -> str: + item_text = "\n".join( + " ".join( + str(value or "").strip() + for value in ( + item.item_date.isoformat() if item.item_date else "", + item.item_type, + item.item_reason, + item.item_location, + item.item_note, + ) + if str(value or "").strip() + ) + for item in list(claim.items or []) + ) + occurred_at = claim.occurred_at.isoformat()[:10] if claim.occurred_at else "" + return "\n".join( + value + for value in ( + claim.claim_no, + claim.expense_type, + claim.status, + claim.reason, + claim.location, + occurred_at, + item_text, + ) + if str(value or "").strip() + ) + + +def score_claim_signals(claim: ExpenseClaim, signals: dict[str, Any]) -> tuple[int, list[str]]: + claim_text = build_claim_text(claim) + compact_claim_text = normalize_text(claim_text) + claim_dates = extract_date_tokens(claim_text) + claim_cities = unique([*extract_city_tokens(claim_text), *extract_city_tokens(claim.location)]) + reasons: list[str] = [] + score = 0 + if dates_overlap(list(signals.get("dates") or []), claim_dates): + score += 4 + reasons.append("票据日期与单据日期一致") + matched_cities = [ + city for city in list(signals.get("cities") or []) if city in compact_claim_text + ] + if matched_cities: + score += min(4, len(matched_cities) * 2) + reasons.append(f"地点或行程包含 {'、'.join(matched_cities)}") + if len(claim_cities) >= 2 and len(matched_cities) >= 2: + score += 2 + reasons.append("票据往返城市与单据事由吻合") + claim_scene = str(claim.expense_type or "").strip().lower().removesuffix("_application") + receipt_scenes = set(signals.get("scenes") or []) + if claim_scene and claim_scene in receipt_scenes: + score += 2 + reasons.append("票据费用场景与单据类型一致") + return score, reasons diff --git a/server/src/app/services/receipt_folder.py b/server/src/app/services/receipt_folder.py index 82201f9..8f0ef05 100644 --- a/server/src/app/services/receipt_folder.py +++ b/server/src/app/services/receipt_folder.py @@ -1,7 +1,7 @@ from __future__ import annotations -import json import hashlib +import json import mimetypes import re import shutil @@ -20,8 +20,8 @@ from app.schemas.receipt_folder import ( ReceiptFolderItemRead, ReceiptFolderUpdate, ) -from app.services.document_preview import DocumentPreviewAssets from app.services.document_intelligence import build_document_insight +from app.services.document_preview import DocumentPreviewAssets from app.services.ocr import SUPPORTED_SUFFIXES RECEIPT_DATE_PATTERN = re.compile( @@ -116,9 +116,16 @@ class ReceiptFolderStorageMixin: @staticmethod def _owner_key(current_user: CurrentUserContext) -> str: - raw = str(current_user.username or current_user.name or "anonymous").strip().lower() - normalized = re.sub(r"[^\w.\-\u4e00-\u9fff]+", "_", raw).strip("._") - return normalized or "anonymous" + raw_owner = str(current_user.username or current_user.name or "anonymous").strip().lower() + owner = re.sub(r"[^\w.\-\u4e00-\u9fff]+", "_", raw_owner).strip("._") or "anonymous" + raw_tenant = str(getattr(current_user, "tenant_id", "default") or "default").strip().lower() + tenant = re.sub(r"[^\w.\-]+", "_", raw_tenant).strip("._") or "default" + # 默认租户继续读取历史目录;其他租户必须使用独立命名空间。 + if raw_tenant == "default": + return owner + tenant_digest = hashlib.sha256(raw_tenant.encode("utf-8")).hexdigest()[:12] + owner_digest = hashlib.sha256(raw_owner.encode("utf-8")).hexdigest()[:12] + return f"{tenant[:48]}-{tenant_digest}__{owner[:64]}-{owner_digest}" @staticmethod def _should_persist_source(filename: str, content: bytes) -> bool: @@ -1180,6 +1187,8 @@ class ReceiptFolderService(ReceiptFolderStorageMixin, ReceiptFolderItemMixin, Re meta = { "id": receipt_id, "owner_key": owner_key, + "tenant_id": str(getattr(current_user, "tenant_id", "default") or "default").strip() + or "default", "file_name": normalized_name, "source_file_name": normalized_name, "media_type": resolved_media_type, @@ -1258,6 +1267,23 @@ class ReceiptFolderService(ReceiptFolderStorageMixin, ReceiptFolderItemMixin, Re self._write_meta(receipt_dir, meta) return self._build_item(meta) + def restore_receipt_meta( + self, + *, + receipt_id: str, + current_user: CurrentUserContext, + meta: dict[str, Any], + ) -> None: + """仅供跨数据库/文件事务补偿使用,恢复调用前的票据元数据。""" + receipt_dir = self._receipt_dir(self._owner_key(current_user), receipt_id) + restored = json.loads(json.dumps(meta, ensure_ascii=False)) + restored["id"] = str(receipt_id or "").strip() + restored["owner_key"] = self._owner_key(current_user) + restored["tenant_id"] = str( + getattr(current_user, "tenant_id", "default") or "default" + ).strip() or "default" + self._write_meta(receipt_dir, restored) + def list_receipts( self, *, diff --git a/server/tests/test_alembic_migrations.py b/server/tests/test_alembic_migrations.py index f0cc988..8e642d8 100644 --- a/server/tests/test_alembic_migrations.py +++ b/server/tests/test_alembic_migrations.py @@ -19,7 +19,7 @@ from app.db.schema_ownership import MIGRATION_OWNED_TABLES, create_legacy_schema MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip() LEGACY_PROBE_TABLE = "legacy_migration_probe_records" -HEAD_REVISION = "20260714_0005" +HEAD_REVISION = "20260716_0006" SERVER_DIR = Path(__file__).resolve().parents[1] ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini" @@ -274,11 +274,27 @@ def _assert_head_schema(engine: Engine) -> None: "uq_memory_evidence_links_entry_case", ("tenant_id", "memory_entry_id", "expense_case_id"), ) + _assert_unique_constraint( + engine, + "attachment_association_jobs", + "uq_attachment_association_jobs_owner_dedupe", + ("tenant_id", "owner_username", "dedupe_key", "generation"), + ) _assert_check_constraint( engine, "memory_entries", "ck_memory_entries_expired_fields", ) + _assert_check_constraint( + engine, + "attachment_association_jobs", + "ck_attachment_association_jobs_running_lease", + ) + _assert_check_constraint( + engine, + "attachment_association_jobs", + "ck_attachment_association_jobs_generation", + ) _assert_indexes( engine, @@ -318,6 +334,21 @@ def _assert_head_schema(engine: Engine) -> None: "ix_auth_sessions_tenant_username": ("tenant_id", "username"), }, ) + _assert_indexes( + engine, + "attachment_association_jobs", + { + "ix_attachment_association_jobs_owner_time": ( + "tenant_id", + "owner_username", + "created_at", + ), + "ix_attachment_association_jobs_status_lease": ( + "status", + "lease_expires_at", + ), + }, + ) _assert_indexes( engine, "ai_application_preview_decisions", diff --git a/server/tests/test_attachment_association_jobs.py b/server/tests/test_attachment_association_jobs.py index 084818e..f9be199 100644 --- a/server/tests/test_attachment_association_jobs.py +++ b/server/tests/test_attachment_association_jobs.py @@ -2,8 +2,10 @@ from __future__ import annotations import base64 from collections.abc import Generator -from datetime import UTC, date, datetime +from datetime import UTC, date, datetime, timedelta from decimal import Decimal +from threading import Lock, Thread +from time import sleep from auth_helpers import install_legacy_header_auth_override from fastapi.testclient import TestClient @@ -14,12 +16,26 @@ from app.api.deps import CurrentUserContext, get_db from app.api.v1.endpoints import attachment_association_jobs as attachment_jobs_endpoint from app.core.config import get_settings from app.main import create_app +from app.models.attachment_association_job import AttachmentAssociationJob from app.models.employee import Employee +from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink from app.models.financial_record import ExpenseClaim, ExpenseClaimItem +from app.schemas.attachment_association_job import AttachmentAssociationJobCreate from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead, OcrRecognizeFieldRead -from app.services.attachment_association_jobs import clear_attachment_association_jobs_for_tests +from app.services.attachment_association_job_store import ( + claim_persistent_job, + update_persistent_job, +) +from app.services.attachment_association_jobs import ( + clear_attachment_association_jobs_for_tests, + create_attachment_association_job, + get_attachment_association_job, + run_attachment_association_job, +) +from app.services.expense_cases import ExpenseCaseService from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage from app.services.expense_claims import ExpenseClaimService +from app.services.expense_receipt_association import ExpenseReceiptAssociationService from app.services.ocr import OcrService from app.services.receipt_folder import ReceiptFolderService from app.test_helpers.db import build_in_memory_session_factory @@ -87,6 +103,164 @@ def seed_travel_claim(db: Session) -> ExpenseClaim: return claim +def seed_approved_application_for_draft(db: Session, draft: ExpenseClaim) -> ExpenseClaim: + application = ExpenseClaim( + id="application-bg-association", + claim_no="AP-20260220-001", + employee_id=draft.employee_id, + employee_name=draft.employee_name, + department_id=draft.department_id, + department_name=draft.department_name, + project_code=draft.project_code, + expense_type="travel_application", + reason=draft.reason, + location=draft.location, + amount=Decimal("3000.00"), + currency="CNY", + invoice_count=0, + occurred_at=draft.occurred_at, + submitted_at=draft.occurred_at, + status="approved", + approval_stage="已完成", + risk_flags_json=[], + ) + db.add(application) + db.flush() + case_service = ExpenseCaseService(db) + expense_case = case_service.ensure_case_for_claim( + application, + tenant_id="default", + relation_type="application", + ) + case_service.link_claim( + expense_case, + draft, + tenant_id="default", + relation_type="generated_reimbursement", + ) + db.commit() + return application + + +def seed_standalone_approved_application(db: Session) -> ExpenseClaim: + employee = Employee( + id="emp-application-only", + employee_no="E10001", + name="张三", + email="zhangsan@example.com", + position="实施顾问", + grade="P4", + ) + application = ExpenseClaim( + id="application-only-association", + claim_no="AP-20260220-ONLY", + employee_id=employee.id, + employee_name=employee.name, + department_id="dept-delivery", + department_name="交付部", + project_code=None, + expense_type="travel_application", + reason="辅助国网仿生产服务器部署,武汉往返上海", + location="上海", + amount=Decimal("3000.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 2, 20, tzinfo=UTC), + submitted_at=datetime(2026, 2, 18, tzinfo=UTC), + status="approved", + approval_stage="已完成", + risk_flags_json=[], + ) + db.add_all([employee, application]) + db.flush() + ExpenseCaseService(db).ensure_case_for_claim( + application, + tenant_id="default", + relation_type="application", + ) + db.commit() + return application + + +def seed_approved_application_with_flag_only( + db: Session, + draft: ExpenseClaim, +) -> tuple[ExpenseClaim, ExpenseCase]: + application = ExpenseClaim( + id="application-flag-only-association", + claim_no="AP-20260220-FLAG", + employee_id=draft.employee_id, + employee_name=draft.employee_name, + department_id=draft.department_id, + department_name=draft.department_name, + project_code=draft.project_code, + expense_type="travel_application", + reason=draft.reason, + location=draft.location, + amount=Decimal("3000.00"), + currency="CNY", + invoice_count=0, + occurred_at=draft.occurred_at, + submitted_at=draft.occurred_at, + status="approved", + approval_stage="已完成", + risk_flags_json=[], + ) + draft.risk_flags_json = [ + { + "source": "application_link", + "application_claim_id": application.id, + "application_claim_no": application.claim_no, + } + ] + db.add(application) + db.flush() + expense_case = ExpenseCaseService(db).ensure_case_for_claim( + application, + tenant_id="default", + relation_type="application", + ) + db.commit() + return application, expense_case + + +def seed_second_matching_draft(db: Session) -> ExpenseClaim: + claim = ExpenseClaim( + id="claim-bg-association-2", + claim_no="BX-20260220-002", + employee_id="emp-bg-association", + employee_name="张三", + department_id="dept-delivery", + department_name="交付部", + project_code=None, + expense_type="travel", + reason="辅助国网仿生产服务器部署,武汉往返上海", + location="上海", + amount=Decimal("0.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 2, 20, tzinfo=UTC), + submitted_at=None, + status="draft", + approval_stage="待提交", + risk_flags_json=[], + items=[ + ExpenseClaimItem( + id="item-bg-association-2", + item_date=date(2026, 2, 20), + item_type="train_ticket", + item_reason="武汉至上海高铁", + item_location="上海", + item_amount=Decimal("0.00"), + invoice_id=None, + ) + ], + ) + db.add(claim) + db.commit() + return claim + + def save_train_receipt( *, service: ReceiptFolderService, @@ -97,7 +271,7 @@ def save_train_receipt( ) -> str: receipt = service.save_receipt( filename=filename, - content=f"fake-pdf-{filename}".encode("utf-8"), + content=f"fake-pdf-{filename}".encode(), media_type="application/pdf", current_user=current_user, document=OcrRecognizeDocumentRead( @@ -160,12 +334,16 @@ def fake_ocr_recognize_without_preview( return fake_ocr_recognize(self, files) -def test_attachment_association_job_links_receipts_after_conversation_exit(monkeypatch, tmp_path) -> None: +def test_attachment_association_job_links_receipts_after_conversation_exit( + monkeypatch, tmp_path +) -> None: monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) get_settings.cache_clear() clear_attachment_association_jobs_for_tests() monkeypatch.setattr(OcrService, "recognize_files", fake_ocr_recognize) - monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments") + monkeypatch.setattr( + ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments" + ) try: client, session_factory = build_client(monkeypatch) current_user = CurrentUserContext( @@ -176,7 +354,8 @@ def test_attachment_association_job_links_receipts_after_conversation_exit(monke employee_no="E10001", ) with session_factory() as db: - seed_travel_claim(db) + draft = seed_travel_claim(db) + seed_approved_application_for_draft(db, draft) receipt_service = ReceiptFolderService() receipt_ids = [ @@ -225,6 +404,14 @@ def test_attachment_association_job_links_receipts_after_conversation_exit(monke assert payload["claim_id"] == "claim-bg-association" assert payload["claim_no"] == "BX-20260220-001" assert payload["uploaded_count"] == 2 + assert payload["resolution"] == "auto_associated" + assert payload["requires_confirmation"] is False + assert payload["expense_case_id"] + assert payload["application_claim_id"] == "application-bg-association" + assert payload["application_claim_no"] == "AP-20260220-001" + assert payload["confidence"] == "high" + assert payload["match_reasons"] + assert payload["draft_payload"]["claim_id"] == "claim-bg-association" with session_factory() as db: claim = db.scalar( @@ -235,8 +422,26 @@ def test_attachment_association_job_links_receipts_after_conversation_exit(monke assert claim is not None attached_items = [item for item in claim.items if item.invoice_id] assert len(attached_items) == 2 + receipt_links = list( + db.scalars( + select(ExpenseCaseLink).where(ExpenseCaseLink.resource_type == "receipt") + ).all() + ) + assert {link.resource_id for link in receipt_links} == set(receipt_ids) + receipt_events = list( + db.scalars( + select(BusinessEvent).where(BusinessEvent.aggregate_type == "receipt") + ).all() + ) + assert len(receipt_events) == 4 + assert {event.event_type for event in receipt_events} == { + "receipt_received", + "attachment_associated", + } - linked_receipts = receipt_service.list_receipts(current_user=current_user, status_filter="linked") + linked_receipts = receipt_service.list_receipts( + current_user=current_user, status_filter="linked" + ) assert {item.id for item in linked_receipts} == set(receipt_ids) assert {item.linked_claim_id for item in linked_receipts} == {"claim-bg-association"} finally: @@ -255,7 +460,9 @@ def test_attachment_association_keeps_receipt_folder_preview_and_fields_after_ca get_settings.cache_clear() clear_attachment_association_jobs_for_tests() monkeypatch.setattr(OcrService, "recognize_files", fake_ocr_recognize_without_preview) - monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments") + monkeypatch.setattr( + ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments" + ) try: client, session_factory = build_client(monkeypatch) current_user = CurrentUserContext( @@ -288,7 +495,9 @@ def test_attachment_association_keeps_receipt_folder_preview_and_fields_after_ca preview_kind="image", preview_data_url=preview_data_url, document_fields=[ - OcrRecognizeFieldRead(key="date", label="列车出发时间", value="2026-02-20 07:55"), + OcrRecognizeFieldRead( + key="date", label="列车出发时间", value="2026-02-20 07:55" + ), OcrRecognizeFieldRead(key="route", label="行程", value="武汉-上海"), OcrRecognizeFieldRead(key="amount", label="金额", value="354元"), ], @@ -339,15 +548,16 @@ def test_attachment_association_keeps_receipt_folder_preview_and_fields_after_ca assert metadata["document_info"]["document_type"] == "train_ticket" assert metadata["document_info"]["document_type_label"] == "火车/高铁票" assert { - (field["label"], field["value"]) - for field in metadata["document_info"]["fields"] + (field["label"], field["value"]) for field in metadata["document_info"]["fields"] } >= { ("列车出发时间", "2026-02-20 07:55"), ("行程", "武汉-上海"), ("金额", "354元"), } - preview_path, media_type, filename = ExpenseClaimService(db).get_claim_item_attachment_preview_content( + preview_path, media_type, filename = ExpenseClaimService( + db + ).get_claim_item_attachment_preview_content( claim_id=claim.id, item_id=attached_item.id, current_user=current_user, @@ -368,7 +578,9 @@ def test_attachment_meta_repairs_existing_pdf_fallback_from_source_receipt( preview_data_url = f"data:image/png;base64,{base64.b64encode(preview_bytes).decode('ascii')}" monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) get_settings.cache_clear() - monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments") + monkeypatch.setattr( + ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments" + ) try: current_user = CurrentUserContext( username="zhangsan@example.com", @@ -403,7 +615,9 @@ def test_attachment_meta_repairs_existing_pdf_fallback_from_source_receipt( preview_kind="image", preview_data_url=preview_data_url, document_fields=[ - OcrRecognizeFieldRead(key="date", label="列车出发时间", value="2026-02-20 07:55"), + OcrRecognizeFieldRead( + key="date", label="列车出发时间", value="2026-02-20 07:55" + ), OcrRecognizeFieldRead(key="route", label="行程", value="武汉-上海"), OcrRecognizeFieldRead(key="amount", label="金额", value="354元"), ], @@ -451,8 +665,7 @@ def test_attachment_meta_repairs_existing_pdf_fallback_from_source_receipt( assert metadata["document_info"]["document_type"] == "train_ticket" assert metadata["document_info"]["document_type_label"] == "火车/高铁票" assert { - (field["label"], field["value"]) - for field in metadata["document_info"]["fields"] + (field["label"], field["value"]) for field in metadata["document_info"]["fields"] } >= { ("列车出发时间", "2026-02-20 07:55"), ("行程", "武汉-上海"), @@ -471,7 +684,10 @@ def test_attachment_meta_repairs_existing_pdf_fallback_from_source_receipt( get_settings.cache_clear() -def test_attachment_association_job_fails_without_editable_claim(monkeypatch, tmp_path) -> None: +def test_attachment_association_job_requests_confirmation_without_editable_claim( + monkeypatch, + tmp_path, +) -> None: monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) get_settings.cache_clear() clear_attachment_association_jobs_for_tests() @@ -511,8 +727,1273 @@ def test_attachment_association_job_fails_without_editable_claim(monkeypatch, tm ) assert status_response.status_code == 200 payload = status_response.json() - assert payload["status"] == "failed" - assert "没有找到可自动关联的报销草稿" in payload["message"] + assert payload["status"] == "succeeded" + assert payload["resolution"] == "requires_confirmation" + assert payload["requires_confirmation"] is True + assert payload["uploaded_count"] == 0 + assert payload["error"] == "" + assert "没有找到" in payload["exceptions"][0] + finally: + clear_attachment_association_jobs_for_tests() + get_settings.cache_clear() + + +def test_confirmation_job_is_re_evaluated_after_draft_is_created( + monkeypatch, + tmp_path, +) -> None: + monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) + get_settings.cache_clear() + clear_attachment_association_jobs_for_tests() + monkeypatch.setattr(OcrService, "recognize_files", fake_ocr_recognize) + monkeypatch.setattr( + ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments" + ) + try: + client, session_factory = build_client(monkeypatch) + current_user = CurrentUserContext( + username="zhangsan@example.com", + name="张三", + role_codes=["user"], + is_admin=False, + employee_no="E10001", + ) + receipt_id = save_train_receipt( + service=ReceiptFolderService(), + current_user=current_user, + filename="2月20 武汉-上海.pdf", + route="武汉-上海", + trip_date="2026-02-20", + ) + headers = { + "x-auth-username": "zhangsan@example.com", + "x-auth-name": "Zhang San", + "x-auth-employee-no": "E10001", + "x-auth-role-codes": "user", + } + + first_job = client.post( + "/api/v1/reimbursements/attachment-association-jobs", + headers=headers, + json={"receipt_ids": [receipt_id]}, + ).json() + first_result = client.get( + f"/api/v1/reimbursements/attachment-association-jobs/{first_job['job_id']}", + headers=headers, + ).json() + assert first_result["resolution"] == "requires_confirmation" + + with session_factory() as db: + seed_travel_claim(db) + + second_job = client.post( + "/api/v1/reimbursements/attachment-association-jobs", + headers=headers, + json={"receipt_ids": [receipt_id]}, + ).json() + second_result = client.get( + f"/api/v1/reimbursements/attachment-association-jobs/{second_job['job_id']}", + headers=headers, + ).json() + third_job = client.post( + "/api/v1/reimbursements/attachment-association-jobs", + headers=headers, + json={"receipt_ids": [receipt_id]}, + ).json() + + assert second_job["job_id"] != first_job["job_id"] + assert third_job["job_id"] == second_job["job_id"] + assert second_result["status"] == "succeeded" + assert second_result["resolution"] == "auto_associated" + assert second_result["uploaded_count"] == 1 + with session_factory() as db: + jobs = list( + db.scalars( + select(AttachmentAssociationJob).order_by( + AttachmentAssociationJob.generation + ) + ).all() + ) + assert [(job.generation, job.resolution) for job in jobs] == [ + (1, "requires_confirmation"), + (2, "auto_associated"), + ] + finally: + clear_attachment_association_jobs_for_tests() + get_settings.cache_clear() + + +def test_attachment_association_job_returns_application_candidate_without_creating_draft( + monkeypatch, + tmp_path, +) -> None: + monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) + get_settings.cache_clear() + clear_attachment_association_jobs_for_tests() + try: + client, session_factory = build_client(monkeypatch) + current_user = CurrentUserContext( + username="zhangsan@example.com", + name="张三", + role_codes=["user"], + is_admin=False, + employee_no="E10001", + ) + with session_factory() as db: + application = seed_standalone_approved_application(db) + application_id = application.id + receipt_id = save_train_receipt( + service=ReceiptFolderService(), + current_user=current_user, + filename="2月20 武汉-上海.pdf", + route="武汉-上海", + trip_date="2026-02-20", + ) + headers = { + "x-auth-username": "zhangsan@example.com", + "x-auth-name": "Zhang San", + "x-auth-employee-no": "E10001", + "x-auth-role-codes": "user", + } + created = client.post( + "/api/v1/reimbursements/attachment-association-jobs", + headers=headers, + json={"receipt_ids": [receipt_id]}, + ).json() + result = client.get( + f"/api/v1/reimbursements/attachment-association-jobs/{created['job_id']}", + headers=headers, + ).json() + + assert result["status"] == "succeeded" + assert result["resolution"] == "requires_confirmation" + assert result["requires_confirmation"] is True + assert result["claim_id"] == "" + assert result["uploaded_count"] == 0 + assert result["candidates"][0]["target_type"] == "approved_application" + assert result["candidates"][0]["application_claim_id"] == application_id + with session_factory() as db: + claims = list(db.scalars(select(ExpenseClaim)).all()) + assert [claim.id for claim in claims] == [application_id] + finally: + clear_attachment_association_jobs_for_tests() + get_settings.cache_clear() + + +def test_attachment_association_job_is_idempotent_for_same_receipt(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) + get_settings.cache_clear() + clear_attachment_association_jobs_for_tests() + monkeypatch.setattr(OcrService, "recognize_files", fake_ocr_recognize) + monkeypatch.setattr( + ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments" + ) + try: + client, session_factory = build_client(monkeypatch) + current_user = CurrentUserContext( + username="zhangsan@example.com", + name="张三", + role_codes=["user"], + is_admin=False, + employee_no="E10001", + ) + with session_factory() as db: + seed_travel_claim(db) + receipt_id = save_train_receipt( + service=ReceiptFolderService(), + current_user=current_user, + filename="2月20 武汉-上海.pdf", + route="武汉-上海", + trip_date="2026-02-20", + ) + headers = { + "x-auth-username": "zhangsan@example.com", + "x-auth-name": "Zhang San", + "x-auth-employee-no": "E10001", + "x-auth-role-codes": "user", + } + + first_job = client.post( + "/api/v1/reimbursements/attachment-association-jobs", + headers=headers, + json={"receipt_ids": [receipt_id]}, + ).json() + first_result = client.get( + f"/api/v1/reimbursements/attachment-association-jobs/{first_job['job_id']}", + headers=headers, + ).json() + second_job = client.post( + "/api/v1/reimbursements/attachment-association-jobs", + headers=headers, + json={"receipt_ids": [receipt_id]}, + ).json() + second_result = client.get( + f"/api/v1/reimbursements/attachment-association-jobs/{second_job['job_id']}", + headers=headers, + ).json() + + assert second_job["job_id"] == first_job["job_id"] + assert first_result["uploaded_count"] == 1 + assert second_result["error"] == "", second_result["error"] + assert second_result["resolution"] == "auto_associated", second_result + assert second_result["uploaded_count"] == 1 + assert second_result["skipped_count"] == 0 + with session_factory() as db: + claim = db.scalar( + select(ExpenseClaim) + .options(selectinload(ExpenseClaim.items)) + .where(ExpenseClaim.id == "claim-bg-association") + ) + assert claim is not None + assert len([item for item in claim.items if item.invoice_id]) == 1 + events = list( + db.scalars( + select(BusinessEvent).where(BusinessEvent.aggregate_type == "receipt") + ).all() + ) + assert len(events) == 2 + finally: + clear_attachment_association_jobs_for_tests() + get_settings.cache_clear() + + +def test_attachment_association_ambiguous_match_has_no_business_writes( + monkeypatch, tmp_path +) -> None: + monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) + get_settings.cache_clear() + clear_attachment_association_jobs_for_tests() + try: + client, session_factory = build_client(monkeypatch) + current_user = CurrentUserContext( + username="zhangsan@example.com", + name="张三", + role_codes=["user"], + is_admin=False, + employee_no="E10001", + ) + with session_factory() as db: + seed_travel_claim(db) + seed_second_matching_draft(db) + receipt_id = save_train_receipt( + service=ReceiptFolderService(), + current_user=current_user, + filename="2月20 武汉-上海.pdf", + route="武汉-上海", + trip_date="2026-02-20", + ) + headers = { + "x-auth-username": "zhangsan@example.com", + "x-auth-name": "Zhang San", + "x-auth-employee-no": "E10001", + "x-auth-role-codes": "user", + } + created = client.post( + "/api/v1/reimbursements/attachment-association-jobs", + headers=headers, + json={"receipt_ids": [receipt_id]}, + ).json() + result = client.get( + f"/api/v1/reimbursements/attachment-association-jobs/{created['job_id']}", + headers=headers, + ).json() + + assert result["status"] == "succeeded" + assert result["resolution"] == "requires_confirmation" + assert result["requires_confirmation"] is True + assert len(result["candidates"]) == 2 + assert result["uploaded_count"] == 0 + receipt = ReceiptFolderService().get_receipt(receipt_id, current_user) + assert receipt.status == "unlinked" + with session_factory() as db: + assert ( + db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_type == "receipt")) + is None + ) + assert ( + db.scalar(select(ExpenseCaseLink).where(ExpenseCaseLink.resource_type == "receipt")) + is None + ) + claims = list( + db.scalars(select(ExpenseClaim).options(selectinload(ExpenseClaim.items))).all() + ) + assert all(not item.invoice_id for claim in claims for item in claim.items) + finally: + clear_attachment_association_jobs_for_tests() + get_settings.cache_clear() + + +def test_attachment_association_rolls_back_when_event_write_fails(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) + get_settings.cache_clear() + clear_attachment_association_jobs_for_tests() + monkeypatch.setattr(OcrService, "recognize_files", fake_ocr_recognize) + monkeypatch.setattr( + ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments" + ) + original_record = ExpenseCaseService.record_resource_event + + def fail_association_event(self, expense_case, **kwargs): + if kwargs.get("event_type") == "attachment_associated": + raise RuntimeError("simulated business event failure") + return original_record(self, expense_case, **kwargs) + + monkeypatch.setattr(ExpenseCaseService, "record_resource_event", fail_association_event) + try: + client, session_factory = build_client(monkeypatch) + current_user = CurrentUserContext( + username="zhangsan@example.com", + name="张三", + role_codes=["user"], + is_admin=False, + employee_no="E10001", + ) + with session_factory() as db: + seed_travel_claim(db) + receipt_id = save_train_receipt( + service=ReceiptFolderService(), + current_user=current_user, + filename="2月20 武汉-上海.pdf", + route="武汉-上海", + trip_date="2026-02-20", + ) + headers = { + "x-auth-username": "zhangsan@example.com", + "x-auth-name": "Zhang San", + "x-auth-employee-no": "E10001", + "x-auth-role-codes": "user", + } + created = client.post( + "/api/v1/reimbursements/attachment-association-jobs", + headers=headers, + json={"receipt_ids": [receipt_id]}, + ).json() + result = client.get( + f"/api/v1/reimbursements/attachment-association-jobs/{created['job_id']}", + headers=headers, + ).json() + + assert result["status"] == "failed" + assert "simulated business event failure" in result["error"] + receipt = ReceiptFolderService().get_receipt(receipt_id, current_user) + assert receipt.status == "unlinked" + with session_factory() as db: + claim = db.scalar( + select(ExpenseClaim) + .options(selectinload(ExpenseClaim.items)) + .where(ExpenseClaim.id == "claim-bg-association") + ) + assert claim is not None + assert all(not item.invoice_id for item in claim.items) + assert ( + db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_type == "receipt")) + is None + ) + finally: + clear_attachment_association_jobs_for_tests() + get_settings.cache_clear() + + +def test_attachment_association_failure_keeps_preexisting_application_case( + monkeypatch, + tmp_path, +) -> None: + monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) + get_settings.cache_clear() + clear_attachment_association_jobs_for_tests() + monkeypatch.setattr(OcrService, "recognize_files", fake_ocr_recognize) + monkeypatch.setattr( + ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments" + ) + original_record = ExpenseCaseService.record_resource_event + + def fail_association_event(self, expense_case, **kwargs): + if kwargs.get("event_type") == "attachment_associated": + raise RuntimeError("simulated application case event failure") + return original_record(self, expense_case, **kwargs) + + monkeypatch.setattr(ExpenseCaseService, "record_resource_event", fail_association_event) + try: + client, session_factory = build_client(monkeypatch) + current_user = CurrentUserContext( + username="zhangsan@example.com", + name="张三", + role_codes=["user"], + is_admin=False, + employee_no="E10001", + ) + with session_factory() as db: + draft = seed_travel_claim(db) + application, expense_case = seed_approved_application_with_flag_only(db, draft) + application_id = application.id + expense_case_id = expense_case.id + receipt_id = save_train_receipt( + service=ReceiptFolderService(), + current_user=current_user, + filename="2月20 武汉-上海.pdf", + route="武汉-上海", + trip_date="2026-02-20", + ) + headers = { + "x-auth-username": "zhangsan@example.com", + "x-auth-name": "Zhang San", + "x-auth-employee-no": "E10001", + "x-auth-role-codes": "user", + } + created = client.post( + "/api/v1/reimbursements/attachment-association-jobs", + headers=headers, + json={"receipt_ids": [receipt_id]}, + ).json() + result = client.get( + f"/api/v1/reimbursements/attachment-association-jobs/{created['job_id']}", + headers=headers, + ).json() + + assert result["status"] == "failed" + with session_factory() as db: + assert db.get(ExpenseCase, expense_case_id) is not None + application_link = db.scalar( + select(ExpenseCaseLink).where( + ExpenseCaseLink.resource_type == "expense_claim", + ExpenseCaseLink.resource_id == application_id, + ) + ) + assert application_link is not None + assert application_link.expense_case_id == expense_case_id + assert ( + db.scalar( + select(ExpenseCaseLink).where( + ExpenseCaseLink.resource_type == "expense_claim", + ExpenseCaseLink.resource_id == "claim-bg-association", + ) + ) + is None + ) + finally: + clear_attachment_association_jobs_for_tests() + get_settings.cache_clear() + + +def test_non_default_tenant_does_not_match_unscoped_legacy_claim( + monkeypatch, + tmp_path, +) -> None: + monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) + get_settings.cache_clear() + clear_attachment_association_jobs_for_tests() + try: + client, session_factory = build_client(monkeypatch) + with session_factory() as db: + seed_travel_claim(db) + tenant_user = CurrentUserContext( + username="zhangsan@example.com", + name="张三", + role_codes=["user"], + is_admin=False, + tenant_id="tenant-b", + employee_no="E10001", + ) + receipt_id = save_train_receipt( + service=ReceiptFolderService(), + current_user=tenant_user, + filename="tenant-b-ticket.pdf", + route="武汉-上海", + trip_date="2026-02-20", + ) + headers = { + "x-auth-username": "zhangsan@example.com", + "x-auth-name": "Zhang San", + "x-auth-tenant-id": "tenant-b", + "x-auth-employee-no": "E10001", + "x-auth-role-codes": "user", + } + created = client.post( + "/api/v1/reimbursements/attachment-association-jobs", + headers=headers, + json={"receipt_ids": [receipt_id]}, + ).json() + result = client.get( + f"/api/v1/reimbursements/attachment-association-jobs/{created['job_id']}", + headers=headers, + ).json() + + assert result["status"] == "succeeded" + assert result["resolution"] == "requires_confirmation" + assert result["candidates"] == [] + assert result["uploaded_count"] == 0 + with session_factory() as db: + claim = db.scalar( + select(ExpenseClaim) + .options(selectinload(ExpenseClaim.items)) + .where(ExpenseClaim.id == "claim-bg-association") + ) + assert claim is not None + assert all(not item.invoice_id for item in claim.items) + finally: + clear_attachment_association_jobs_for_tests() + get_settings.cache_clear() + + +def test_confirmation_match_does_not_repair_or_commit_unrelated_claim( + monkeypatch, + tmp_path, +) -> None: + monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) + get_settings.cache_clear() + clear_attachment_association_jobs_for_tests() + try: + client, session_factory = build_client(monkeypatch) + with session_factory() as db: + employee = Employee( + id="emp-readonly-confirmation", + employee_no="E10001", + name="张三", + email="zhangsan@example.com", + ) + claim = ExpenseClaim( + id="claim-readonly-confirmation", + claim_no="BX-20260101-READONLY", + employee_id=employee.id, + employee_name=employee.name, + department_name="交付部", + expense_type="travel", + reason="北京客户拜访", + location="北京", + amount=Decimal("100.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 1, 1, tzinfo=UTC), + status="submitted", + approval_stage="预算管理者审批", + risk_flags_json=[ + { + "source": "manual_approval", + "event_type": "expense_claim_approval", + "previous_approval_stage": "直属领导审批", + "next_approval_stage": "预算管理者审批", + "operator": "同一审批人", + "next_approver_name": "同一审批人", + } + ], + ) + db.add_all([employee, claim]) + db.commit() + current_user = CurrentUserContext( + username="zhangsan@example.com", + name="张三", + role_codes=["user"], + is_admin=False, + employee_no="E10001", + ) + receipt_id = save_train_receipt( + service=ReceiptFolderService(), + current_user=current_user, + filename="4月20 武汉-上海.pdf", + route="武汉-上海", + trip_date="2026-04-20", + ) + headers = { + "x-auth-username": "zhangsan@example.com", + "x-auth-name": "Zhang San", + "x-auth-employee-no": "E10001", + "x-auth-role-codes": "user", + } + created = client.post( + "/api/v1/reimbursements/attachment-association-jobs", + headers=headers, + json={"receipt_ids": [receipt_id]}, + ).json() + result = client.get( + f"/api/v1/reimbursements/attachment-association-jobs/{created['job_id']}", + headers=headers, + ).json() + + assert result["resolution"] == "requires_confirmation" + with session_factory() as db: + claim = db.get(ExpenseClaim, "claim-readonly-confirmation") + assert claim is not None + assert claim.approval_stage == "预算管理者审批" + assert len(claim.risk_flags_json) == 1 + assert claim.risk_flags_json[0]["source"] == "manual_approval" + finally: + clear_attachment_association_jobs_for_tests() + get_settings.cache_clear() + + +def test_mixed_receipt_batch_requires_confirmation_without_writes( + monkeypatch, + tmp_path, +) -> None: + monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) + get_settings.cache_clear() + clear_attachment_association_jobs_for_tests() + try: + client, session_factory = build_client(monkeypatch) + current_user = CurrentUserContext( + username="zhangsan@example.com", + name="张三", + role_codes=["user"], + is_admin=False, + employee_no="E10001", + ) + with session_factory() as db: + draft = seed_travel_claim(db) + seed_approved_application_for_draft(db, draft) + matching_receipt_id = save_train_receipt( + service=ReceiptFolderService(), + current_user=current_user, + filename="2月20 武汉-上海.pdf", + route="武汉-上海", + trip_date="2026-02-20", + ) + unrelated_receipt_id = save_train_receipt( + service=ReceiptFolderService(), + current_user=current_user, + filename="5月12 北京-广州.pdf", + route="北京-广州", + trip_date="2026-05-12", + ) + headers = { + "x-auth-username": "zhangsan@example.com", + "x-auth-name": "Zhang San", + "x-auth-employee-no": "E10001", + "x-auth-role-codes": "user", + } + created = client.post( + "/api/v1/reimbursements/attachment-association-jobs", + headers=headers, + json={"receipt_ids": [matching_receipt_id, unrelated_receipt_id]}, + ).json() + result = client.get( + f"/api/v1/reimbursements/attachment-association-jobs/{created['job_id']}", + headers=headers, + ).json() + + assert result["resolution"] == "requires_confirmation" + assert "每一份" in result["exceptions"][0] + assert result["uploaded_count"] == 0 + assert all( + ReceiptFolderService().get_receipt(receipt_id, current_user).status == "unlinked" + for receipt_id in (matching_receipt_id, unrelated_receipt_id) + ) + with session_factory() as db: + assert ( + db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_type == "receipt")) + is None + ) + finally: + clear_attachment_association_jobs_for_tests() + get_settings.cache_clear() + + +def test_attachment_write_failure_restores_previous_directory_and_business_state( + monkeypatch, + tmp_path, +) -> None: + monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) + get_settings.cache_clear() + clear_attachment_association_jobs_for_tests() + monkeypatch.setattr(OcrService, "recognize_files", fake_ocr_recognize) + monkeypatch.setattr( + ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments" + ) + original_write_meta = ExpenseClaimAttachmentStorage.write_meta + + def write_meta_then_fail(self, file_path, payload): + original_write_meta(self, file_path, payload) + raise OSError("simulated attachment metadata failure") + + monkeypatch.setattr(ExpenseClaimAttachmentStorage, "write_meta", write_meta_then_fail) + try: + client, session_factory = build_client(monkeypatch) + current_user = CurrentUserContext( + username="zhangsan@example.com", + name="张三", + role_codes=["user"], + is_admin=False, + employee_no="E10001", + ) + with session_factory() as db: + draft = seed_travel_claim(db) + item_id = draft.items[0].id + item_dir = ExpenseClaimAttachmentStorage().build_item_dir( + "claim-bg-association", + item_id, + ) + item_dir.mkdir(parents=True, exist_ok=True) + sentinel = item_dir / "existing-state.txt" + sentinel.write_text("keep-me", encoding="utf-8") + receipt_id = save_train_receipt( + service=ReceiptFolderService(), + current_user=current_user, + filename="2月20 武汉-上海.pdf", + route="武汉-上海", + trip_date="2026-02-20", + ) + headers = { + "x-auth-username": "zhangsan@example.com", + "x-auth-name": "Zhang San", + "x-auth-employee-no": "E10001", + "x-auth-role-codes": "user", + } + created = client.post( + "/api/v1/reimbursements/attachment-association-jobs", + headers=headers, + json={"receipt_ids": [receipt_id]}, + ).json() + result = client.get( + f"/api/v1/reimbursements/attachment-association-jobs/{created['job_id']}", + headers=headers, + ).json() + + assert result["status"] == "failed" + assert "simulated attachment metadata failure" in result["error"] + assert sentinel.read_text(encoding="utf-8") == "keep-me" + assert [path.name for path in item_dir.iterdir()] == ["existing-state.txt"] + assert ReceiptFolderService().get_receipt(receipt_id, current_user).status == "unlinked" + with session_factory() as db: + claim = db.scalar( + select(ExpenseClaim) + .options(selectinload(ExpenseClaim.items)) + .where(ExpenseClaim.id == "claim-bg-association") + ) + assert claim is not None + assert all(not item.invoice_id for item in claim.items) + assert ( + db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_type == "receipt")) + is None + ) + finally: + clear_attachment_association_jobs_for_tests() + get_settings.cache_clear() + + +def test_concurrent_jobs_for_same_receipt_are_serialized(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) + get_settings.cache_clear() + clear_attachment_association_jobs_for_tests() + monkeypatch.setattr(OcrService, "recognize_files", fake_ocr_recognize) + monkeypatch.setattr( + ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments" + ) + try: + _client, session_factory = build_client(monkeypatch) + current_user = CurrentUserContext( + username="zhangsan@example.com", + name="张三", + role_codes=["user"], + is_admin=False, + employee_no="E10001", + ) + with session_factory() as db: + seed_travel_claim(db) + receipt_id = save_train_receipt( + service=ReceiptFolderService(), + current_user=current_user, + filename="2月20 武汉-上海.pdf", + route="武汉-上海", + trip_date="2026-02-20", + ) + with session_factory() as db: + job_a = create_attachment_association_job( + AttachmentAssociationJobCreate(receipt_ids=[receipt_id]), + current_user, + db, + ) + job_b = create_attachment_association_job( + AttachmentAssociationJobCreate(receipt_ids=[receipt_id]), + current_user, + db, + ) + assert job_a.job_id == job_b.job_id + original_associate = ExpenseReceiptAssociationService.associate + active_lock = Lock() + active_count = 0 + max_active_count = 0 + + def tracked_associate(self, **kwargs): + nonlocal active_count, max_active_count + with active_lock: + active_count += 1 + max_active_count = max(max_active_count, active_count) + sleep(0.05) + try: + return original_associate(self, **kwargs) + finally: + with active_lock: + active_count -= 1 + + monkeypatch.setattr(ExpenseReceiptAssociationService, "associate", tracked_associate) + threads = [ + Thread( + target=run_attachment_association_job, + args=(job_id, session_factory), + ) + for job_id in (job_a.job_id, job_b.job_id) + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=5) + assert not thread.is_alive() + + with session_factory() as db: + result = get_attachment_association_job(job_a.job_id, current_user, db) + assert max_active_count == 1 + assert result is not None and result.status == "succeeded" + assert result.uploaded_count == 1 + assert result.skipped_count == 0 + assert ReceiptFolderService().get_receipt(receipt_id, current_user).status == "linked" + with session_factory() as db: + claim = db.scalar( + select(ExpenseClaim) + .options(selectinload(ExpenseClaim.items)) + .where(ExpenseClaim.id == "claim-bg-association") + ) + assert claim is not None + attached_items = [item for item in claim.items if item.invoice_id] + assert len(attached_items) == 1 + attachment_path = ExpenseClaimAttachmentStorage().resolve_item_path(attached_items[0]) + assert attachment_path is not None and attachment_path.exists() + events = list( + db.scalars( + select(BusinessEvent).where(BusinessEvent.aggregate_type == "receipt") + ).all() + ) + assert len(events) == 2 + finally: + clear_attachment_association_jobs_for_tests() + get_settings.cache_clear() + + +def test_concurrent_receipts_for_same_claim_are_serialized(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) + get_settings.cache_clear() + clear_attachment_association_jobs_for_tests() + monkeypatch.setattr(OcrService, "recognize_files", fake_ocr_recognize) + monkeypatch.setattr( + ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments" + ) + try: + _client, session_factory = build_client(monkeypatch) + current_user = CurrentUserContext( + username="zhangsan@example.com", + name="张三", + role_codes=["user"], + is_admin=False, + employee_no="E10001", + ) + with session_factory() as db: + seed_travel_claim(db) + receipt_ids = [ + save_train_receipt( + service=ReceiptFolderService(), + current_user=current_user, + filename=filename, + route="武汉-上海", + trip_date="2026-02-20", + ) + for filename in ("并发票据-A.pdf", "并发票据-B.pdf") + ] + with session_factory() as db: + jobs = [ + create_attachment_association_job( + AttachmentAssociationJobCreate(receipt_ids=[receipt_id]), + current_user, + db, + ) + for receipt_id in receipt_ids + ] + assert jobs[0].job_id != jobs[1].job_id + + original_associate_matched = ExpenseReceiptAssociationService._associate_matched + active_lock = Lock() + active_count = 0 + max_active_count = 0 + + def tracked_associate_matched(self, **kwargs): + nonlocal active_count, max_active_count + with active_lock: + active_count += 1 + max_active_count = max(max_active_count, active_count) + sleep(0.05) + try: + return original_associate_matched(self, **kwargs) + finally: + with active_lock: + active_count -= 1 + + monkeypatch.setattr( + ExpenseReceiptAssociationService, + "_associate_matched", + tracked_associate_matched, + ) + threads = [ + Thread( + target=run_attachment_association_job, + args=(job.job_id, session_factory), + ) + for job in jobs + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=5) + assert not thread.is_alive() + + with session_factory() as db: + results = [ + get_attachment_association_job(job.job_id, current_user, db) for job in jobs + ] + claim = db.scalar( + select(ExpenseClaim) + .options(selectinload(ExpenseClaim.items)) + .where(ExpenseClaim.id == "claim-bg-association") + ) + events = list( + db.scalars( + select(BusinessEvent).where(BusinessEvent.aggregate_type == "receipt") + ).all() + ) + receipt_links = list( + db.scalars( + select(ExpenseCaseLink).where( + ExpenseCaseLink.resource_type == "receipt" + ) + ).all() + ) + + assert max_active_count == 1 + assert all(result is not None and result.status == "succeeded" for result in results) + assert all(result.resolution == "auto_associated" for result in results if result) + assert all(result.uploaded_count == 1 for result in results if result) + assert claim is not None + attached_items = [item for item in claim.items if item.invoice_id] + assert len(attached_items) == 2 + assert claim.invoice_count == 2 + assert len({item.invoice_id for item in attached_items}) == 2 + assert all( + (path := ExpenseClaimAttachmentStorage().resolve_item_path(item)) is not None + and path.exists() + for item in attached_items + ) + assert len(events) == 4 + assert len(receipt_links) == 2 + assert { + (event.aggregate_id, event.event_type) for event in events + } == { + (receipt_id, event_type) + for receipt_id in receipt_ids + for event_type in ("receipt_received", "attachment_associated") + } + linked_receipts = [ + ReceiptFolderService().get_receipt(receipt_id, current_user) + for receipt_id in receipt_ids + ] + assert all(receipt.status == "linked" for receipt in linked_receipts) + assert all( + receipt.linked_claim_id == "claim-bg-association" for receipt in linked_receipts + ) + assert len( + { + str((receipt.raw_meta or {}).get("linked_item_id") or "") + for receipt in linked_receipts + } + ) == 2 + finally: + clear_attachment_association_jobs_for_tests() + get_settings.cache_clear() + + +def test_persistent_job_resumes_after_process_state_is_cleared(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) + get_settings.cache_clear() + clear_attachment_association_jobs_for_tests() + monkeypatch.setattr(OcrService, "recognize_files", fake_ocr_recognize) + monkeypatch.setattr( + ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments" + ) + try: + client, session_factory = build_client(monkeypatch) + current_user = CurrentUserContext( + username="zhangsan@example.com", + name="张三", + role_codes=["user"], + is_admin=False, + employee_no="E10001", + ) + with session_factory() as db: + seed_travel_claim(db) + receipt_id = save_train_receipt( + service=ReceiptFolderService(), + current_user=current_user, + filename="2月20 武汉-上海.pdf", + route="武汉-上海", + trip_date="2026-02-20", + ) + with session_factory() as db: + queued_job = create_attachment_association_job( + AttachmentAssociationJobCreate(receipt_ids=[receipt_id]), + current_user, + db, + ) + persisted_job = db.get(AttachmentAssociationJob, queued_job.job_id) + assert persisted_job is not None + persisted_job.status = "running" + persisted_job.attempt_count = 1 + persisted_job.lease_expires_at = datetime.now(UTC) - timedelta(seconds=1) + db.commit() + clear_attachment_association_jobs_for_tests() + headers = { + "x-auth-username": "zhangsan@example.com", + "x-auth-name": "Zhang San", + "x-auth-employee-no": "E10001", + "x-auth-role-codes": "user", + } + + first_response = client.get( + f"/api/v1/reimbursements/attachment-association-jobs/{queued_job.job_id}", + headers=headers, + ) + assert first_response.status_code == 200 + assert first_response.json()["status"] == "running" + second_response = client.get( + f"/api/v1/reimbursements/attachment-association-jobs/{queued_job.job_id}", + headers=headers, + ) + assert second_response.status_code == 200 + assert second_response.json()["status"] == "succeeded" + assert second_response.json()["uploaded_count"] == 1 + finally: + clear_attachment_association_jobs_for_tests() + get_settings.cache_clear() + + +def test_expired_worker_cannot_overwrite_newer_job_attempt(monkeypatch) -> None: + clear_attachment_association_jobs_for_tests() + try: + _client, session_factory = build_client(monkeypatch) + current_user = CurrentUserContext( + username="zhangsan@example.com", + name="张三", + role_codes=["user"], + is_admin=False, + employee_no="E10001", + ) + with session_factory() as db: + created = create_attachment_association_job( + AttachmentAssociationJobCreate(receipt_ids=["lease-fencing-receipt"]), + current_user, + db, + ) + first_attempt = claim_persistent_job(db, created.job_id) + assert first_attempt is not None and first_attempt.attempt_count == 1 + job = db.get(AttachmentAssociationJob, created.job_id) + assert job is not None + job.lease_expires_at = datetime.now(UTC) - timedelta(seconds=1) + db.commit() + + with session_factory() as db: + second_attempt = claim_persistent_job(db, created.job_id) + assert second_attempt is not None and second_attempt.attempt_count == 2 + + with session_factory() as db: + update_persistent_job( + db, + created.job_id, + expected_attempt_count=1, + status="failed", + resolution="failed", + error="stale worker must not win", + ) + current = db.get(AttachmentAssociationJob, created.job_id) + assert current is not None + assert current.status == "running" + assert current.attempt_count == 2 + assert current.error == "" + assert current.lease_expires_at is not None + + with session_factory() as db: + update_persistent_job( + db, + created.job_id, + expected_attempt_count=2, + status="succeeded", + resolution="auto_associated", + message="new worker succeeded", + error="", + ) + current = db.get(AttachmentAssociationJob, created.job_id) + assert current is not None + assert current.status == "succeeded" + assert current.attempt_count == 2 + assert current.lease_expires_at is None + assert current.message == "new worker succeeded" + + with session_factory() as db: + update_persistent_job( + db, + created.job_id, + expected_attempt_count=2, + status="failed", + resolution="failed", + message="late callback", + error="late callback", + ) + current = db.get(AttachmentAssociationJob, created.job_id) + assert current is not None + assert current.status == "succeeded" + assert current.resolution == "auto_associated" + assert current.message == "new worker succeeded" + assert current.error == "" + finally: + clear_attachment_association_jobs_for_tests() + + +def test_failed_job_creates_new_generation_without_rewriting_history(monkeypatch) -> None: + clear_attachment_association_jobs_for_tests() + try: + _client, session_factory = build_client(monkeypatch) + current_user = CurrentUserContext( + username="zhangsan@example.com", + name="张三", + role_codes=["user"], + is_admin=False, + employee_no="E10001", + ) + payload = AttachmentAssociationJobCreate(receipt_ids=["failed-generation-receipt"]) + with session_factory() as db: + first = create_attachment_association_job(payload, current_user, db) + claimed = claim_persistent_job(db, first.job_id) + assert claimed is not None + update_persistent_job( + db, + first.job_id, + expected_attempt_count=claimed.attempt_count, + status="failed", + resolution="failed", + message="generation one failed", + error="generation one failed", + ) + + with session_factory() as db: + second = create_attachment_association_job(payload, current_user, db) + jobs = list( + db.scalars( + select(AttachmentAssociationJob).order_by( + AttachmentAssociationJob.generation + ) + ).all() + ) + + assert second.job_id != first.job_id + assert second.status == "queued" + assert [(job.generation, job.status) for job in jobs] == [ + (1, "failed"), + (2, "queued"), + ] + assert jobs[0].message == "generation one failed" + assert jobs[0].error == "generation one failed" + finally: + clear_attachment_association_jobs_for_tests() + + +def test_attachment_association_job_and_receipts_are_tenant_scoped(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) + get_settings.cache_clear() + clear_attachment_association_jobs_for_tests() + try: + client, _session_factory = build_client(monkeypatch) + tenant_a_user = CurrentUserContext( + username="shared@example.com", + name="同名用户", + role_codes=["user"], + is_admin=False, + tenant_id="tenant-a", + employee_no="EA001", + ) + receipt_id = save_train_receipt( + service=ReceiptFolderService(), + current_user=tenant_a_user, + filename="tenant-a-ticket.pdf", + route="武汉-上海", + trip_date="2026-02-20", + ) + tenant_a_headers = { + "x-auth-username": "shared@example.com", + "x-auth-name": "Same User", + "x-auth-tenant-id": "tenant-a", + "x-auth-employee-no": "EA001", + "x-auth-role-codes": "user", + } + tenant_b_headers = {**tenant_a_headers, "x-auth-tenant-id": "tenant-b"} + created = client.post( + "/api/v1/reimbursements/attachment-association-jobs", + headers=tenant_a_headers, + json={"receipt_ids": [receipt_id]}, + ) + assert created.status_code == 202 + job_id = created.json()["job_id"] + assert ( + client.get( + f"/api/v1/reimbursements/attachment-association-jobs/{job_id}", + headers=tenant_b_headers, + ).status_code + == 404 + ) + assert ( + client.get( + f"/api/v1/reimbursements/attachment-association-jobs/{job_id}", + headers={**tenant_b_headers, "x-auth-is-admin": "true"}, + ).status_code + == 404 + ) + + tenant_b_user = CurrentUserContext( + username="shared@example.com", + name="同名用户", + role_codes=["user"], + is_admin=False, + tenant_id="tenant-b", + employee_no="EA001", + ) + try: + ReceiptFolderService().get_receipt(receipt_id, tenant_b_user) + except FileNotFoundError: + pass + else: + raise AssertionError("跨租户不应读取同用户名票据") + + collision_a_user = CurrentUserContext( + username="collision@example.com", + name="命名空间碰撞用户", + role_codes=["user"], + is_admin=False, + tenant_id="tenant/a", + employee_no="EA002", + ) + collision_receipt_id = save_train_receipt( + service=ReceiptFolderService(), + current_user=collision_a_user, + filename="collision-ticket.pdf", + route="武汉-上海", + trip_date="2026-02-20", + ) + collision_b_user = CurrentUserContext( + username="collision@example.com", + name="命名空间碰撞用户", + role_codes=["user"], + is_admin=False, + tenant_id="tenant?a", + employee_no="EA002", + ) + try: + ReceiptFolderService().get_receipt(collision_receipt_id, collision_b_user) + except FileNotFoundError: + pass + else: + raise AssertionError("规范化后同名的不同租户也不应共享票据目录") finally: clear_attachment_association_jobs_for_tests() get_settings.cache_clear() diff --git a/server/tests/test_migration_preflight.py b/server/tests/test_migration_preflight.py index 57df395..6278d73 100644 --- a/server/tests/test_migration_preflight.py +++ b/server/tests/test_migration_preflight.py @@ -48,7 +48,7 @@ def test_unversioned_database_without_migration_owned_tables_is_safe(engine: Eng @pytest.mark.parametrize( "owned_table", - sorted(MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0005"]), + sorted(MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0006"]), ) def test_unversioned_database_with_any_migration_owned_table_is_rejected( engine: Engine, @@ -100,6 +100,11 @@ def test_known_revision_requires_and_accepts_its_exact_owned_table_set( "20260714_0005", MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0005"] - {"memory_entries"}, ), + ( + "20260716_0006", + MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0006"] + - {"attachment_association_jobs"}, + ), ], ) def test_known_revision_with_missing_or_unexpected_owned_tables_is_rejected( diff --git a/server/tests/test_schema_ownership.py b/server/tests/test_schema_ownership.py index 5be8ec7..f96b53e 100644 --- a/server/tests/test_schema_ownership.py +++ b/server/tests/test_schema_ownership.py @@ -20,6 +20,7 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None: "ai_decision_feedback", "ai_decisions", "auth_sessions", + "attachment_association_jobs", "business_events", "expense_case_links", "expense_cases", diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiAttachmentAssociationFlow.js b/web/src/composables/workbenchAiMode/useWorkbenchAiAttachmentAssociationFlow.js index d72aa12..404f9a8 100644 --- a/web/src/composables/workbenchAiMode/useWorkbenchAiAttachmentAssociationFlow.js +++ b/web/src/composables/workbenchAiMode/useWorkbenchAiAttachmentAssociationFlow.js @@ -410,8 +410,8 @@ export function useWorkbenchAiAttachmentAssociationFlow({ return [] } return [{ - label: '查看单据', - description: '打开已归集票据的报销单。', + label: '查看草稿', + description: '打开已归集票据的报销草稿。', icon: 'mdi mdi-open-in-new', action_type: 'open_application_detail', payload: { diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiAttachmentAssociationJobs.js b/web/src/composables/workbenchAiMode/useWorkbenchAiAttachmentAssociationJobs.js index db092d0..f7928b0 100644 --- a/web/src/composables/workbenchAiMode/useWorkbenchAiAttachmentAssociationJobs.js +++ b/web/src/composables/workbenchAiMode/useWorkbenchAiAttachmentAssociationJobs.js @@ -1,5 +1,13 @@ import * as aiAttachmentAssociationModel from '../../utils/aiAttachmentAssociationModel.js' -import { fetchAttachmentAssociationJob } from '../../services/attachmentAssociationJobs.js' +import { + buildAttachmentAssociationCandidateActions, + isAttachmentAssociationConfirmationResult, + resolveAttachmentAssociationTarget +} from '../../utils/attachmentAssociationJobModel.js' +import { + fetchAttachmentAssociationJob, + normalizeAttachmentAssociationJob +} from '../../services/attachmentAssociationJobs.js' const ATTACHMENT_ASSOCIATION_JOB_POLL_INTERVAL_MS = 1200 const ATTACHMENT_ASSOCIATION_JOB_MAX_POLLS = 90 @@ -25,23 +33,7 @@ export function useWorkbenchAiAttachmentAssociationJobs({ } function normalizeJob(job = {}) { - const jobId = String(job?.job_id || job?.jobId || '').trim() - if (!jobId) { - return null - } - return { - jobId, - status: String(job?.status || 'queued').trim() || 'queued', - message: String(job?.message || '').trim(), - receiptIds: (Array.isArray(job?.receipt_ids) ? job.receipt_ids : job?.receiptIds || []) - .map((item) => String(item || '').trim()) - .filter(Boolean), - claimId: String(job?.claim_id || job?.claimId || '').trim(), - claimNo: String(job?.claim_no || job?.claimNo || '').trim(), - uploadedCount: Number(job?.uploaded_count ?? job?.uploadedCount ?? 0) || 0, - skippedCount: Number(job?.skipped_count ?? job?.skippedCount ?? 0) || 0, - error: String(job?.error || '').trim() - } + return normalizeAttachmentAssociationJob(job) } function isPending(job = {}) { @@ -112,12 +104,39 @@ export function useWorkbenchAiAttachmentAssociationJobs({ if (!findMessage(messageId)) { return true } + if (isAttachmentAssociationConfirmationResult(normalizedJob)) { + const confirmationMessage = aiAttachmentAssociationModel.buildAiAttachmentAssociationConfirmationResultMessage({ + job: normalizedJob, + fileNames + }) + await streamOrSetInlineAssistantContent(messageId, confirmationMessage) + replaceJobMessage(messageId, confirmationMessage, { + attachmentAssociationJob: normalizedJob, + attachmentOcrDetails, + stewardPlan: { + streamStatus: 'completed', + thinkingEvents: buildThinkingEvents('completed') + }, + suggestedActions: buildAttachmentAssociationCandidateActions(normalizedJob) + }) + persistCurrentConversation() + return true + } if (normalizedJob.status === 'succeeded') { + const target = resolveAttachmentAssociationTarget(normalizedJob) const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationResultMessage({ - claimNo: normalizedJob.claimNo, + claimNo: target.claimNo, fileNames, uploadedCount: normalizedJob.uploadedCount, - skippedCount: normalizedJob.skippedCount + skippedCount: normalizedJob.skippedCount, + applicationClaimNo: normalizedJob.applicationClaimNo, + confidence: normalizedJob.confidence, + matchReasons: normalizedJob.matchReasons, + riskItems: normalizedJob.riskItems, + confirmationRequired: Boolean( + normalizedJob.draftPayload?.confirmationRequired + ?? normalizedJob.draftPayload?.confirmation_required + ) }) await streamOrSetInlineAssistantContent(messageId, finalMessageText) replaceJobMessage(messageId, finalMessageText, { @@ -128,13 +147,13 @@ export function useWorkbenchAiAttachmentAssociationJobs({ thinkingEvents: buildThinkingEvents('completed') }, suggestedActions: buildDetailActions({ - claimId: normalizedJob.claimId, - claimNo: normalizedJob.claimNo + claimId: target.claimId, + claimNo: target.claimNo }) }) notifyRequestUpdated?.({ - claimId: normalizedJob.claimId, - claimNo: normalizedJob.claimNo, + claimId: target.claimId, + claimNo: target.claimNo, source: 'ai-workbench-attachment-association-job', uploadedCount: normalizedJob.uploadedCount, skippedCount: normalizedJob.skippedCount @@ -244,7 +263,7 @@ export function useWorkbenchAiAttachmentAssociationJobs({ extractReceiptIdsFromOcrDocuments, normalizeJob, pollJob, - resumePendingJobs + resumePendingJobs, + updateJobMessage } } - diff --git a/web/src/composables/workbenchAiMode/workbenchAiMessageModel.js b/web/src/composables/workbenchAiMode/workbenchAiMessageModel.js index 4e6ba72..120731c 100644 --- a/web/src/composables/workbenchAiMode/workbenchAiMessageModel.js +++ b/web/src/composables/workbenchAiMode/workbenchAiMessageModel.js @@ -1,3 +1,5 @@ +import { normalizeAttachmentAssociationJob } from '../../utils/attachmentAssociationJobModel.js' + export const AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION = 'confirm_ai_attachment_association' export const AI_ATTACHMENT_OCR_DETAIL_ACTION = 'show_ai_attachment_ocr_details' @@ -212,28 +214,7 @@ export function createWorkbenchAiMessageRuntime() { } export function normalizeInlineAttachmentAssociationJob(job = null) { - if (!job || typeof job !== 'object') { - return null - } - const jobId = String(job.jobId || job.job_id || '').trim() - if (!jobId) { - return null - } - const status = String(job.status || 'queued').trim() || 'queued' - const receiptIds = (Array.isArray(job.receiptIds) ? job.receiptIds : job.receipt_ids || []) - .map((item) => String(item || '').trim()) - .filter(Boolean) - return { - jobId, - status, - message: String(job.message || '').trim(), - receiptIds, - claimId: String(job.claimId || job.claim_id || '').trim(), - claimNo: String(job.claimNo || job.claim_no || '').trim(), - uploadedCount: Number(job.uploadedCount ?? job.uploaded_count ?? 0) || 0, - skippedCount: Number(job.skippedCount ?? job.skipped_count ?? 0) || 0, - error: String(job.error || '').trim() - } + return normalizeAttachmentAssociationJob(job) } export function normalizeInlineLinkedReimbursementDraftJob(job = null) { diff --git a/web/src/services/attachmentAssociationJobs.js b/web/src/services/attachmentAssociationJobs.js index 476485b..c967d30 100644 --- a/web/src/services/attachmentAssociationJobs.js +++ b/web/src/services/attachmentAssociationJobs.js @@ -1,21 +1,25 @@ import { apiRequest } from './api.js' +import { normalizeAttachmentAssociationJob } from '../utils/attachmentAssociationJobModel.js' + +export { normalizeAttachmentAssociationJob } from '../utils/attachmentAssociationJobModel.js' function normalizeJobId(jobId) { return String(jobId || '').trim() } -export function createAttachmentAssociationJob(payload = {}) { - return apiRequest('/reimbursements/attachment-association-jobs', { +export async function createAttachmentAssociationJob(payload = {}) { + const job = await apiRequest('/reimbursements/attachment-association-jobs', { method: 'POST', body: JSON.stringify(payload || {}) }) + return normalizeAttachmentAssociationJob(job) } -export function fetchAttachmentAssociationJob(jobId) { +export async function fetchAttachmentAssociationJob(jobId) { const normalizedJobId = normalizeJobId(jobId) if (!normalizedJobId) { throw new Error('附件关联任务不存在或已失效。') } - return apiRequest(`/reimbursements/attachment-association-jobs/${encodeURIComponent(normalizedJobId)}`) + const job = await apiRequest(`/reimbursements/attachment-association-jobs/${encodeURIComponent(normalizedJobId)}`) + return normalizeAttachmentAssociationJob(job) } - diff --git a/web/src/utils/aiAttachmentAssociationModel.js b/web/src/utils/aiAttachmentAssociationModel.js index 8bb0f71..12ec9db 100644 --- a/web/src/utils/aiAttachmentAssociationModel.js +++ b/web/src/utils/aiAttachmentAssociationModel.js @@ -449,25 +449,136 @@ export function buildAiAttachmentAssociationResultMessage({ claimNo = '', uploadedCount = 0, skippedCount = 0, - fileNames = [] + fileNames = [], + applicationClaimNo = '', + confidence = '', + matchReasons = [], + riskItems = [], + confirmationRequired = false } = {}) { const normalizedUploadedCount = Math.max(0, Number(uploadedCount || 0)) const normalizedSkippedCount = Math.max(0, Number(skippedCount || 0)) - const done = normalizedUploadedCount > 0 && normalizedSkippedCount === 0 + const alreadyAssociated = normalizedUploadedCount === 0 && normalizedSkippedCount > 0 + const completed = normalizedUploadedCount + normalizedSkippedCount > 0 + const riskText = formatAssociationIssueList(riskItems) + const requiresReview = Boolean(confirmationRequired || riskText) + const resultText = alreadyAssociated + ? `${normalizedSkippedCount} 份已关联,无需重复归集` + : `${normalizedUploadedCount} 份新归集${normalizedSkippedCount ? `,${normalizedSkippedCount} 份已关联并跳过` : ''}` return [ - done ? '已完成自动归集。' : '自动归集已处理完成,请留意未归集附件。', + alreadyAssociated + ? '票据此前已经完成归集,本次未重复写入。' + : completed + ? '已完成自动归集。' + : '自动归集未写入附件,请进入草稿核对。', renderAssociationCard({ - title: done ? '票据已归集' : '票据归集结果', - status: done ? '已完成' : '部分完成', - tone: done ? 'is-success' : 'is-warning', + title: alreadyAssociated ? '票据已关联' : completed ? '票据已归集' : '票据归集结果', + status: requiresReview ? '待复核' : completed ? '已完成' : '未完成', + tone: requiresReview || !completed ? 'is-warning' : 'is-success', fields: [ renderAssociationField('关联单据', claimNo || '当前匹配单据'), - renderAssociationField('归集结果', `${normalizedUploadedCount} 份成功${normalizedSkippedCount ? `,${normalizedSkippedCount} 份未归集` : ''}`), + applicationClaimNo + ? renderAssociationField('来源申请', applicationClaimNo) + : '', + renderAssociationField('归集结果', resultText), + confidence + ? renderAssociationField('匹配置信度', confidence) + : '', + Array.isArray(matchReasons) && matchReasons.length + ? renderAssociationField('匹配依据', matchReasons.join(';'), { wide: true, muted: true }) + : '', + riskText + ? renderAssociationField('风险提示', riskText, { wide: true }) + : '', renderAssociationField('附件', formatAttachmentNames(fileNames), { wide: true }) - ], - note: done - ? '附件已经写入该报销单,可进入详情页继续核对。' - : '部分附件没有找到可用明细项,请进入详情页手动核对。' + ].filter(Boolean), + note: requiresReview + ? '附件已关联,但草稿仍有风险或待确认项,请进入详情页复核后再提交。' + : completed + ? '附件已经写入该报销单,可进入详情页继续核对。' + : '没有附件写入,请进入详情页手动核对。' + }) + ].join('\n\n') +} + +function formatAssociationIssueList(items = []) { + return (Array.isArray(items) ? items : []) + .map((item) => String(item?.message || item?.description || item?.detail || item || '').trim()) + .filter(Boolean) + .join(';') +} + +function formatServerCandidate(candidate = {}, index = 0) { + const claimNo = String(candidate?.claimNo || candidate?.claim_no || '').trim() + const applicationClaimNo = String( + candidate?.applicationClaimNo || candidate?.application_claim_no || '' + ).trim() + const documentNo = claimNo || applicationClaimNo || '未编号单据' + const confidence = String(candidate?.confidence || '').trim() + const score = Number(candidate?.score ?? candidate?.confidenceScore ?? candidate?.confidence_score ?? 0) || 0 + const reasons = Array.isArray(candidate?.matchReasons) + ? candidate.matchReasons + : Array.isArray(candidate?.match_reasons) + ? candidate.match_reasons + : [] + return [ + `${index + 1}. ${documentNo}`, + claimNo && applicationClaimNo ? `来源申请 ${applicationClaimNo}` : '', + confidence ? `置信度 ${confidence}${score ? `(${score})` : ''}` : score ? `匹配分 ${score}` : '', + reasons.length ? reasons.join('、') : '' + ].filter(Boolean).join(',') +} + +export function buildAiAttachmentAssociationConfirmationResultMessage({ + job = null, + fileNames = [] +} = {}) { + const candidates = (Array.isArray(job?.candidates) ? job.candidates : []) + .slice(0, 3) + .map((candidate, index) => formatServerCandidate(candidate, index)) + const exceptions = formatAssociationIssueList(job?.exceptions) + const missingFields = formatAssociationIssueList(job?.missingFields || job?.missing_fields) + const riskItems = formatAssociationIssueList(job?.riskItems || job?.risk_items) + const matchReasons = (Array.isArray(job?.matchReasons) ? job.matchReasons : job?.match_reasons || []) + .map((item) => String(item || '').trim()) + .filter(Boolean) + const confidence = String(job?.confidence || '').trim() + const confidenceScore = Number(job?.confidenceScore ?? job?.confidence_score ?? 0) || 0 + + return [ + '我已完成票据匹配分析,但当前信息不足以安全自动归集。', + renderAssociationCard({ + title: '候选单据需要确认', + status: '未归集', + tone: 'is-warning', + ariaLabel: '票据关联候选确认', + fields: [ + renderAssociationField('本次附件', formatAttachmentNames(fileNames)), + confidence || confidenceScore + ? renderAssociationField( + '整体置信度', + [confidence, confidenceScore ? String(confidenceScore) : ''].filter(Boolean).join(' / ') + ) + : '', + matchReasons.length + ? renderAssociationField('匹配依据', matchReasons.join(';'), { wide: true, muted: true }) + : '', + candidates.length + ? renderAssociationField('候选单据', candidates.join(';'), { wide: true }) + : renderAssociationField('候选单据', '暂未形成可安全查看的候选单据', { wide: true }), + exceptions + ? renderAssociationField('待处理异常', exceptions, { wide: true }) + : '', + missingFields + ? renderAssociationField('待补信息', missingFields, { wide: true }) + : '', + riskItems + ? renderAssociationField('风险提示', riskItems, { wide: true }) + : '' + ].filter(Boolean), + note: candidates.length + ? '我没有写入任何草稿。请先查看候选单据,核对后再决定归集目标。' + : '我没有写入任何草稿,避免因信息不足造成误归集。' }) ].join('\n\n') } diff --git a/web/src/utils/attachmentAssociationJobModel.js b/web/src/utils/attachmentAssociationJobModel.js new file mode 100644 index 0000000..d6608cc --- /dev/null +++ b/web/src/utils/attachmentAssociationJobModel.js @@ -0,0 +1,162 @@ +function normalizeText(value = '') { + return String(value ?? '').trim() +} + +function normalizeStringList(values = []) { + return (Array.isArray(values) ? values : []) + .map((item) => normalizeText(item)) + .filter(Boolean) +} + +function normalizeIssue(item = null) { + if (!item) { + return null + } + if (typeof item === 'string' || typeof item === 'number') { + const message = normalizeText(item) + return message ? { code: '', message } : null + } + if (typeof item !== 'object') { + return null + } + const code = normalizeText(item.code || item.type || item.key) + const message = normalizeText( + item.message || item.detail || item.description || item.reason || item.label || code + ) + return message ? { code, message } : null +} + +function normalizeIssueList(values = []) { + return (Array.isArray(values) ? values : []) + .map((item) => normalizeIssue(item)) + .filter(Boolean) +} + +function normalizeDraftPayload(payload = null) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return null + } + return { + ...payload, + claimId: normalizeText(payload.claimId || payload.claim_id || payload.id), + claimNo: normalizeText(payload.claimNo || payload.claim_no) + } +} + +export function normalizeAttachmentAssociationCandidate(candidate = null) { + if (!candidate || typeof candidate !== 'object') { + return null + } + const claimId = normalizeText(candidate.claimId || candidate.claim_id) + const claimNo = normalizeText(candidate.claimNo || candidate.claim_no) + const applicationClaimId = normalizeText(candidate.applicationClaimId || candidate.application_claim_id) + const applicationClaimNo = normalizeText(candidate.applicationClaimNo || candidate.application_claim_no) + if (!claimId && !claimNo && !applicationClaimId && !applicationClaimNo) { + return null + } + return { + targetType: normalizeText(candidate.targetType || candidate.target_type || 'expense_claim'), + expenseCaseId: normalizeText(candidate.expenseCaseId || candidate.expense_case_id), + applicationClaimId, + applicationClaimNo, + claimId, + claimNo, + confidence: normalizeText(candidate.confidence), + score: Number(candidate.score ?? candidate.confidence_score ?? 0) || 0, + matchReasons: normalizeStringList(candidate.matchReasons || candidate.match_reasons || []) + } +} + +export function normalizeAttachmentAssociationJob(job = null) { + if (!job || typeof job !== 'object') { + return null + } + const jobId = normalizeText(job.jobId || job.job_id) + if (!jobId) { + return null + } + const resolution = normalizeText(job.resolution) + const requiresConfirmation = Boolean( + job.requiresConfirmation ?? job.requires_confirmation ?? resolution === 'requires_confirmation' + ) + return { + jobId, + status: normalizeText(job.status || 'queued') || 'queued', + resolution: resolution || (requiresConfirmation ? 'requires_confirmation' : ''), + requiresConfirmation, + message: normalizeText(job.message), + receiptIds: normalizeStringList(job.receiptIds || job.receipt_ids || []), + expenseCaseId: normalizeText(job.expenseCaseId || job.expense_case_id), + applicationClaimId: normalizeText(job.applicationClaimId || job.application_claim_id), + applicationClaimNo: normalizeText(job.applicationClaimNo || job.application_claim_no), + claimId: normalizeText(job.claimId || job.claim_id), + claimNo: normalizeText(job.claimNo || job.claim_no), + confidence: normalizeText(job.confidence), + confidenceScore: Number(job.confidenceScore ?? job.confidence_score ?? 0) || 0, + matchReasons: normalizeStringList(job.matchReasons || job.match_reasons || []), + exceptions: normalizeIssueList(job.exceptions || []), + missingFields: normalizeIssueList(job.missingFields || job.missing_fields || []), + riskItems: normalizeIssueList(job.riskItems || job.risk_items || []), + candidates: (Array.isArray(job.candidates) ? job.candidates : []) + .map((candidate) => normalizeAttachmentAssociationCandidate(candidate)) + .filter(Boolean), + draftPayload: normalizeDraftPayload(job.draftPayload || job.draft_payload), + uploadedCount: Number(job.uploadedCount ?? job.uploaded_count ?? 0) || 0, + skippedCount: Number(job.skippedCount ?? job.skipped_count ?? 0) || 0, + error: normalizeText(job.error) + } +} + +export function isAttachmentAssociationConfirmationResult(job = null) { + const normalizedJob = normalizeAttachmentAssociationJob(job) + return Boolean( + normalizedJob?.requiresConfirmation || normalizedJob?.resolution === 'requires_confirmation' + ) +} + +export function resolveAttachmentAssociationTarget(job = null) { + const normalizedJob = normalizeAttachmentAssociationJob(job) + if (!normalizedJob) { + return { claimId: '', claimNo: '' } + } + const draftPayload = normalizedJob.draftPayload || {} + const firstCandidate = normalizedJob.candidates[0] || {} + return { + claimId: normalizeText( + normalizedJob.claimId || draftPayload.claimId || draftPayload.claim_id || draftPayload.id || firstCandidate.claimId + ), + claimNo: normalizeText( + normalizedJob.claimNo || draftPayload.claimNo || draftPayload.claim_no || firstCandidate.claimNo + ) + } +} + +export function buildAttachmentAssociationCandidateActions(job = null) { + const normalizedJob = normalizeAttachmentAssociationJob(job) + if (!normalizedJob) { + return [] + } + return normalizedJob.candidates.slice(0, 3).map((candidate, index) => { + const applicationOnly = !candidate.claimId && !candidate.claimNo && Boolean( + candidate.applicationClaimId || candidate.applicationClaimNo + ) + const labelPrefix = applicationOnly ? '查看候选申请' : '查看候选草稿' + const documentNo = applicationOnly ? candidate.applicationClaimNo : candidate.claimNo + return { + label: normalizedJob.candidates.length > 1 ? `${labelPrefix} ${index + 1}` : labelPrefix, + description: documentNo + ? `打开候选${applicationOnly ? '申请单' : '报销草稿'} ${documentNo} 核对详情。` + : `打开候选${applicationOnly ? '申请单' : '报销草稿'}核对详情。`, + icon: 'mdi mdi-open-in-new', + action_type: 'open_application_detail', + payload: { + claim_id: applicationOnly ? candidate.applicationClaimId : candidate.claimId, + claim_no: applicationOnly ? candidate.applicationClaimNo : candidate.claimNo, + document_type: applicationOnly ? 'application' : 'expense', + expense_case_id: candidate.expenseCaseId, + application_claim_id: candidate.applicationClaimId, + application_claim_no: candidate.applicationClaimNo + } + } + }) +} diff --git a/web/tests/attachment-association-job-result.test.mjs b/web/tests/attachment-association-job-result.test.mjs new file mode 100644 index 0000000..0237413 --- /dev/null +++ b/web/tests/attachment-association-job-result.test.mjs @@ -0,0 +1,236 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildAttachmentAssociationCandidateActions, + normalizeAttachmentAssociationJob, + resolveAttachmentAssociationTarget +} from '../src/utils/attachmentAssociationJobModel.js' +import { + buildAiAttachmentAssociationConfirmationResultMessage, + buildAiAttachmentAssociationResultMessage +} from '../src/utils/aiAttachmentAssociationModel.js' +import { renderAiConversationHtml } from '../src/utils/aiConversationHtmlRenderer.js' +import { createWorkbenchAiMessageRuntime } from '../src/composables/workbenchAiMode/workbenchAiMessageModel.js' +import { useWorkbenchAiAttachmentAssociationJobs } from '../src/composables/workbenchAiMode/useWorkbenchAiAttachmentAssociationJobs.js' + +function buildConfirmationJob(overrides = {}) { + return { + job_id: 'association-job-1', + status: 'succeeded', + resolution: 'requires_confirmation', + requires_confirmation: true, + expense_case_id: 'case-1', + application_claim_id: 'application-1', + application_claim_no: 'SQ-20260716-001', + confidence: 'medium', + confidence_score: 0.8, + match_reasons: ['日期接近', '行程城市相同'], + exceptions: [{ code: 'ambiguous_candidates', message: '前两名候选分差过小' }], + missing_fields: ['项目待补充'], + risk_items: [{ code: 'amount_gap', description: '票据金额与申请预算存在差异' }], + candidates: [ + { + target_type: 'reimbursement_draft', + expense_case_id: 'case-1', + application_claim_id: 'application-1', + application_claim_no: 'SQ-20260716-001', + claim_id: 'draft-1', + claim_no: 'BX-20260716-001', + confidence: 'medium', + score: 0.8, + match_reasons: ['日期接近', '行程城市相同'] + }, + { + target_type: 'reimbursement_draft', + expense_case_id: 'case-2', + claim_id: 'draft-2', + claim_no: 'BX-20260716-002', + confidence: 'medium', + score: 0.67, + match_reasons: ['日期接近'] + } + ], + draft_payload: { claim_id: 'draft-preview', claim_no: 'BX-PREVIEW-001' }, + ...overrides + } +} + +test('附件关联任务完整规范化新协议并在会话序列化后保留', () => { + const normalized = normalizeAttachmentAssociationJob(buildConfirmationJob()) + + assert.equal(normalized.requiresConfirmation, true) + assert.equal(normalized.expenseCaseId, 'case-1') + assert.equal(normalized.applicationClaimNo, 'SQ-20260716-001') + assert.equal(normalized.confidenceScore, 0.8) + assert.deepEqual(normalized.matchReasons, ['日期接近', '行程城市相同']) + assert.equal(normalized.exceptions[0].code, 'ambiguous_candidates') + assert.equal(normalized.missingFields[0].message, '项目待补充') + assert.equal(normalized.riskItems[0].message, '票据金额与申请预算存在差异') + assert.equal(normalized.candidates[0].claimId, 'draft-1') + assert.equal(normalized.draftPayload.claimNo, 'BX-PREVIEW-001') + + const runtime = createWorkbenchAiMessageRuntime() + const message = runtime.createInlineMessage('assistant', '后台处理中', { + attachmentAssociationJob: buildConfirmationJob() + }) + const restored = runtime.normalizeRuntimeMessage(runtime.serializeRuntimeMessage(message)) + + assert.deepEqual(restored.attachmentAssociationJob, normalized) +}) + +test('待确认结果集中展示候选和异常且不伪装成自动归集成功', () => { + const job = normalizeAttachmentAssociationJob(buildConfirmationJob()) + const message = buildAiAttachmentAssociationConfirmationResultMessage({ + job, + fileNames: ['武汉-上海.pdf'] + }) + const html = renderAiConversationHtml(message) + const actions = buildAttachmentAssociationCandidateActions(job) + + assert.match(message, /当前信息不足以安全自动归集/) + assert.match(html, /候选单据需要确认/) + assert.match(html, /BX-20260716-001/) + assert.match(html, /前两名候选分差过小/) + assert.match(html, /项目待补充/) + assert.match(html, /票据金额与申请预算存在差异/) + assert.match(html, /没有写入任何草稿/) + assert.doesNotMatch(message, /已完成自动归集/) + assert.equal(actions.length, 2) + assert.equal(actions[0].label, '查看候选草稿 1') + assert.equal(actions[0].payload.claim_id, 'draft-1') + assert.equal(actions[0].payload.application_claim_no, 'SQ-20260716-001') +}) + +test('只有申请候选时保留申请详情动作且不会伪造报销草稿', () => { + const job = normalizeAttachmentAssociationJob(buildConfirmationJob({ + candidates: [{ + target_type: 'approved_application', + expense_case_id: 'case-application-only', + application_claim_id: 'application-only', + application_claim_no: 'AP-ONLY-001', + confidence: 'high', + score: 0.8, + match_reasons: ['申请日期和票据日期一致'] + }] + })) + const actions = buildAttachmentAssociationCandidateActions(job) + + assert.equal(job.candidates.length, 1) + assert.equal(job.candidates[0].claimId, '') + assert.equal(actions[0].label, '查看候选申请') + assert.equal(actions[0].payload.claim_id, 'application-only') + assert.equal(actions[0].payload.document_type, 'application') +}) + +test('幂等重放展示为已关联而不是未归集', () => { + const message = buildAiAttachmentAssociationResultMessage({ + claimNo: 'BX-20260716-001', + uploadedCount: 0, + skippedCount: 1, + fileNames: ['武汉-上海.pdf'] + }) + + assert.match(message, /此前已经完成归集/) + assert.match(message, /1 份已关联,无需重复归集/) + assert.doesNotMatch(message, /未归集附件|部分完成/) +}) + +test('自动归集成功仍展示草稿风险和复核提示', () => { + const message = buildAiAttachmentAssociationResultMessage({ + claimNo: 'BX-20260716-001', + uploadedCount: 1, + riskItems: [{ message: '票据金额需要人工复核' }], + confirmationRequired: true + }) + + assert.match(message, /风险提示/) + assert.match(message, /票据金额需要人工复核/) + assert.match(message, /待复核/) + assert.match(message, /复核后再提交/) +}) + +test('任务编排把待确认结果视为安全终态且不发送归集成功通知', async () => { + const runtime = createWorkbenchAiMessageRuntime() + const conversationMessages = { + value: [runtime.createInlineMessage('assistant', '后台处理中', { id: 'message-1', pending: true })] + } + let notifyCount = 0 + let persistCount = 0 + const flow = useWorkbenchAiAttachmentAssociationJobs({ + conversationMessages, + createInlineMessage: runtime.createInlineMessage, + persistCurrentConversation: () => { persistCount += 1 }, + replaceInlineMessage: (messageId, message) => { + const index = conversationMessages.value.findIndex((item) => item.id === messageId) + conversationMessages.value.splice(index, 1, message) + }, + streamOrSetInlineAssistantContent: async () => {}, + notifyRequestUpdated: () => { notifyCount += 1 }, + toast: () => {}, + buildDetailActions: () => [{ label: '查看草稿' }], + buildThinkingEvents: (status) => [{ status }] + }) + + const done = await flow.updateJobMessage({ + job: buildConfirmationJob(), + messageId: 'message-1', + fileNames: ['武汉-上海.pdf'] + }) + const resultMessage = conversationMessages.value[0] + + assert.equal(done, true) + assert.equal(notifyCount, 0) + assert.equal(persistCount, 1) + assert.equal(resultMessage.pending, false) + assert.equal(resultMessage.stewardPlan.streamStatus, 'completed') + assert.match(resultMessage.content, /未归集/) + assert.doesNotMatch(resultMessage.content, /已完成自动归集/) + assert.equal(resultMessage.suggestedActions[0].payload.claim_id, 'draft-1') +}) + +test('自动完成结果可从 draft_payload 恢复并继续提供查看草稿动作', async () => { + const runtime = createWorkbenchAiMessageRuntime() + const conversationMessages = { + value: [runtime.createInlineMessage('assistant', '后台处理中', { id: 'message-2', pending: true })] + } + let notification = null + const flow = useWorkbenchAiAttachmentAssociationJobs({ + conversationMessages, + createInlineMessage: runtime.createInlineMessage, + persistCurrentConversation: () => {}, + replaceInlineMessage: (messageId, message) => { + const index = conversationMessages.value.findIndex((item) => item.id === messageId) + conversationMessages.value.splice(index, 1, message) + }, + streamOrSetInlineAssistantContent: async () => {}, + notifyRequestUpdated: (payload) => { notification = payload }, + toast: () => {}, + buildDetailActions: (target) => [{ + label: '查看草稿', + action_type: 'open_application_detail', + payload: { claim_id: target.claimId, claim_no: target.claimNo } + }], + buildThinkingEvents: (status) => [{ status }] + }) + const job = { + job_id: 'association-job-2', + status: 'succeeded', + resolution: 'auto_associated', + uploaded_count: 1, + skipped_count: 0, + draft_payload: { claim_id: 'draft-auto', claim_no: 'BX-AUTO-001' } + } + + await flow.updateJobMessage({ job, messageId: 'message-2', fileNames: ['住宿发票.pdf'] }) + const resultMessage = conversationMessages.value[0] + + assert.deepEqual(resolveAttachmentAssociationTarget(job), { + claimId: 'draft-auto', + claimNo: 'BX-AUTO-001' + }) + assert.match(resultMessage.content, /已完成自动归集/) + assert.equal(resultMessage.suggestedActions[0].label, '查看草稿') + assert.equal(resultMessage.suggestedActions[0].payload.claim_id, 'draft-auto') + assert.equal(notification.claimId, 'draft-auto') +})