feat(expense): add persistent zero-entry receipt association

This commit is contained in:
caoxiaozhu
2026-07-16 10:23:23 +08:00
parent 54754b5502
commit ae3f02c35a
30 changed files with 4450 additions and 810 deletions

View File

@@ -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 与平台级异步任务治理仍未完成。

View File

@@ -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。

View File

@@ -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不做未经确认的跨目录迁移。

View File

@@ -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")

View File

@@ -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

View File

@@ -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",

View File

@@ -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")

View File

@@ -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",

View File

@@ -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",

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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 ""),
)

View File

@@ -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 ""

View File

@@ -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,

View File

@@ -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} 已上传并关联到当前费用明细。",

View File

@@ -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

View File

@@ -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 ""

View File

@@ -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

View File

@@ -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,
*,

View File

@@ -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",

File diff suppressed because it is too large Load Diff

View File

@@ -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(

View File

@@ -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",

View File

@@ -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: {

View File

@@ -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
}
}

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -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')
}

View File

@@ -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
}
}
})
}

View File

@@ -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')
})