feat(platform): close AI expense value loop

Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
caoxiaozhu
2026-07-17 14:14:08 +08:00
parent 242d68c36f
commit 787bc3a481
507 changed files with 82072 additions and 6344 deletions

View File

@@ -1,6 +1,6 @@
# AI 费用闭环与价值证明 概念文档
更新时间2026-07-16
更新时间2026-07-17
文档路径document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md
@@ -619,3 +619,10 @@ docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv \
- 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 与平台级异步任务治理仍未完成。
- 2026-07-17费用价值闭环新增 Savings Ledger、CFO 价值分析和财务确认/冲回链路;现金、工时、风险暴露和预计机会严格分账。住宿标准调整从服务端锁定原金额创建机会,付款/生产连接器回执产生 actual realization独立财务确认后才进入现金 KPI退款以负向追加事实冲回。
- 2026-07-17财务连接器建立配置生命周期、HMAC、防重放、事件幂等、对账、ERP 入账、退款/冲回、operational event 和 mock/test/staging 隔离。端到端以本地自签的 production-mode 事件验证申请、票据、报销、预审、审批、回执、ERP、归档、Savings 与商业价值契约;它不代表真实银行/ERP 回执,模拟回执也不会改变核心财务事实。
- 2026-07-17真实发布遥测规则发布从真实 observation、可信 disposition/reviewer label、独立盲审负样本和保守 recall 进入 Monitor/Guardcollecting、积压、聚合失败或低 precision 均不晋级并保持 stable。0023 提供 audit sample、双人票、append-only 和 PostgreSQL 并发保护。
- 2026-07-17商业闭环新增套餐、订阅、账期、权益、配额、用量、成本、ROI、毛利和定价走廊中央 Orchestrator、Runtime Chat、OCR、金融连接器和附件源文件写入按权威数量预占、结算或释放客户价值、平台收入和内部成本不混账。
- 2026-07-17全链租户安全新增 0025-0028统一 Tenant、Employee、Claim、Agent Asset、Knowledge、Ontology、Hermes、Finance Report、Qdrant、文件和缓存作用域ONLYOFFICE 改为数据库一次性会话与 SSRF/重放保护。审批员工解析按认证企业或结构化 Claim 企业首层过滤,跨租户身份和相同名称不再命中。
- 2026-07-17工程收口验证176 个后端测试文件在主应用容器内分片或专项通过,费用主服务 121 项通过fresh PostgreSQL 迁移/并发总探针 `87 passed / 0 skipped / 0 failed`head 为 0028Web 全量 `815 passed / 0 failed` 与 Vite build 通过Mobile lint/typecheck、197 个新增 Python 文件 Ruff、目标 compileall、受门禁核心类/组件 800 行检查和 `git diff --check` 通过。
- 2026-07-17完成边界工程代码和容器验收已收口生产 ONLYOFFICE DNS/TLS、备份副本迁移演练、逐租户 SMTP、真实 provider/会计规则、移动/浏览器实机联调、30/90 天企业试点、客户财务签字和合同定价仍必须使用目标环境与真实业务证据完成,不能由 mock 或本地测试替代。总验收见 `document/development/2026-07-17/feature/engineering-closure-and-production-readiness/`

View File

@@ -1,6 +1,6 @@
# AI 费用闭环与价值证明 开发 TODO
更新时间2026-07-16
更新时间2026-07-17
文档路径document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md
@@ -34,21 +34,28 @@
## 3. 契约与设计
- [ ] [CONCEPT: 费用领域与编排] 定义 `Expense Case`、申请、票据、报销、审批、付款、凭证和归档的领域边界与迁移关系。
- [ ] [CONCEPT: 数据与契约] 定义 `expense_cases``expense_case_links` 和最小状态机,明确非法状态跃迁
- [ ] [CONCEPT: 数据与契约] 定义 `business_events` 事件信封、事件词典、correlation ID、幂等键和版本策略
- [x] [CONCEPT: 费用领域与编排] 定义 `Expense Case`、申请、票据、报销、审批、付款、凭证和归档的领域边界与迁移关系。
证据:`expense_cases.py`、Expense Case/Link/Event 模型、连接器付款/ERP/归档与 Savings 冲回 E2E
- [x] [CONCEPT: 数据与契约] 定义 `expense_cases``expense_case_links` 和最小状态机,明确非法状态跃迁
证据:`20260713_0001_expense_case_business_events.py``ExpenseCaseService` 与状态/事件回归。
- [x] [CONCEPT: 数据与契约] 定义 `business_events` 事件信封、事件词典、correlation ID、幂等键和版本策略。
证据:`BusinessEvent` schema/service、业务事件词典及申请/报销/审批/支付/ERP/Savings 回归。
- [x] [CONCEPT: 业务事件与 AI 决策] 定义 `ai_decisions``ai_decision_feedback``workflow_outcomes` 契约。
证据:`ai_learning.py``expense_application_learning.py``20260714_0003_ai_learning_loop.py`;三类事实分别表达 AI 建议、用户采纳/显式字段纠正和业务结果,技术执行成功、用户反馈与工作流结果不复用同一状态。
- [x] [CONCEPT: 业务事件与 AI 决策] 定义落单前服务端预览决策的签发、授权、过期、一次消费、重放和续签契约。
证据:`ai_application_preview.py``expense_application_preview_decisions.py``expense_application_snapshot.py`;预览不创建空 Case绑定租户、actor、登录会话与 conversation30 分钟过期,保存/提交成功后一次消费;保存草稿在业务提交后尽力返回基于服务端事实的新 `decision_id`,续签失败不反转已成功动作。
- [x] [CONCEPT: 记忆与学习] 定义并实现 `memory_entries``memory_evidence_links`、优先级、有效期、敏感白名单、撤销和遗忘契约;首个切片仅覆盖 `travel_application.transport_mode`
证据:`ai_memory.py``expense_application_memory.py``expense_application_memory_evidence.py``20260714_0005_ai_memory.py`;记忆使用租户、主体、场景、字段和值指纹精确隔离,值只允许“飞机/火车/轮船”,服务异常降级为不应用记忆。
- [ ] [CONCEPT: 自动化决策] 定义动作风险、金额阈值、置信度、证据完整度、可逆性、抽检率和企业授权策略。
- [ ] [CONCEPT: 节省与价值] 定义 Savings Ledger 的机会、执行、实现、确认、去重和归因状态
- [x] [CONCEPT: 自动化决策] 定义动作风险、金额阈值、置信度、证据完整度、可逆性、抽检率和企业授权策略。
证据:`automation_eligibility.py``test_automation_eligibility.py`;资金、制度和高风险动作始终人控
- [x] [CONCEPT: 节省与价值] 定义 Savings Ledger 的机会、执行、实现、确认、去重和归因状态。
证据Savings 模型、0015 迁移、状态服务与并发/E2E 测试。
- [ ] [CONCEPT: 连接器] 定义票据邮箱、税务、企业卡、商旅、支付、银行、ERP、消息和 SSO 连接器协议。
- [ ] [CONCEPT: 权限与安全] 定义服务端会话、租户数据范围、角色和动作级授权契约。
- [x] [CONCEPT: 权限与安全] 定义服务端会话、租户数据范围、角色和动作级授权契约。
证据:不透明 Bearer Session、`CurrentUserContext`、租户访问策略和 endpoint role dependencies0025-0028 多租户安全迁移。
- [ ] [CONCEPT: 指标与验收] 完成首个试点指标字典、基线采集方案、分子分母、数据源和负责人。
- [ ] [CONCEPT: 方案设计] 完成分阶段架构评审,确认新增 service 不继续堆入 `ExpenseClaimService``UserAgentService` 或大型前端 composable。
- [x] [CONCEPT: 方案设计] 完成分阶段架构评审,确认新增 service 不继续堆入 `ExpenseClaimService``UserAgentService` 或大型前端 composable。
证据访问策略、员工解析、附件、预审、Savings、商业、连接器、发布遥测及前端 composable 均按职责拆分;受门禁核心类/组件 800 行检查通过。
## 4. P0 后端实现:费用闭环与数据基础
@@ -56,12 +63,18 @@
证据:`auth_sessions.py``auth_session.py``deps.py``auth.py``authSessionStorage.js`;登录签发不透明 Bearer token数据库仅保存 SHA-256 摘要,生产代码不再信任 `X-Auth-*`,过期/撤销/伪造会话回归测试通过。
- [x] [CONCEPT: 权限与安全] 为管理面、Bootstrap、Settings、模型连通性、缓存、审计和系统日志补齐平台管理员保护。
证据:`bootstrap.py``settings.py``audit_logs.py``agent_traces.py``system_logs.py``vite.config.js`;已初始化 Bootstrap 脱敏且拒绝匿名重配,平台管理员/业务经理权限边界和 Vite Setup 锁测试通过。
- [ ] [CONCEPT: 权限与安全] 继续盘点并收口风险规则发布、制度发布及其他尚未纳入本轮的敏感动作,按动作定义平台管理员或双人复核权限。
- [ ] [CONCEPT: 权限与安全] 为所有新增表和共享核心数据补齐最小 `tenant_id`、数据库约束、查询守卫及默认租户迁移
- [x] [CONCEPT: 权限与安全] 继续盘点并收口风险规则发布、制度发布及其他尚未纳入本轮的敏感动作,按动作定义平台管理员或双人复核权限。
证据Agent Asset/Rule Editor/Reviewer、发布盲审双人票、风险豁免独立决定、平台资产只读和 ONLYOFFICE 写入权限均有服务端守卫与安全测试
- [x] [CONCEPT: 权限与安全] 为本轮新增表及 Claim、Employee、Agent Asset、Knowledge、Ontology、Hermes 和 Report 等纳入范围的共享核心数据补齐 `tenant_id`、约束、查询守卫和默认租户迁移。
证据0025-0028 迁移、复合租户外键、首层 SQL 过滤和 tenant security 测试fresh PostgreSQL 迁移总探针通过。
- [ ] [CONCEPT: 权限与安全] 将 `agent_conversations` 等仍依赖 JSON tenant 的 legacy 状态迁移到结构化租户列和数据库约束。
证据要求:后继迁移、历史回填、复合约束和跨租户回归;当前只能由服务层校验 `state_json.tenant_id`,不得表述为数据库边界已完成。
- [x] [CONCEPT: 权限与安全] 为 Expense Case 查询提供面向用户的精简事件 DTO移除幂等键、correlation、causation 和 Outbox 投递字段,并明确审批意见、退回原因和操作人可见范围。
证据:`expense_case.py``test_expense_case_endpoints.py`;用户态响应只保留安全流程摘要,关联资源 ID 和未知嵌套 payload 被递归过滤,本人、当前审批人、财务、管理员、无权限及跨租户边界测试通过。
- [ ] [CONCEPT: 权限与安全] 为 Qdrant collection/namespace、对象存储前缀和缓存键补齐租户隔离回归测试。
- [ ] [CONCEPT: 数据与契约] 建立 Alembic baseline 和正式迁移链,停止请求路径运行 DDL
- [x] [CONCEPT: 权限与安全] 为 Qdrant collection/namespace、对象存储前缀和缓存键补齐租户隔离回归测试。
证据Knowledge tenant scope、RAG workspace、Qdrant namespace、票据/附件路径运行缓存和财务快照 tenant fingerprint 回归
- [x] [CONCEPT: 数据与契约] 建立 Alembic baseline 和正式迁移链,停止请求路径运行 DDL。
证据migration ownership/preflight 与 0001-0028 正式链fresh PostgreSQL 完整 upgrade/downgrade/re-upgrade 通过,请求路径不再创建 migration-owned 表。
- [x] [CONCEPT: 兼容策略] 集中 migration-owned 表所有权并在标准启动迁移前执行只读漂移预检,禁止 legacy bootstrap 越权建表。
证据:`schema_ownership.py``migration_preflight.py``server_start.sh``test_migration_preflight.py``test_schema_ownership.py`;无版本自有表、缺表、多表、未知/多 revision 均 fail-fast不自动 stamp 或修改数据库。
- [x] [CONCEPT: 费用领域与编排] 新增 `ExpenseCaseService` 和费用事件查询接口,保持编排与具体职责分离。
@@ -99,7 +112,8 @@
- [ ] [CONCEPT: 兼容策略] 制定旧 `ReimbursementRequest` 只读兼容、迁移和停止新增编排的计划。
- [ ] [CONCEPT: 兼容策略] 把新增审批、付款、归档和关系事件移出 `risk_flags_json`,保留旧数据读取兼容。
- [ ] [CONCEPT: 数据与契约] 补齐撤回、取消、驳回、作废、补件、支付失败、对账异常和归档状态。
- [ ] [CONCEPT: 连接器] 实现统一连接器基类、幂等、重试、错误状态和回执事件。
- [x] [CONCEPT: 连接器] 实现财务连接器统一事件信封、认证、幂等、重试、错误状态和回执事件。
证据:`financial_connector_auth.py``financial_connector_ingestion.py``payment_reconciliation.py`、配置生命周期及 operational events服务/API/并发测试通过。
- [ ] [CONCEPT: 连接器] 实现支付批次、回执、重复付款防护、ERP 凭证和对账的内部契约,首期允许 mock connector 但不得再只写单一“已付款”状态。
- [x] [CONCEPT: 降级策略] 将附件关联和关联报销草稿后台任务迁为可持久化、可恢复、可幂等的任务状态。
证据:新增 migration-owned `attachment_association_jobs``20260716_0006`任务使用租户、owner、票据集合和 generation 去重,运行态带租约与 `attempt_count + running` 栅栏GET 可恢复 queued/租约过期任务。同票据和同 Claim 均由进程锁与 PostgreSQL advisory lock 串行化Claim 锁内重新匹配;待确认或失败历史保留原代并以新 generation 重新评估,自动关联成功代继续幂等复用。进程状态清空、租约过期、旧 worker 回写、不同票据并发同 Claim 和代际重评估回归通过。
@@ -137,9 +151,12 @@
## 6. P1 算法与规则实现:安全自动化与持续学习
- [ ] [CONCEPT: 自动化决策] 实现 L0-L5 动作级自动化等级和资格计算器。
- [ ] [CONCEPT: 自动化决策] 为每个动作实现硬白名单、金额上限、证据要求、抽检率和企业上限
- [ ] [CONCEPT: 风险与预审] 统一风险输出为事实、规则、证据、判断、建议动作和降级原因
- [x] [CONCEPT: 自动化决策] 实现 L0-L5 动作级自动化等级和资格计算器。
证据:`AutomationEligibilityCalculator` 将 L0-L4 资格与 L5 人控分开计算,不直接执行动作;单元测试覆盖保守降级
- [x] [CONCEPT: 自动化决策] 为每个可自动动作实现硬白名单、金额上限、证据要求、抽检率和企业上限
证据:系统硬白名单与企业白名单取交集;资金、制度和高风险动作永远人控,金额、置信度、证据、历史精度、样本和抽检任一不足即降级。
- [x] [CONCEPT: 风险与预审] 统一风险输出为事实、规则、证据、判断、建议动作和降级原因。
证据:结构化 pre-review findings、平台风险投影、整改动作、historical evidence 和 fail-closed 原因已用于申请/报销提交与审批风险卡。
- [x] [CONCEPT: 记忆激活] 为首个个人出行方式切片实现 candidate/active/suppressed/expired/revoked 记忆状态机。
证据Candidate/Active 默认有效期分别为 90/180 天;反向或非白名单纠正抑制已激活记忆,过期后新证据创建新 generation忘记后清值并阻止旧请求复活。
- [x] [CONCEPT: 记忆激活] 实现用户、部门、企业记忆优先级、冲突解释、时间衰减和最小样本要求。
@@ -160,9 +177,14 @@
- [ ] [CONCEPT: 记忆与学习] 从字段接受/修改/拒绝、退回、审批覆盖、付款和审计结果生成记忆证据。
- [x] [CONCEPT: 记忆与学习] 将已确认 few-shot 扩展到报销预审和审批辅助,并按租户、场景、制度版本过滤。
证据:`few_shot_ingestion.py``few_shot_retrieval.py``expense_claim_historical_evidence.py``expense_claim_pre_review.py`;样本关系库和 Qdrant 同时绑定租户、场景、制度与规则版本,检索命中后再由关系库校验,旧版本标记 stale依赖异常返回空证据。公开 `historical_case_evidence` 与 AI 助手只显示“历史已确认/历史误报,仅供复核”的固定脱敏摘要,不暴露 sample ID、单号、人工评论或结论原文也不改变确定性结论、阻断数量、预算复核和审批路由。
- [ ] [CONCEPT: 风险与预审] 完成 golden case、Prompt/规则版本、Canary、回归门禁和自动回滚。
- [ ] [CONCEPT: 自动化决策] 先上线 shadow再按动作逐项开放 L3L4 必须单独评审
- [ ] [CONCEPT: 降级策略] 对模型、OCR、Qdrant、连接器和记忆服务实现稳定降级和可观测状态
- [x] [CONCEPT: 风险与预审] 完成 golden case、Prompt/规则版本、Canary、回归门禁和自动回滚的工程闭环
证据Golden evaluator、版本化资产、真实 observation/label、盲审负样本、Release Monitor/Guard、Canary 与 stable 回滚组合测试通过;生产效果仍由试点章节单独验收
- [x] [CONCEPT: 自动化决策] 实现默认 shadow、按动作开放 L3、L4 单独受控的代码策略
证据Automation Policy 的 `release_stage`、企业上限、硬白名单和 L4 资格检查;资金/制度/高风险动作不进入自动执行。
- [ ] [CONCEPT: 自动化决策] 在真实企业按周运行 shadow 并依据签字阈值逐项开放 L3/L4。
证据要求:生产样本、抽检、回滚演练和企业授权;本地代码测试不得替代。
- [x] [CONCEPT: 降级策略] 对模型、OCR、Qdrant、连接器和记忆服务实现稳定降级和可观测状态。
证据Runtime Chat attempt、OCR worker、Knowledge RAG、连接器 operational events/health、记忆 fail-closed 与发布告警均有明确失败状态和回归。
## 7. P1 前端实现:审批例外与 AI 记忆
@@ -192,23 +214,38 @@
## 8. P2 实现:费用经营与价值证明
- [ ] [CONCEPT: 节省与价值] 新增 `savings_opportunities``savings_realizations` 和去重/确认表结构。
- [ ] [CONCEPT: 节省与价值] 实现风险暴露、预计节省、执行中、实际节省和财务确认的严格状态转换
- [ ] [CONCEPT: 费用分析与节省] 持久化员工、部门、费用类型、供应商、城市、项目和流程基线,记录窗口和样本量
- [ ] [CONCEPT: 费用分析与节省] 实现预算预测、异常归因、供应商价格漂移、重复小额浪费和政策模拟
- [ ] [CONCEPT: CFO 价值看板] 展示现金节省、工时价值、直通率、风险护栏、节省来源和责任人
- [ ] [CONCEPT: CFO 价值看板] 实现部门、项目、费用类型、供应商、城市、时间和单据下钻
- [ ] [CONCEPT: CFO 价值看板] 每项节省支持查看基线、建议、执行、实际结果、确认人和证据
- [x] [CONCEPT: 节省与价值] 新增 Savings baseline、opportunity、realization、evidence、event 和去重/确认表结构。
证据:`models/savings.py``20260716_0015_savings_value_ledger.py` 与模型/迁移测试
- [x] [CONCEPT: 节省与价值] 实现预计机会、接受、执行中、实际结果、财务确认、拒绝、过期和冲回的严格状态转换
证据Savings actions/realization 服务、版本/指纹、canonical benefit 去重与 PostgreSQL 并发测试;风险暴露保持独立护栏,不混入节省金额
- [x] [CONCEPT: 费用分析与节省] 持久化员工、部门、费用类型、城市、项目和流程基线,记录窗口和样本量
证据:`savings_baseline_generation.py` 与 baseline/insight 测试;维度、窗口、样本、算法版本、查询指纹和质量等级均冻结
- [ ] [CONCEPT: 费用分析与节省] 接入租户化供应商、合同价、数量和单位价格事实后生成供应商基线
证据要求:真实供应商主数据和合同/采购事实;当前明确返回 `supplier_dimension_unavailable`,不从模拟应付数据伪造。
- [x] [CONCEPT: 费用分析与节省] 实现预算预测、异常归因、重复小额浪费和只读政策模拟准备项。
证据Savings insight budget/analysis/attribution缺正式反事实时 `estimated_savings=None` 且不创建机会。
- [ ] [CONCEPT: 费用分析与节省] 接入真实供应商价格漂移分析。
证据要求:租户化合同价、数量、单位价和供应商证据;当前保持 coverage gap。
- [x] [CONCEPT: CFO 价值看板] 展示现金节省、工时价值、直通率、风险护栏、节省来源和责任人。
证据CFO API/analytics/dashboard现金、工时、风险和机会分卡缺数据使用 collecting/unavailable。
- [x] [CONCEPT: CFO 价值看板] 实现部门、项目、费用类型、供应商、城市、时间和单据下钻。
证据CFO filters、`cfoValueSourceLinks.js`、URL 状态恢复与前端回归;供应商无事实时显示缺口而非零。
- [x] [CONCEPT: CFO 价值看板] 每项节省支持查看基线、建议、执行、实际结果、确认人和证据。
证据:`CfoValueOpportunityDrawer.vue` 与 Savings detail projection无证据时不开放实际结果登记。
- [ ] [CONCEPT: 指标与验收] 生成客户月度 ROI 报告,现金节省与工时价值分开披露。
- [ ] [CONCEPT: 风险与开放问题] 建立节省归因复核、重复收益去重和客户财务签字流程。
## 9. P3 实现:商业化与规模复制
- [ ] [CONCEPT: 商业计量] 在 P0 最小租户隔离基础上新增套餐、配额、用量和增值模块授权模型。
- [ ] [CONCEPT: 商业计量] 按客户、模型、OCR、文档、分析任务和模块记录成本
- [ ] [CONCEPT: 商业计量] 建立客户贡献毛利、实施成本摊销和私有部署成本看板
- [x] [CONCEPT: 商业计量] 在 P0 最小租户隔离基础上新增套餐、订阅、账期、配额、用量和增值模块授权模型。
证据商业模型、0016/0019/0021/0024 迁移、管理/查询 API 与商业工作台
- [x] [CONCEPT: 商业计量] 按客户和权威 meter 记录模型、OCR、附件存储、连接器、分析任务与模块用量/成本
证据Orchestrator、Runtime Chat、OCR、附件、连接器 permit/预占/结算;已识别运行入口资源组合 63 项通过。
- [x] [CONCEPT: 商业计量] 建立客户 ROI、平台贡献毛利、成本明细和私有部署成本输入模型。
证据:`commercial_analytics.py`、商业价值/成本前端;客户价值、平台收入和内部成本分账,多币种不合并。
- [ ] [CONCEPT: 目标与非目标] 定义年度基础订阅、用量超额、智能风控、预算经营、价值洞察、企业集成和私有部署包。
- [ ] [CONCEPT: 目标与非目标] 对财务确认的已实现节省提供可选节省分成合同
- [x] [CONCEPT: 目标与非目标] 定价引擎仅允许对财务确认的已实现节省计算可选价值分享上限
证据:`commercial_pricing.py` 使用 confirmed value、成本下限和成功费封顶实际合同仍需客户签署。
- [ ] [CONCEPT: 连接器] 建立 ERP、HR、SSO、支付、电子档案和消息平台标准实施模板。
- [ ] [CONCEPT: 权限与安全] 完成删除传播、数据导出、审计增强和私有部署安全验收,不把基础租户隔离留到本阶段。
@@ -218,27 +255,32 @@
证据:容器内 `pytest -q server/tests/test_expense_case_service.py` 7 项通过;联合差旅主链路定向回归共 24 项通过。
- [x] [CONCEPT: 测试方案] 为 AI 新建申请直接提交补充统一事务回归,覆盖提交成功、事件失败回滚和仅保存草稿三条边界。
证据:容器内直接提交定向测试 3 项、申请提交回归 5 项、预算与 Expense Case 回归 13 项通过;相关 Python 文件 `ruff --select F,I` 通过。
- [ ] [CONCEPT: 测试方案] 为 Expense Case 状态机、事件账本、AI 决策、结果、记忆、自动化和节省服务补充单元测试。
当前进度:已补充预审决策确定性、嵌套无序集合、动态 findings 变化、申请/报销统一阻断、P8 全链路、真实预算前阻断、事件幂等/回滚、同用户名租户隔离、后台任务 tenant 伪造和 Case `approved_to_spend → claiming` 阶段测试;学习、自动化和节省仍待后续阶段
- [x] [CONCEPT: 测试方案] 为 Expense Case 状态机、事件账本、AI 决策、结果、记忆、自动化和节省服务补充单元测试。
证据Case/Event、AI learning/memory、`test_automation_eligibility.py`、Savings model/service/API/E2E 与全量后端分片均通过
- [x] [CONCEPT: 测试方案] 为服务端会话、管理员保护和 Bootstrap 重配置补充首批安全回归测试。
证据:`test_auth_session_endpoints.py``test_auth_service.py``test_bootstrap_security.py`;容器定向测试覆盖 token 摘要、伪造身份头、过期/撤销、登出原子收尾、业务经理越权和初始化后匿名重配置拒绝。
- [ ] [CONCEPT: 测试方案] 为租户隔离、跨租户访问、规则/制度发布和双人复核等剩余敏感动作补充安全回归测试。
- [x] [CONCEPT: 测试方案] 为租户隔离、跨租户访问、规则/制度发布和双人复核等敏感动作补充安全回归测试。
证据Tenant Identity、Agent Asset、Knowledge、Ontology/Employee、Hermes/Report、Steward、Finance Dashboard、Approval、Release Review 与 ONLYOFFICE 安全测试通过。
- [x] [CONCEPT: 测试方案] 为 Expense Case GET 接口补充 owner、审批人、财务、管理员、无权限用户和整 Case 关联事件可见范围的 HTTP 权限测试。
证据:`test_expense_case_endpoints.py` 容器内 8 项通过,覆盖跨租户、无 Case、申请与报销关联摘要以及内部字段递归过滤。
- [ ] [CONCEPT: 测试方案] 为 Alembic baseline、升级、旧数据迁移和回滚边界补充 Postgres 集成测试。
- [x] [CONCEPT: 测试方案] 为 Alembic baseline、升级、旧数据迁移和回滚边界补充 PostgreSQL 集成测试。
证据fresh PostgreSQL 最终总探针 `87 passed / 0 skipped / 0 failed`,覆盖 62 项迁移、完整降级/再升级、旧结构迁移、复合租户约束、append-only 和有事实回滚保护head 为 0028。
- [x] [CONCEPT: 测试方案] 为当前 migration-owned schema 切片补充一次性 PostgreSQL 集成测试和危险 URL 防误连门禁。
证据:`test_alembic_migrations.py` 默认无显式 URL 时跳过,主机和库名必须带 disposable 标记;当前 0012 Head 在 tmpfs PostgreSQL 17 中通过完整迁移验证,覆盖空库升级、重复升级、审批动作账本、风险处置复合租户约束、只追加事件触发器、不可变响应快照及有数据降级拒绝、组织 active 脏数据升级前拒绝、版本化 few-shot 无损降级拒绝、外键级联、base 降级、legacy 哨兵保留、漂移拒绝和再次升级;临时容器自动清理,持久化开发库未被修改。完整 legacy baseline 仍保留在上一条未完成项中。
- [x] [CONCEPT: 测试方案] 验证审批动作幂等、任务编排、风险门禁、豁免决定权限和共同锁顺序。
证据:容器内本阶段后端回归 `122 passed, 4 skipped`、前端审批/单据中心/风险专项 `60 passed`、Ruff 与生产构建通过;一次性 PostgreSQL 17 空库迁移循环 `1 passed`、审批任务和风险并发 `3 passed`,证明同 request ID 并发只生成一个事件、陈旧版本只有一个胜者、风险重新打开与审批竞争时按 Claim 公共锁读取最新事实。
- [ ] [CONCEPT: 测试方案] 为连接器幂等、重试、回执、失败恢复、重复付款和对账补充测试。
- [ ] [CONCEPT: 测试方案] 跑通申请 → 票据 → 报销 → 预审 → 审批 → 付款 → 入账 → 归档端到端
当前进度:申请批准 → 自动报销草稿 → 票据归集 → 预审 → 报销提交已在同一 Case 中跑通付款回执、ERP 入账和对账仍未接入
- [x] [CONCEPT: 测试方案] 为连接器幂等、重试、回执、失败恢复、重复付款和对账补充测试。
证据:连接器 service/endpoint/config/operational/concurrency 测试覆盖签名、防重放、冲突、错配、失败、ERP、退款和非生产隔离
- [x] [CONCEPT: 测试方案] 跑通申请 → 票据 → 报销 → 预审 → 审批 → 付款 → 入账 → 归档端到端
证据:`test_expense_financial_value_chain_e2e.py` 使用测试密钥自签 production-mode 事件,覆盖 HMAC、ERP posted、独立财务确认、Savings、商业价值和退款冲回契约不声明真实 provider 或真实现金。
- [x] [CONCEPT: 测试方案] 跑通首个个人出行方式切片的 AI 建议 → 用户修改 → 工作流结果 → 记忆激活 → 下次建议变化闭环。
证据:容器内记忆、预览决策、迁移与所有权组合回归 56 项通过、1 项条件跳过;一次性 PostgreSQL 迁移循环 1 项通过前端申请快速预览、个人记忆、Steward 与会话恢复组合 83 项通过Vite 生产构建通过Python Ruff F/I 与 `git diff --check` 通过。
- [x] [CONCEPT: 测试方案] 跑通首个行为采集切片AI 申请预填 → 用户接受/显式修改 → 草稿或提交结果同事务落账。
证据:`test_user_agent_application_draft_events.py``test_reimbursement_endpoints.py``expense-application-decision-feedback.test.mjs``expense-application-fast-preview.test.mjs`;覆盖可信入口、模板/详情排除、隐私指纹、同事务事件关联、日期联动及异步乱序响应。容器内学习账本与迁移所有权定向 29 项、一次性 PostgreSQL 迁移 4 项和前端关键场景 5 项通过。
- [ ] [CONCEPT: 测试方案] 跑通风险反馈 → few-shot → golden case → Canary → 回滚闭环。
- [ ] [CONCEPT: 测试方案] 跑通节省机会 → 执行 → 实现 → 财务确认 → ROI 看板闭环
- [x] [CONCEPT: 测试方案] 跑通风险反馈 → few-shot → golden case → Canary → 回滚工程闭环。
证据风险处置学习、Golden evaluator、发布 runtime/telemetry/review/recall/monitor 与 PostgreSQL 并发测试;低 precision 和失败路径恢复 stable
- [x] [CONCEPT: 测试方案] 跑通节省机会 → 执行 → 实现 → 财务确认 → ROI 看板闭环。
证据:`test_savings_value_e2e.py``test_expense_financial_value_chain_e2e.py` 和 CFO analytics/frontend 测试。
- [x] [CONCEPT: 测试方案] 为已有 Expense Case 事件时间线补充视图模型、404 降级、详情页接入及相关响应式回归,并完成前端生产构建。
证据:容器内 `node --test` 定向执行 99 项通过;`npm --prefix web run build` 通过。一次性克隆迁移库上的隔离后端已完成真实登录、身份读取和旧单时间线 200 联调;持久开发库仍未迁移,因此日常本地页面仍保持兼容提示。
- [x] [CONCEPT: 测试方案] 为 AI 申请草稿事件补充事务失败回滚、同快照幂等、同 run 多版本留痕和 Steward 重放回归。
@@ -250,8 +292,10 @@
证据:容器内新增决策安全用例 9 项、旧快速保存/提交 4 项、申请学习账本 9 项、迁移与所有权 26 项通过且条件型 PostgreSQL 用例 1 项跳过;一次性 PostgreSQL 17 迁移 4 项、前端定向 3 组及 Vite 生产构建已通过。临时 PostgreSQL 已清理,持久开发库 8 张 migration-owned 表数量仍为 0。
- [x] [CONCEPT: 测试方案] 验证小财管家、Steward、通用 Orchestrator 的统一预览闭环、认证绑定、fail-closed、稳定重试和跨刷新恢复。
证据:容器内后端组合回归 50 项通过覆盖服务端预览、Steward 动作/图运行、跨租户 checkpoint、Orchestrator 匿名/普通用户/管理员来源授权及决策消费Python Ruff F/I 通过;前端结构化动作、会话恢复、工作台路由、富确认和 `ai-application-preview-actions` 共 18 项通过Vite 生产构建通过。共享规则工作簿相关套件按容器内串行执行,避免并行读取正在变动的 XLSX 产生非业务性 ZIP 竞争。
- [ ] [CONCEPT: 测试方案] 所有后端、集成和迁移测试在当前主应用容器内执行,单条命令最大超时 60s。
- [ ] [CONCEPT: 指标与验收] 记录测试、lint、typecheck、构建、端到端和未覆盖风险证据
- [x] [CONCEPT: 测试方案] 所有后端、集成和迁移测试在当前主应用容器内执行,单条命令最大超时 60s。
证据176 个后端测试文件按有界分片或专项执行PostgreSQL 探针由应用容器运行,未在宿主机安装替代 venv
- [x] [CONCEPT: 指标与验收] 记录测试、lint、typecheck、构建、端到端和未覆盖风险证据。
证据后端分片与费用主服务通过PostgreSQL `87/0/0`Web `815/0` 与 Vite buildMobile lint/typecheck新增 Python Ruff、compileall、受门禁核心类/组件 800 行检查和 `git diff --check`。未覆盖的生产/试点项保留在第 11-12 节。
## 11. 分阶段试点与价值验证
@@ -266,5 +310,7 @@
- [ ] [CONCEPT: 指标与验收] 将试点实际基线替换方向性目标,冻结正式验收阈值。
- [ ] [CONCEPT: 风险与开放问题] 更新目标客户、试点场景、支付边界、连接器范围和自动化授权结论。
- [ ] [CONCEPT: 本轮实现记录] 每个阶段完成后补充实现文件、迁移、接口、测试和真实页面证据。
- [ ] [CONCEPT: 功能一句话] 确认最终实现持续服务于“不用填表、少被退回、真正省钱”的核心结果。
- [x] [CONCEPT: 本轮实现记录] 每个工程阶段完成后补充实现文件、迁移、接口和容器测试证据。
证据:本 CONCEPT/TODO、2026-07-16/17 分功能文档与 `engineering-closure-and-production-readiness` 总验收文档。
- [x] [CONCEPT: 功能一句话] 确认最终工程实现持续服务于“不用填表、少被退回、真正省钱”的核心结果。
证据零录入票据、服务端预审、审批例外、可信学习、Savings/CFO 与商业计量形成同一费用闭环;真实成效仍由第 11 节企业试点验证。

View File

@@ -0,0 +1,9 @@
## 修复记录
- 18:14记录 bug 修复Agent Run 详情绕过财务看板权限暴露跨租户快照与工具响应。
- Git 提交检查18:13 执行 `git fetch --all --prune``HEAD..origin/main` 无新提交,本地比 `origin/main` ahead 17 个提交,范围为 `661990b2 feat(expenses): add transactional expense case events``242d68c3 feat(approval): add task workflow and waiver decisions`,本次未合并或改写这些提交。
- 修改:新增 `agent_run_access_policy.py`,识别 `finance_dashboard_snapshot` 运行记录并同时核对 route/ontology 中的 `tenant_id``data_scope` 及当前用户财务角色;两份范围标签缺失、不一致、跨租户或与当前数据范围不符时一律 fail-closed。
- 修改:`agent_runs.py` 在列表返回前过滤无权查看的财务快照,在详情返回 `snapshot_payload`、工具请求和工具响应前执行同一访问策略;跨租户(包括其他租户 admin按 404 处理,同租户普通用户按 403 处理,只有同租户 `finance``executive` 或 admin 能读取完整快照。
- 操作:在 `test_finance_dashboard_tenant_security.py` 构造 tenant-a、tenant-b、无租户旧快照和损坏 data scope 快照,覆盖列表、详情、普通用户、财务用户和跨租户管理员路径;所有命令均在 `local-x-financial-linux` 容器执行。
- 验证Ruff 检查与格式检查通过财务看板、Agent Run 服务和 Ontology 端点定向回归共 17 个测试通过,证明合法同租户详情仍可读取,跨租户载荷、无范围旧记录和损坏范围记录均不可见。
- 影响:已登录用户不能再通过猜测或复用 `run_id` 绕过财务看板领域权限读取其他租户的报销金额快照和工具调用明细,列表入口也不会泄露这些快照的摘要记录。

View File

@@ -0,0 +1,8 @@
## 修复记录
- 22:33记录 bug 修复Agent Run 轻量列表遗漏语义解析结果。
- Git 提交检查:已执行 `git fetch --all --prune``HEAD..@{u}` 为空,未发现 upstream 新提交;本地 `main` ahead 17。审批链相关提交为 `242d68c3``28b834ed``4940ebc4`AI 报销学习与申请链相关提交为 `ee88a36b``6bdf65bc``ae3f02c3``54754b55``211f85d9``5b246307``a662cfe6`,费用事件与事务链相关提交为 `5ed34c2b``1347366b``22669a90``a616b30c``661990b2`,另有迁移安全 `11275e4b` 与会话鉴权 `653eda05`;这些均为当前任务开始前已有的本地提交,本次没有改写或合并。
- 修改:在 `agent_run.py` 的轻量仓储查询中按列表已经筛选出的 run_id 批量读取每个 Run 的首条语义解析;在 `agent_runs.py` 中恢复 `AgentRunRead.semantic_parse` 的既有列表契约,同时保留工具调用和大 JSON 字段的轻量预览策略。
- 操作:先在容器复现 seeded trace 用例失败,再核对 foundation seed、详情序列化和前端消费字段没有修改 seed fixture 来掩盖列表序列化断层。
- 验证:容器内 `test_agent_runs_service.py``test_agent_run_tenant_security.py``test_agent_trace_service.py` 与 OnlyOffice 定向测试共 11 项通过;`test_agent_asset_service.py` 全部 28 项通过;相关实现文件 Ruff 与 `git diff --check` 通过。
- 影响Agent Run 列表重新携带真实语义解析摘要seeded trace、运行轨迹界面和依赖 `semantic_parse` 的流程可继续使用;补充查询仅以租户过滤后的 run_id 为输入,不扩大数据作用域。

View File

@@ -0,0 +1,11 @@
## 修复记录
- 22:10记录 bug 修复Agent Run 普通日志跨租户读取与统计泄露。
- Git 提交检查22:10 执行 `git fetch --all --prune``HEAD..origin/main` 无新提交,本地比 `origin/main` ahead 17 个提交,范围为 `661990b2 feat(expenses): add transactional expense case events``242d68c3 feat(approval): add task workflow and waiver decisions`,本次未合并或改写这些既有提交。
- 修改:`agent_run.py` repository 将 `route_json.tenant_id``ontology_json.tenant_id` 的双重一致性条件下推到 SQL在排序和 `limit` 前完成租户过滤;任一标记缺失、空作用域或两处标记冲突的记录均 fail-closed详情也在数据库查询阶段按租户收窄。
- 修改:`agent_runs.py` service 新增显式的租户级列表、统计和详情入口,保留带注释的受信任内部跨作用域入口;`agent_run_access_policy.py` 增加返回前二次租户校验,并把财务快照的角色与 `data_scope` 门禁同步下推,防止不可见快照挤占列表和统计窗口。
- 修改Agent Run API 的列表、统计和详情统一使用当前认证租户;普通 run 的跨租户详情、无作用域旧记录和冲突标记记录均返回 404跨租户管理员也没有旁路工具 `request_json`/`response_json`、语义 `raw_query` 与错误统计不会跨租户暴露。
- 修改:`create_run` 支持显式 `tenant_id` 并同时写入 route/ontology后续整体更新或 route 合并会保留已验证的双重标记发现调用方已有冲突标记时拒绝写入Orchestrator、Ontology、知识同步和财务快照的认证/租户感知创建路径已传入可信 tenant知识同步的活动任务复用也改为租户内查询。
- 操作:新增 `test_agent_run_tenant_security.py`,构造 tenant-a、tenant-b、无作用域、冲突作用域和同租户财务快照覆盖 limit 前过滤、列表、统计、详情、跨租户 admin 及敏感载荷反向断言;全部命令均在 `local-x-financial-linux` 容器内以 60 秒超时执行。
- 验证Agent Run、财务快照、Ontology 端点、Orchestrator 认证和知识服务定向回归 31 个测试通过;核心变更文件 Ruff、`git diff --check`、PostgreSQL 方言 SQL 编译与代码行数检查通过,最大核心文件 `agent_runs.py` 为 760 行,低于 800 行硬上限。额外 Ontology 全文件回归共 82 个通过、4 个既有业务信号识别用例失败,失败堆栈位于未由本次改动触碰的 `_has_supported_business_signal` 判定。
- 影响Agent Run 日志现在以认证租户为强边界,其他租户、历史无归属记录及损坏作用域记录不再进入列表、统计或详情响应,同时保留内部后台任务按明确可信入口读取的兼容性。

View File

@@ -0,0 +1,8 @@
## 修复记录
- 19:19修复 AI 报销申请预检的阻断提示与既有交互文案契约不一致问题。
- Git 提交检查:已执行 `git fetch --all --prune``HEAD..origin/main` 无新提交;本地相对 upstream ahead 17 个既有提交,依次为 `242d68c3``28b834ed``4940ebc4``ee88a36b``6bdf65bc``ae3f02c3``54754b55``211f85d9``5b246307``a662cfe6``5ed34c2b``11275e4b``1347366b``22669a90``a616b30c``653eda05``661990b2`;本次未合并、覆盖或改写共享工作树中的其他变更。
- 修改:`aiApplicationPrecheckModel.js` 将受限状态和证据快照提示统一为“请先检查”“请先核对”,恢复动作前置关系并与页面测试契约一致。
- 操作:检查在 `local-x-financial-linux` 容器内执行,未修改财务规则 XLSX。
- 验证AI 申请预检模型定向前端回归 `4 passed`
- 影响:用户在提交前能清楚理解必须先完成的检查动作,避免因提示语义弱化而误以为可直接继续。

View File

@@ -0,0 +1,9 @@
## 修复记录
- 19:01修复一个 AI 决策关联多条工作流结果后,原申请动作幂等重放可能命中多行的问题。
- Git 提交检查:已执行 `git fetch --all --prune``HEAD..origin/main` 无新提交;本地相对 upstream ahead 17 个既有提交,从 `661990b2 feat(expenses): add transactional expense case events``242d68c3 feat(approval): add task workflow and waiver decisions`,其中包含认证、费用 Case、AI 反馈/记忆、迁移安全、预审、风险处置和审批任务能力;本次未拉取、合并或改写这些历史。
- 修改:`ExpenseApplicationLearningService._find_existing()` 不再按 `decision_id` 使用可返回多行的无序 scalar 查询,而是根据原始动作的租户、幂等键和稳定 UUID 精确取回首次 Feedback/Outcome并二次校验租户与 Decision 关联。
- 修改:工作流结果桥接只关联事件发生前最新的已提交 AI Decision原动作重放只回填同 correlation 的预审结论,不会把后续重新提交的退回或审批结果污染到旧决策。
- 操作:在 `local-x-financial-linux` 容器中使用 `/tmp/x-financial-server-venv` 运行 Ruff 和 AI 预览/工作流学习回归;未在宿主机执行 Python 或 pytest。
- 验证:`test_expense_application_preview_decisions.py``test_expense_workflow_learning.py` 组合回归 `17 passed`;新用例验证第二次提交后的退回仅关联最新 Decision多 Outcome/Feedback 不影响原请求重放。
- 影响:申请动作重试不再因后续付款、退回或审计结果增多而出现多行异常,也不会将新一轮流程结果错标到历史 AI 建议上。

View File

@@ -0,0 +1,9 @@
## 修复记录
- 19:09修复 CFO 价值看板允许发起无证据手工实际结果、与后端证据门禁冲突的问题。
- Git 提交检查:已执行 `git fetch --all --prune``HEAD..origin/main` 无新提交;本地相对 upstream ahead 17 个既有提交,依次为 `242d68c3``28b834ed``4940ebc4``ee88a36b``6bdf65bc``ae3f02c3``54754b55``211f85d9``5b246307``a662cfe6``5ed34c2b``11275e4b``1347366b``22669a90``a616b30c``653eda05``661990b2`;本次未合并、覆盖或改写共享工作树中的其他变更。
- 修改:`CfoValueOpportunityDrawer.vue` 过滤 `record_realization` 空证据入口并明确提示实际结果必须来自平台付款事件或可追溯的支付、银行、ERP 凭证;`CfoValueActionDialog.vue` 删除不可履行证据要求的手工金额表单;`useCfoValueDashboard.js` 增加防御性拒绝,避免旁路重新提交空证据。
- 修改:`cfo-value-dashboard.test.mjs` 将序列化样例改为带外部凭证的请求,并增加前端不暴露空证据登记入口的回归断言。
- 操作:全部检查均在 `local-x-financial-linux` 容器内执行,未修改财务规则 XLSX 或历史开发文档。
- 验证CFO/应用壳/财务看板组合前端回归 `22 passed`Vite 生产构建成功(`2227 modules transformed``built in 5.08s``git diff --check -- web` 通过。
- 影响:用户不会再遇到“页面允许登记、后端必然拒绝”的假动作;在真实连接器或凭证上传入口接入前,现金节省仍只能依靠可信付款事件落账并由独立财务确认。

View File

@@ -0,0 +1,8 @@
## 修复记录
- 21:40记录 bug 修复:定价毛利率边界舍入越过后端上限。
- Git 提交检查:已执行 `git fetch --all --prune``HEAD..origin/main` 无新增提交;本地 `main` ahead 17 个既有提交,分别为 `242d68c3` 审批任务流、`28b834ed` 不可变动作回放、`4940ebc4` 风险处置、`ee88a36b` 分层费用学习、`6bdf65bc` 权威预审、`ae3f02c3` 零录入票据关联、`54754b55` 个人申请记忆、`211f85d9` 统一申请流、`5b246307` 申请预览决策、`a662cfe6` 申请反馈台账、`5ed34c2b` 历史申请回填、`11275e4b` 迁移归属校验、`1347366b` 费用时间线与草稿事件、`22669a90` 统一费用事件时间线、`a616b30c` AI 申请事务、`653eda05` bearer 会话和 `661990b2` 费用案例事件;均非本次修复产生。
- 修改:`commercialWorkspaceModel.js` 先把百分比量化为后端允许的六位小数,再校验目标贡献毛利率严格小于 `0.95``CommercialPricingScenarioPanel.vue` 同步把可输入上限收紧到 `94.9999%`,并在模型测试中覆盖舍入临界值。
- 操作:在容器 `local-x-financial-linux` 内执行边界探针、商业模型与组件定向测试、全量前端测试、code-size 门禁和 Vite production build。
- 验证:临界探针确认 `94.9999%` 序列化为 `0.949999``94.999999%` 在请求前被拒绝;商业定向测试 35 项通过,修复后的模型与组件回归 28 项通过,全量前端 802 项通过code-size 通过Vite 完成 2246 个模块转换。
- 影响:管理员无法再提交一个表面小于 95%、但六位小数量化后等于后端禁值 `0.95` 的场景,避免无意义的 422 往返,同时不改变合法目标毛利率的精度。

View File

@@ -0,0 +1,8 @@
## 修复记录
- 21:09记录 bug 修复:未配置硬配额被前端误显示为剩余 0。
- Git 提交检查:已执行 `git fetch --all --prune``HEAD..origin/main` 无新增提交;本地 `main` ahead 17 个既有提交,分别为 `242d68c3` 审批任务流、`28b834ed` 不可变动作回放、`4940ebc4` 风险处置、`ee88a36b` 分层费用学习、`6bdf65bc` 权威预审、`ae3f02c3` 零录入票据关联、`54754b55` 个人申请记忆、`211f85d9` 统一申请流、`5b246307` 申请预览决策、`a662cfe6` 申请反馈台账、`5ed34c2b` 历史申请回填、`11275e4b` 迁移归属校验、`1347366b` 费用时间线与草稿事件、`22669a90` 统一费用事件时间线、`a616b30c` AI 申请事务、`653eda05` bearer 会话和 `661990b2` 费用案例事件;均非本次修复产生。
- 修改:`commercialWorkspaceModel.js` 的数值规范化显式把 `null``undefined` 和空字符串保留为“未知”,不再依赖 JavaScript 的 `Number(null) === 0` 隐式转换。
- 操作:在容器 `local-x-financial-linux` 内运行商业服务、模型和 Vue 组件定向 Node 测试。
- 验证:商业服务、模型和组件定向测试共 28 项通过;全量 `web/tests/*.test.mjs` 共 795 项通过;`code-size-limits` 通过Vite production build 完成 2233 个模块转换。权益测试同时覆盖不限量、未配置硬上限和失败关闭状态。
- 影响:真实硬配额为 0 时仍显示 0未配置硬配额时显示“未配置硬上限”避免管理员把未知配置误判为已耗尽配额。

View File

@@ -0,0 +1,11 @@
## 修复记录
- 19:37修复商业分析 ROI 口径、商业合同可见角色和已消费权益历史可变三类问题。
- Git 提交检查:已执行 `git fetch --all --prune``HEAD..origin/main` 无新提交;本地相对 upstream ahead 17 个既有提交,依次为 `242d68c3``28b834ed``4940ebc4``ee88a36b``6bdf65bc``ae3f02c3``54754b55``211f85d9``5b246307``a662cfe6``5ed34c2b``11275e4b``1347366b``22669a90``a616b30c``653eda05``661990b2`;本次未合并、覆盖或改写共享工作树中的其他变更。
- 修改:`commercial_analytics.py` 将客户现金 ROI 统一为“(财务确认现金节省-客户合同收费代理值)/ 客户合同收费代理值”,并把 ratio numerator 改为净收益;禁止截止时间早于窗口开始及无时区分析时间。
- 修改:`commercial_access_policy.py` 移除直属经理对套餐、订阅和配额的默认读取权限仅保留财务、executive 和平台管理员。
- 修改:`commercial_admin.py` 在权益已有用量后禁止回改配额、计价配置和有效期,只允许暂停/恢复;完全相同的 PUT 不再无意义增加版本。
- 修改:`commercial.py` 强制套餐、订阅、权益、用量和成本事实时间显式带时区,避免跨时区账期歧义。
- 操作:全部检查均在 `local-x-financial-linux` 容器内执行,未修改财务规则 XLSX 或历史开发文档。
- 验证:商业模型、服务与 HTTP 定向回归 `10 passed`;相关 Ruff 检查通过。
- 影响:商业 ROI 与既定合同口径一致,普通直属经理不能查看敏感商业合同,历史用量不会因事后回改权益配置而改变含义。

View File

@@ -0,0 +1,23 @@
## 修复记录
- 21:56记录 bug 修复:非成功 Agent 工具调用可能被商业账本误计量。
- Git 提交检查:已执行 `git fetch --all --prune``HEAD..origin/main` 无新增提交;本地 `main` ahead 17 个既有提交,分别为 `242d68c3` 审批任务流、`28b834ed` 不可变动作回放、`4940ebc4` 风险处置、`ee88a36b` 分层费用学习、`6bdf65bc` 权威预审、`ae3f02c3` 零录入票据关联、`54754b55` 个人申请记忆、`211f85d9` 统一申请流、`5b246307` 申请预览决策、`a662cfe6` 申请反馈台账、`5ed34c2b` 历史申请回填、`11275e4b` 迁移归属校验、`1347366b` 费用时间线与草稿事件、`22669a90` 统一费用事件时间线、`a616b30c` AI 申请事务、`653eda05` bearer 会话和 `661990b2` 费用案例事件;均非本次修复产生。
- 修改:`commercial_runtime_policy.py` 把工具调用状态明确分为可计量、采集中和不可计量;`commercial_runtime_metering.py` 只允许 `succeeded/success/ok/completed` 的真实终态调用进入用量与成本账本,`running/pending/queued` 保留待终态补偿语义,`blocked/failed/skipped/cancelled` 不再产生商业事实。同步用 `commercial_runtime_bridge.py` 将未配置租户兼容放行、显式配置后的执行前配额门禁、成功调用后的幂等追加和故障补偿接入真实 `AgentToolCall` 生命周期。
- 操作:在 `AgentRunService` 的创建与终态更新后触发桥接计量;在中央工具执行器调用真实 executor 前执行权益预检;将工具执行职责拆到 `orchestrator_tool_execution.py`,让核心编排文件回落到 762 行;所有命令均在 `local-x-financial-linux` 容器内运行并设置 60 秒超时。
- 验证:商业运行计量 17 项通过,商业模型/服务/接口/运行计量合计 28 项通过AgentRun 与 Orchestrator 鉴权相关回归合计 34 项通过;范围内 Ruff、`compileall`、diff 检查和类级 800 行检查通过。全库 code-size 门禁仍被本次范围外的 `RiskRuleGenerationService` 817 行阻断,未在本修复中改动该类。
- 影响:未成功完成的工具调用不会再消耗客户配额或形成内部成本;未配置商业计量的既有租户继续执行;已配置租户在执行前受配额约束。计量系统故障时真实工具调用记录保持不变,返回 `requires_reconciliation` 并可按同一工具调用 ID 幂等重试,避免把计量失败伪装成已入账。
- 22:31修复执行前只读配额在并发下可超卖、直接调用绕过预占及补偿误判问题。
- Git 提交检查:再次执行 `git fetch --all --prune``git status -sb``git log HEAD..@{u}``git log @{u}..HEAD`;上游无新增提交,本地仍 ahead 17 个既有提交:`242d68c3``28b834ed``4940ebc4``ee88a36b``6bdf65bc``ae3f02c3``54754b55``211f85d9``5b246307``a662cfe6``5ed34c2b``11275e4b``1347366b``22669a90``a616b30c``653eda05``661990b2`,均非本次修复产生。
- 修改:新增 `commercial_runtime_reservations` 运营占位及 `0019` 迁移;在订阅、权益行锁内按“已用量 + 有效预占 + 本次预占”原子校验硬配额。中央 Orchestrator 先生成稳定 tool call ID 并预占,再执行真实工具;成功且真实量不超过预占才追加用量/成本并提交,失败或阻断释放,变量基准没有执行器 hard max 时失败关闭。
- 修改:新增持久 `reconciliation_required` 和过期补偿器。缺少执行前预占的旧直接调用不再静默补写正常用量,而是冻结对应容量;补偿器仅在真实工具成功、失败或运行已终止且无调用时结算/释放,运行中或来源不确定继续保留。修复历史过期订阅/权益错误启用门禁、无租户旧运行错误进入补偿、相同预占重试被自身占位判定为额度耗尽三个边界。
- 操作:拆出运行时成本解析、周期键、标量校验、预占和补偿模块,相关核心类均低于 800 行更新模型注册、迁移所有权、前置检查、商业配额投影、AgentRun/中央工具调用点和迁移/并发/补偿测试。所有 Python、Alembic 和 PostgreSQL 验证均在 `local-x-financial-linux` 容器内以 60 秒超时执行。
- 验证:运行时定向 25 项通过商业、Agent、权限、迁移组合 140 项通过;一次性 PostgreSQL 17 商业并发 4 项通过,两个线程竞争一份硬配额时只有一个预占成功;全新 PostgreSQL 0019→0020 完整 Alembic 升降级循环 1 项通过;范围内 Ruff、`compileall``git diff --check` 和相关类 800 行检查通过。Orchestrator review 套件保持既有 5 项本体/申请流失败、11 项通过;全库 code-size 仍仅被范围外 `RiskRuleGenerationService` 817 行阻断。
- 影响:商业硬配额从“执行前提示”升级为数据库原子 permit真实工具并发不能再穿透上限成功事实可幂等结算计量或成本故障保留可补偿状态且不伪造成功未配置 runtime meter 的路径继续兼容,历史失效配置不会误拦截用户。
- 22:39修复“用量已提交、成本写入失败”缺少持久补偿身份的问题。
- Git 提交检查:再次执行 `git fetch --all --prune``git status -sb``git log HEAD..@{u}``git log @{u}..HEAD`;上游仍无新增提交,本地仍 ahead 17 个既有提交:`242d68c3``28b834ed``4940ebc4``ee88a36b``6bdf65bc``ae3f02c3``54754b55``211f85d9``5b246307``a662cfe6``5ed34c2b``11275e4b``1347366b``22669a90``a616b30c``653eda05``661990b2`,均非本次修复产生。
- 修改:为运行时预占增加 `committed_reconciliation_required` 状态。真实用量已经追加但内部成本失败时,保留真实数量、结算时间和失败原因并进入补偿队列;按同一 tool call 重试只幂等补写成本,成功后恢复 committed。该状态不再计入有效预占避免真实用量和冻结量重复消耗配额。
- 修改:直连调用发生后若当前唯一匹配合同已暂停,允许补偿记录绑定原订阅/权益快照并进入 `reconciliation_required`;正常执行前 reserve 仍严格要求 active/trialing 订阅和 active 权益,不放宽真实执行许可。
- 验证:容器内运行时定向 27 项、商业/Agent/权限/迁移组合 183 项通过,另 1 项因未显式配置外部迁移库跳过;全新 PostgreSQL 完整迁移循环 1 项、商业并发 4 项通过。成本故障用例验证 `used=1``reserved=0`,重试后用量仍为 1 且只新增 1 条成本;范围内 Ruff 通过。
- 影响:成本账本短暂故障不再只依赖日志发现,也不会让客户额度被重复扣减;暂停发生与工具终态竞态时,已发生业务仍有持久、租户隔离的补偿证据。

View File

@@ -0,0 +1,17 @@
## 修复记录
- 17:11记录 bug 修复:财务看板跨租户聚合、快照串租户复用与普通用户越权读取。
- Git 提交检查:执行 `git fetch --all --prune` 后未发现 `HEAD..origin/main` 新提交;本地比 `origin/main` ahead 17 个提交,最新为 `242d68c3 feat(approval): add task workflow and waiver decisions`,其余为 `28b834ed``661990b2` 的审批、费用闭环、AI 记忆、迁移安全和认证能力提交,本次没有合并或改写这些历史。
- 修改:`finance_dashboard.py` 通过 `ExpenseClaimTenantScopeMixin` 按 Expense Case Link 聚合当前租户报销单;缺少 `tenant_id` 的旧预算表仅允许 `default` 租户读取,其他租户返回带原因的明确空预算;新增 `finance_dashboard_scope.py` 统一声明报销与预算数据范围。
- 修改:`finance_dashboard_snapshot.py``tenant_id`、数据范围和完整时间参数纳入无歧义缓存键,并在 SQL 查询、Agent Run 路由及工具请求中同时校验租户和范围;`finance_dashboard_scheduler.py` 显式固定 `default` 系统租户,非默认租户不能调用默认定时快照入口。
- 修改:新增 `finance_dashboard_access_policy.py``analytics.py` 显式接收 `CurrentUserContext`,仅允许 `finance``executive` 或 admin 只读访问财务看板,普通用户和仅有 `budget_monitor` 角色的用户返回 403。
- 操作:所有检查均在 `local-x-financial-linux` 容器内执行;新增租户 A/B、default 历史单、旧预算、快照缓存与接口权限测试,没有触碰受保护的财务规则 XLSX 和历史开发文档。
- 验证Ruff 对本次 7 个 Python 模块及新增测试检查通过;`test_finance_dashboard_tenant_security.py``test_finance_dashboard_service.py` 共 8 个测试通过。补充运行财务报告与数字员工回归时 4 个测试通过、1 个既有财务周报用例失败,原因是用例将“当前时间减 2 天”的数据断言进“上一完整周”窗口,和本次租户过滤无关。
- 影响:财务看板不再读取其他租户的报销单或把 default 旧预算暴露给非默认租户;相同时间参数的租户快照不会互相命中,后台默认快照和前台读取权限也有了可审计的显式边界。
- 18:14补齐财务看板租户 fail-closed 边界与模块拆分验证。
- Git 提交检查18:13 再次执行 `git fetch --all --prune``HEAD..origin/main` 无新提交,本地仍比 `origin/main` ahead 17 个提交,范围为 `661990b2 feat(expenses): add transactional expense case events``242d68c3 feat(approval): add task workflow and waiver decisions`,未合并、改写或覆盖这些提交及工作区内其他智能体改动。
- 修改:`finance_dashboard_access_policy.py` 对空白租户上下文直接拒绝,避免异常认证上下文回落到 default将预算摘要、预算卡片和预算瓶颈投影提取到 `finance_dashboard_budget.py``finance_dashboard.py` 从 920 行降至 746 行,保持租户过滤和原 API 不变。
- 操作:只在 `local-x-financial-linux` 容器内执行 Ruff、财务看板服务与接口测试、Agent Run 服务回归和 Ontology 端点回归;未触碰财务规则 XLSX、Savings 迁移或前端文件。
- 验证Ruff 检查与格式检查通过财务看板、Agent Run 服务和 Ontology 端点定向回归共 17 个测试通过。另跑数字员工、系统看板和财务报告回归时 6 个通过、1 个既有周报窗口用例失败;该用例在周四写入“当前时间减 2 天”的单据,却断言它属于“上一完整周”,失败与本次改动无关。
- 影响:租户身份缺失时财务看板不再隐式读取 default 数据;预算展示职责被独立封装,后续继续扩展财务指标时不会把核心聚合模块推过项目 800 行硬上限。

View File

@@ -0,0 +1,11 @@
## 修复记录
- 22:29记录 bug 修复非生产财务回执会进入核心付款状态机连接器配置缺少版本化生命周期审计normalized payload 冗余保存完整单号。
- Git 提交检查:执行 `git fetch --all --prune` 后未发现 `HEAD..origin/main` 新提交;当前 `main` 相对 `origin/main` ahead 17范围为 `661990b2..242d68c3`包含认证、费用事件、AI 学习、审批任务/风险处置和迁移所有权等既有基础提交,本轮未合并或改写这些提交。
- 修改:`financial_connector_ingestion.py` 与新增 `financial_connector_simulation.py` 将 test/mock/staging 六类事件收口为 `simulation_only` 只读事实,禁止修改 Claim、对账、ERP、Business Event、归档和 Savings生产 origin 查询只接受同来源生产事实,不能引用模拟结算触发冲回。
- 修改:`financial_connector_config_lifecycle.py``financial_connector_config_audit.py`、配置 schema/API 和 `FinancialConnectorConfigEvent` 新增带 expected version、认证 actor、request ID、reason 的 activate/disable/rotate 状态机;新配置只能 disabled 创建,激活/轮换前解析服务端 `secret_ref` 并校验 HMAC 密钥强度,审计前后快照不保存密钥引用或明文。
- 修改:`20260716_0020_financial_connector_config_lifecycle.py` 基于 0019 增加配置 version、复合租户约束和 PostgreSQL append-only 审计 trigger受控移除历史 connector event normalized payload 中的完整 `claim_reference`,保留金额/币种、必要尾号和内容指纹,并把可能已有旧非生产副作用的响应标记为 `legacy_nonproduction_effect_unknown`,不伪装为新策略下的无副作用模拟事实。
- 修改:保留并验证 HMAC v2 对 tenant/provider/key version/timestamp/method/path 的绑定拒绝共享密钥跨来源重放ERP 回执按协议只要求 origin、Claim、金额和币种不再错误强制重复支付参考号空白密钥即使长度足够也按强度不足失败关闭。
- 操作:在独立一次性 PostgreSQL 17 容器完成空库升级到 0020、schema/约束/trigger 探针、并发激活单版本胜者、0019→0020 历史脱敏探针和完整升降级循环验证完成后删除临时容器。同步更新模型注册、迁移所有权、preflight、HEAD revision、迁移断言及连接器 CONCEPT/TODO。
- 验证:容器内连接器/配置/费用价值链/迁移组合回归 `140 passed, 1 skipped`PostgreSQL 连接器并发 `3 passed`;全新 PostgreSQL 完整迁移循环 `1 passed`,商业迁移 agent 在另一空库复跑同样 `1 passed`Ruff、全树 `git diff --check` 均通过,连接器核心文件最大 509 行,低于 800 行硬上限。
- 影响:模拟、测试和预发布环境现在可以安全演练完整事件协议而不会改账;只有 `production_verified` 且精确匹配的回执能够推进付款、ERP 与 Savings。管理员可以安全激活、停用和轮换密钥所有配置动作可追溯且不泄露密钥或完整单号。

View File

@@ -0,0 +1,9 @@
## 修复记录
- 19:01修复风险规则 Golden 评测的 FP/FN 统计颠倒、修订版本未进门禁以及异常/空用例默认放行问题。
- Git 提交检查:已执行 `git fetch --all --prune``HEAD..origin/main` 无新提交;本地相对 upstream ahead 17 个既有提交,依次为 `242d68c3``28b834ed``4940ebc4``ee88a36b``6bdf65bc``ae3f02c3``54754b55``211f85d9``5b246307``a662cfe6``5ed34c2b``11275e4b``1347366b``22669a90``a616b30c``653eda05``661990b2`;本次未合并、覆盖或改写共享工作树中的其他变更。
- 修改:`risk_rule_golden_evaluator.py` 将 false positive 改为“期望不命中但实际命中”false negative 改为“期望命中但实际未命中”Precision/Recall 分母恢复正确。
- 修改:初次发布和 revision 发布均强制执行 Golden 门禁;缺规则文档、缺 rule code、缺 active Golden case 或评测异常均 fail-closed。仅当 `GOLDEN_SET_GATE_ENABLED=false` 被显式配置时允许跳过,且仍写入 status=skipped 的 `AgentAssetTestRun`
- 操作:在 `local-x-financial-linux` 容器中运行 Golden、发布、修订和安全自动化定向回归并执行 Ruff未修改财务规则 XLSX。
- 验证Golden、release guard 和自动化资格组合回归 `29 passed`;并行定向发布/修订回归纳入总计 `62 passed`,评测异常、空用例和缺配置均留下 failed 记录并拦截发布。
- 影响误报不再被错计为漏报Precision/Recall 可用于可信的 Canary 与回滚判断;新规则和修订规则不能因评测器失败或没有黄金用例而静默上线。

View File

@@ -0,0 +1,8 @@
## 修复记录
- 22:33记录 bug 修复OnlyOffice 回调测试仍 patch 已拆分前的网络符号。
- Git 提交检查:已执行 `git fetch --all --prune``HEAD..@{u}` 为空,未发现 upstream 新提交;本地 `main` ahead 17。审批链相关提交为 `242d68c3``28b834ed``4940ebc4`AI 报销学习与申请链相关提交为 `ee88a36b``6bdf65bc``ae3f02c3``54754b55``211f85d9``5b246307``a662cfe6`,费用事件与事务链相关提交为 `5ed34c2b``1347366b``22669a90``a616b30c``661990b2`,另有迁移安全 `11275e4b` 与会话鉴权 `653eda05`;这些均为当前任务开始前已有的本地提交,本次没有改写或合并。
- 修改:把 `test_onlyoffice_callback_summary.py` 的网络 patch 目标切换到实际查找符号的 `agent_asset_onlyoffice` 模块,并删除对旧版本元数据方法及“回调自行生成 change_note”的过期假设测试现在验证下载内容、回调用户和 `onlyoffice` 来源被完整委托给统一上传流程。
- 操作:没有在 `agent_assets` 中恢复底层 `urlopen` 兼容导出,因为该符号不是公共 API且兼容别名也无法拦截拆分模块中的真实调用差异摘要仍由 `upload_rule_spreadsheet` 统一生成和审计,已有服务测试覆盖其工作表/单元格统计。
- 验证:容器内回调定向测试通过;包含该测试的 Agent Run/租户/轨迹组合共 11 项通过,`test_agent_asset_service.py` 全部 28 项通过;测试文件 Ruff 与 `git diff --check` 通过。
- 影响OnlyOffice 回调测试重新拦截真实网络边界,不会发出外部请求,也不会因内部模块拆分误报;生产回调与摘要生成职责保持不变。

View File

@@ -0,0 +1,12 @@
# 报销审批非参与者错误映射与陈旧测试契约
日期2026-07-16
文档路径document/development/2026-07-16/dev-logs/bugs/reimbursement-approval-task-access-error-mapping.md
## 修复记录
- 21:38记录 bug 修复报销审批非参与者错误映射与陈旧测试契约。bug-log:242d68c3
- Git 提交检查:已手工执行 `git fetch --all --prune`upstream `origin/main` 无本地尚未包含的新提交;当前分支 ahead 17 且工作区已有其他智能体和用户的未提交改动因此未自动合并或变基。ahead 包括审批任务与风险链 `242d68c3``28b834ed``4940ebc4`AI/费用学习与预审链 `ee88a36b``6bdf65bc``ae3f02c3``54754b55``211f85d9``5b246307``a662cfe6`Expense Case/时间线与事务链 `5ed34c2b``1347366b``22669a90``a616b30c``661990b2`,以及迁移安全 `11275e4b`、认证会话 `653eda05`
- 修改:`reimbursement_approval_actions.py` 将审批任务访问策略抛出的 `LookupError` 显式映射为 404避免非任务参与者通过报销审批/退回接口触发 500`test_expense_claim_service.py``test_reimbursement_endpoints.py` 同步到正式审批任务的资源隐藏与任务冲突契约,并增加申请人审批、退回均无状态和动作账本副作用的 HTTP 回归。
- 操作:完整读取 `agent-change-log` Skill沿 `ExpenseClaimActionProtocolMixin → ApprovalTaskLifecycleService → ApprovalTaskAccessPolicy` 诊断调用链;保留“非参与者不可读取任务”的权限语义,没有放宽审批人、管理员、申请人或租户边界;随后运行日志 helper 创建本记录并补齐实际证据。
- 验证:在 `local-x-financial-linux` 容器中,原失败用例与新增 HTTP 用例 2 项通过;审批任务、审批路由和报销接口组合 65 项通过;费用服务审批、退回和付款相关筛选回归 30 项通过;相关 Python 文件 Ruff F/I 检查通过。
- 影响:申请人或其他非任务参与者调用审批动作时稳定返回 404不再出现服务端 500也不会创建动作账本、审批事件或修改 Claim可见但不可操作的任务仍按既有策略返回 403状态/版本冲突继续返回 409。

View File

@@ -0,0 +1,27 @@
# AI 发布门禁全局资产越权与快照损坏静默放行
## 修复记录
- 19:48记录 AI 分阶段发布的租户越权与运行时完整性失效修复。
- Git 提交检查:执行 `git fetch --all --prune` 后未发现 `HEAD..origin/main` 上游新提交;本地 `main` 比上游 ahead 17最新为 `242d68c3 feat(approval): add task workflow and waiver decisions`其余为审批安全、AI 费用学习、报销预审、迁移安全与会话认证等既有检查点,本次未合并或改写这些提交。
- 修改:`agent_asset_release_guard.py` 为所有发布查询、启动、评测、晋级和回滚入口增加平台全局资产管理边界;未绑定租户的共享资产只允许平台管理员管理,租户级 manager 统一返回不可见。`agent_asset_releases.py` 与既有风险规则发布入口只从认证上下文传递平台管理员权限,不能由请求体或自报 actor 绕过。
- 修改:`expense_claim_risk_rule_loader.py` 在候选快照与稳定快照均无法通过 SHA-256 完整性校验时生成强制阻断信号,`expense_claim_platform_risk.py` 将该信号转换为 critical/block 风险,而不是把损坏规则当成“未命中”静默跳过。
- 操作:仅通过 `apply_patch` 修改源码和测试;保护现有财务规则 XLSX、历史未跟踪开发目录及其他智能体改动未执行提交、推送或数据库破坏操作。
- 验证:在 `local-x-financial-linux` 容器内运行 Ruff 定向检查通过;`test_agent_asset_release_guard.py``test_agent_asset_release_runtime.py` 共 12 项测试全部通过,新增覆盖租户 manager 无权管理全局发布资产、平台 admin 可管理,以及双快照损坏后报销自动流转被阻断。
- 影响:单租户管理员不再能影响所有企业共享的风险规则;受控发布元数据损坏时系统优先停流并提示平台恢复稳定版本,避免风险规则失效后继续自动审批。
- 19:53继续修复发布质量指标可由管理人员手工伪造的问题。
- Git 提交检查:再次执行 `git fetch --all --prune``HEAD..origin/main` 仍无上游新提交;本地仍 ahead 17提交范围与 19:48 检查一致,未自动合并、变基或覆盖共享工作区改动。
- 修改:新增 `agent_asset_release_monitor_auth.py`,评测请求必须使用至少 32 字节独立密钥对时间戳、租户、资产、release ID、当前阶段和规范化请求体摘要做 HMAC-SHA256 签名;仅允许 5 分钟时钟窗口并使用常量时间比较。`agent_asset_releases.py` 在写入评测记录前验证签名,密钥未配置返回 503缺失、过期或错误签名返回 401。
- 操作:保留服务层直接写入能力供同进程可信监控使用,但关闭普通 HTTP manager 仅凭自报数字写入“通过”证据的路径;签名绑定 release ID 和阶段,旧阶段请求不能在晋级后重复使用。
- 验证:容器内 Ruff 定向检查通过;发布 guard/runtime 共 12 项测试通过HTTP 用例新增未签名评测返回 401同时签名监控数据仍可触发 shadow→Canary→active 和指标越界自动回滚。
- 影响:人工管理权限与机器监控证据分离,发布门禁不再把未经认证的手工数字当成可信质量结果。
- 21:51补齐“签名合法但指标仍可伪造”的第二层真实性修复并接通真实证据自动回滚。
- Git 提交检查:执行 `git fetch --all --prune``git status -sb``git log HEAD..@{u}``git log @{u}..HEAD``origin/main` 无新提交,本地仍 ahead 17最新提交仍为 `242d68c3 feat(approval): add task workflow and waiver decisions`,其余 ahead 提交为既有审批、AI 费用学习、报销预审、迁移安全和会话认证检查点。本次未合并、变基、提交或推送,也未触碰受保护财务规则 XLSX。
- 修改Release Monitor HTTP 契约改为禁止额外字段的空触发请求HMAC 继续绑定 tenant/asset/release/stage`total``precision` 等质量数字只能由服务端查询 append-only observation/label 聚合;即使签名正确,携带伪造汇总字段也返回 422。
- 修改:真实 shadow/Canary/active manifest 执行追加脱敏 observation类型化 `RiskDispositionEvent` 追加可信标签并即时触发 Guard相同 release 聚合快照复用同一测试运行,低 precision 或结构化运行失败自动回滚 stable。即时监控失败由租户周期调度补偿`collecting` 不写虚假 passed。
- 修改:新增 `0018` 两张发布遥测表、复合租户/release 外键、幂等唯一约束和 PostgreSQL append-only 触发器,并登记主 metadata、迁移所有权与启动前置检查将风险处置发布同步拆到独立服务使核心 `risk_dispositions.py` 保持 800 行以内。
- 操作:全部源码与文档通过 `apply_patch` 修改;用独立 `pgvector/pgvector:pg17` 一次性数据库验证完整迁移链及数据库约束,未连接或变更生产数据库。
- 验证:容器内发布 Guard/Runtime/Telemetry/Monitor/风险处置组合 44 项通过调度器、Monitor 与风险处置定向 24 项通过;迁移/schema owner 静态回归 112 项、启动前置检查 77 项、一次性 PostgreSQL 完整迁移循环 1 项通过PostgreSQL savings/commercial/financial connector/approval 并发探针共 14 项通过。Ruff 定向检查与 `git diff --check` 通过(文档回填后仍需最终全量复验)。
- 影响:发布质量门禁不再信任“会签名的调用方”提交的汇总数字,而是由数据库真实执行与可信人工结论生成;遥测暂时失败只会阻止晋级,不会撤销已成功人工处置或关闭现有 stable 保护。

View File

@@ -0,0 +1,9 @@
## 修复记录
- 19:19修复商业计量迁移上线后标准调整 Savings 回填脚本因精确锁定旧版本而错误拒绝执行的问题。
- Git 提交检查:已执行 `git fetch --all --prune``HEAD..origin/main` 无新提交;本地相对 upstream ahead 17 个既有提交,依次为 `242d68c3``28b834ed``4940ebc4``ee88a36b``6bdf65bc``ae3f02c3``54754b55``211f85d9``5b246307``a662cfe6``5ed34c2b``11275e4b``1347366b``22669a90``a616b30c``653eda05``661990b2`;本次未合并、覆盖或改写共享工作树中的其他变更。
- 修改:`backfill_standard_adjustment_savings.py` 不再要求当前 revision 必须精确等于 `20260716_0015`,改为遍历 Alembic `down_revision` 祖先链,只有当前迁移确实包含 Savings 数据契约时才允许回填。
- 修改:`test_standard_adjustment_savings_backfill.py` 覆盖所需版本本身、后继商业迁移 `0016`、更旧版本和未迁移数据库四类边界。
- 操作:全部检查均在 `local-x-financial-linux` 容器内执行,未修改财务规则 XLSX 或历史开发文档。
- 验证Ruff 通过;标准调整 Savings 回填定向回归 `12 passed`
- 影响:数据库升级到 `0016` 及未来合法后继版本后仍可安全执行历史节省回填;更旧、未迁移或不包含目标契约的版本仍会 fail-closed。

View File

@@ -0,0 +1,17 @@
## 修复记录
- 19:24修复历史费用偏离分析可能读取分析截止时间之后才冻结的基线、形成时间穿越的问题。
- Git 提交检查:已执行 `git fetch --all --prune``HEAD..origin/main` 无新提交;本地相对 upstream ahead 17 个既有提交,依次为 `242d68c3``28b834ed``4940ebc4``ee88a36b``6bdf65bc``ae3f02c3``54754b55``211f85d9``5b246307``a662cfe6``5ed34c2b``11275e4b``1347366b``22669a90``a616b30c``653eda05``661990b2`;本次未合并、覆盖或改写共享工作树中的其他变更。
- 修改:`savings_insight_analysis.py` 在历史基线查询中增加 `frozen_at <= as_of`,确保候选分析只能使用截止时间当时已经存在的冻结快照。
- 修改:`test_savings_baseline_insights.py` 新增未来冻结基线回归;确认这类快照不会产生历史价格偏离候选,并返回基线不可用的数据质量提示。
- 操作:全部检查均在 `local-x-financial-linux` 容器内执行,未修改财务规则 XLSX 或历史开发文档。
- 验证Savings 基线/洞察、端点与 CFO 组合回归 `10 passed`;相关 Ruff 检查通过。
- 影响:`as_of` 历史回放不再引用未来才生成的知识CFO 候选洞察和审计结果保持时间一致性。
- 22:50继续修复预算预测读取分析窗口或 `as_of` 之后预算配置与核销流水的问题。
- Git 提交检查:已执行 `git fetch --all --prune``HEAD..origin/main` 无新提交;本地仍相对 upstream ahead 17 个既有提交(`242d68c3``661990b2`,内容为审批任务/风险处置、AI 学习与预审、Expense Case、迁移安全和认证等共享能力未发现新的上游提交也未合并或改写共享工作树。
- 修改:`savings_insight_budget.py``min(as_of, window_end)` 作为预算分析截止点,只读取当时已经创建且最后更新的预算配置,以及预算期间开始至截止点的核销/回滚事实;部门范围优先使用稳定部门 ID缺 ID 才使用部门名称或成本中心。
- 修改:`test_savings_baseline_insights.py` 增加窗口后核销、截止时间后配置、稳定重放与零机会副作用回归。
- 操作:所有 pytest 和 Ruff 均在 `local-x-financial-linux` 容器内以 60 秒超时执行;未接触财务规则 XLSX、商业/连接器/发布迁移或迁移 HEAD。
- 验证Savings/CFO 定向组合 `34 passed, 6 skipped`6 项为未配置 PostgreSQL 专用测试 URL 的预期跳过),相关 Ruff 检查通过。
- 影响:历史预算预测不再使用报告窗口之后才出现的配置或交易,异常归因、政策模拟候选与 CFO 审计回放保持同一时间边界。

View File

@@ -0,0 +1,9 @@
## 修复记录
- 18:11记录 bug 修复:接受住宿职级标准调整时不再信任客户端金额、天数或金额快照。
- Git 提交检查:已执行 `git fetch --all --prune``HEAD..origin/main` 未发现 upstream 新提交;本地相对 upstream ahead 17 个既有提交,依次为 `242d68c3 feat(approval): add task workflow and waiver decisions``28b834ed fix(approval): replay immutable action responses``4940ebc4 feat(approval): add safe risk disposition workflow``ee88a36b feat(ai): add tenant-safe hierarchical expense learning``6bdf65bc feat(expenses): add authoritative pre-review workflow``ae3f02c3 feat(expense): add persistent zero-entry receipt association``54754b55 feat(ai): add personal expense application memory``211f85d9 feat(ai): unify verified expense application workflow``5b246307 feat(ai): issue verified application preview decisions``a662cfe6 feat(ai): add expense application feedback ledger``5ed34c2b feat(expenses): backfill historical claims into expense cases``11275e4b fix(migrations): enforce schema ownership safety``1347366b feat(expenses): secure timeline and draft events``22669a90 feat(expenses): show unified expense event timeline``a616b30c fix(expenses): unify AI application submission transaction``653eda05 feat(auth): add opaque bearer sessions``661990b2 feat(expenses): add transactional expense case events`;这些提交均早于本次未提交修复,本次没有拉取、合并或覆盖共享工作树。
- 修改:`expense_claim_standard_adjustment.py` 只从已锁定的 `ExpenseClaimItem.item_amount` 读取原始金额,只接受服务端差旅规则计算出的最终可报金额;客户端携带的 `application_days``original_amount``reimbursable_amount` 仅作为旧界面兼容展示字段,不参与计算。服务端快照新增规则名、规则版本(无发布版本时使用内容指纹)、地点、匹配城市、职级、职级档、天数、每日住宿标准、住宿标准总额及计算指纹。
- 修改:为标准调整增加 PostgreSQL advisory lock + Claim/Item 行锁和非 PostgreSQL 进程内串行锁;请求支持 `request_id` 幂等键与 `expected_updated_at` 乐观前置条件。相同请求直接重放且不改 `created_at`/计算快照,同请求号改选其他明细会被拒绝;单次调整只替换被选明细的快照,不再误删其他明细已接受的标准调整。
- 操作:在 `local-x-financial-linux` 容器及 `/tmp/x-financial-server-venv` 中运行定向、接口全量和服务全量测试;运行 scoped Ruff 与 `git diff --check`,未在宿主机运行 Python/pytest也未修改规则表或其他用户文件。
- 验证:标准调整定向回归(含非 PostgreSQL 同租户同单据锁竞争)`9 passed``test_reimbursement_endpoints.py` 全量 `22 passed`Scoped Ruff 与 `git diff --check` 通过。`test_expense_claim_service.py` 全量为 `112 passed, 8 failed`8 个失败均位于既有审批任务配置/旧错误文案断言(直属领导任务、费用申请提交、本人审批、重复退回),不经过标准调整实现;本次新增及关联标准调整用例全部通过。
- 影响:伪造低原金额、任意可报金额或超长住宿天数不能降低或抬高实际报销额;规则缺失时整次操作失败关闭且不改金额。审批人看到的原额、可报额和差额均可追溯到数据库明细与服务端规则证据,重复点击和并发请求不会重写金额证据。

View File

@@ -0,0 +1,9 @@
## 修复记录
- 19:37修复 Workbench AI 超大运行时导致职责耦合,以及前端回归测试仍绑定旧单体文件和宿主机缺失 Pillow 的问题。
- Git 提交检查:已执行 `git fetch --all --prune``HEAD..origin/main` 无新提交;本地相对 upstream ahead 17 个既有提交,依次为 `242d68c3``28b834ed``4940ebc4``ee88a36b``6bdf65bc``ae3f02c3``54754b55``211f85d9``5b246307``a662cfe6``5ed34c2b``11275e4b``1347366b``22669a90``a616b30c``653eda05``661990b2`;本次未合并、覆盖或改写共享工作树中的其他变更。
- 修改:将会话滚动、流式响应、持久化/重置提取到 `useWorkbenchAiConversationRuntime.js`,把模型意图规划、低置信确认和多任务衔接提取到 `useWorkbenchAiIntentExecution.js``usePersonalWorkbenchAiMode.js` 降至 768 行。
- 修改Workbench、会话删除、报销关联与快速申请预览测试改为联合审计入口与职责模块视觉验证复用容器已有 ImageMagick保留像素和动画断言不再依赖未安装的 Pillow。
- 操作:全部 node 测试和构建均在 `local-x-financial-linux` 容器内执行,未修改后端或财务规则 XLSX。
- 验证Workbench AI/会话组合 `85 passed`,快速申请预览 `67 passed`Vite 生产构建成功2229 modules`git diff --check` 通过。
- 影响:会话清理、详情智能录入、关联门禁和申请预览的回归测试不再因内部职责迁移误报,核心运行时恢复到项目 800 行硬上限内。

View File

@@ -0,0 +1,333 @@
# AI 分阶段发布真实遥测 概念文档
更新时间2026-07-17
## 功能一句话
把真实 shadow/Canary 规则执行、正负样本盲审真值和服务端保守聚合串成可审计证据链,只有精确率、召回率下界与运行质量同时满足门禁时才允许晋级,越界时自动回滚到稳定版本。
## 背景与问题
风险规则已经具备 Golden Case、`shadow → canary → active → rolled_back` 状态机、稳定流量路由和自动回滚能力,但线上评测仍有一个关键证据缺口:`ReleaseEvaluationInput` 由外部调用方直接提交 `total``failure_count``precision``baseline_precision` 汇总数字Release Guard 无法证明这些数字来自哪一次真实规则执行、哪一个租户、哪一个 release也无法证明精度分子和分母来自可信人工结论。
现有运行与学习链路的事实边界如下:
- `ExpenseClaimRiskRuleLoader` 能按租户和稳定路由键选择 stable、shadow 或 Canary 候选版本,并在快照损坏时保守阻断。
- `evaluate_platform_risk_rules()` 会返回 shadow 候选的 `asset_id / rule_code / rule_version / release_stage / hit / severity`Canary 命中会进入带版本和阶段的风险 flag但当前返回值不会持久化成发布样本。
- `RiskDispositionEvent` 是类型化、租户化、只追加的人工处置事实,`confirm``false_positive` 可作为正例命中的可信真值来源。
- `RiskObservationFeedback` 的自由评论、`AIDecisionFeedback``WorkflowOutcome` 可以支持业务学习,但它们没有同时绑定 `asset_id + release_id + stage + version`,不能直接作为某次发布的精度标签。
- Release Monitor 的 HMAC 能认证请求来自持有共享密钥的调用方,并限制传输重放;它不能证明请求体中的汇总数字由真实数据库 observation 和人工 label 计算得出。
因此,线上门禁必须从“相信外部汇总数字”改为“服务端从只追加事实计算指标”。本能力是 `ai-data-flywheel` 在线质量闭环和 `ai-expense-closed-loop-and-value-proof` 分阶段发布目标的证据层,不替代离线 Golden Case。
## 目标与非目标
### 目标
- [G1] 为每次真实候选规则执行记录租户、资产、release、阶段、版本、规则、命中、基线命中和结构化运行状态。
- [G2] 将专用发布复核或数据库中可信 `RiskDispositionEvent` 转换成只追加的正例判断;将独立盲审转换成与模型预测语义分离的 `risk_present / risk_absent` 真值。
- [G3] 使用稳定幂等键处理同一执行或人工动作的安全重放,不同内容复用同一来源时拒绝冲突。
- [G4] 从真实 observation 和最新可信 label 聚合运行总量、运行失败、候选 precision 和基线 precision。
- [G5] 未标注候选命中保持 `collecting`,不得把“没有人工结论”当成成功或正确。
- [G6] 聚合结果可转换成现有 `ReleaseEvaluationInput`,供 shadow/Canary 门禁、自动晋级和自动回滚使用。
- [G7] 全链路租户隔离、数据脱敏、append-only并拒绝跨租户和陈旧 release/stage/version 标签。
- [G8] 对候选未命中人群实施分层盲审:基线命中分歧样本全量复核,其余负样本按不可预测稳定分数随机抽检;证据不足时 recall/FN 仍显式不可用。
- [G9] 使用抽样漏检率估计总体 FN并以 Wilson 上界反推保守召回率下界;发布门禁只使用下界,不把点估计冒充确定事实。
### 非目标
- [NG1] 不用线上遥测替代离线 Golden Case前者验证真实分布后者验证覆盖明确预期的回归集合。
- [NG2] 不把候选未命中直接判为 false negative也不从“后续未退回”反推规则正确。
- [NG3] 不保存报销事由、票据内容、人工评论、单号、操作者账号原值或原始风险 payload。
- [NG4] 不接受客户端、浏览器或普通管理员提交的 precision/recall 作为发布真值。
- [NG5] 不因为 HMAC 校验通过就信任汇总数字HMAC 只解决传输来源和重放,不解决数据生成真实性。
- [NG6] 不让通过质量门禁的规则绕过审批、风险处置或资金动作的人类控制。
- [NG7] 不把遥测或自动回滚做成绕开共享风险循环、迁移所有权、审批控制或稳定版本保护的旁路。
## 用户与场景
### 用户
1. 风险运营/规则管理员:查看某个 release 的真实样本数、待标注数、误报率和基线比较,决定是否继续采集或人工回滚。
2. 财务审计/审批人:在既有风险处置或专用发布复核队列中给出类型化确认/误报结论,不接触发布汇总公式。
3. Release Monitor只从数据库聚合当前 release满足证据条件后把服务端计算结果交给 Release Guard。
4. 平台审计员按租户、release、版本和来源指纹回放 observation、label、评测与状态转换不读取业务正文。
### 核心场景
1. shadow 阶段同时执行 stable 和候选规则;记录候选 hit/miss并记录同一单据上 stable 是否命中。
2. 候选或 stable 命中进入人工复核;`confirm` 表示风险事实成立,`false_positive` 表示该次正向命中是误报。
3. 每条成功 observation 同步决定盲审层:候选命中全量入队、候选未命中但基线命中全量入队、双方均未命中按发布时冻结比例随机入队。
4. 复核队列混排正负样本,只提供业务单据、规则和业务阶段,不返回 candidate/baseline hit复核人只回答“存在真实风险/确认无该风险”。
5. 所有候选未命中样本必须由两个不同复核人给出一致结论;同一人的重复动作不增加法定票数,冲突时继续 collecting。
6. 候选命中仍有任何未标注项时,聚合状态保持 `collecting`Release Monitor 不提交通过评测。
7. 候选正例全部标注后,服务端计算 candidate precision基线正例也全部标注时才计算 baseline precision。
8. 负样本达到最小独立复核量后,服务端分别披露实际观察 FN、总体估计 FN、FN 置信上界、recall 点估计和 recall 置信下界。
9. Canary 路由中的候选 hit、miss 和结构化运行失败继续追加错误率、precision 或 recall 下界越界时 Release Guard 自动回滚稳定快照。
10. release 已晋级、回滚或被新 release 替代后,旧 observation/sample/label 仍保留,但不能再接收新标签或作为当前晋级输入。
## 功能能力
- [C1] 运行样本生产:消费现有 `shadow_evaluations`、Canary/active flag并提供 manifest 执行循环级 hook 记录 Canary 未命中和结构化失败。
- [C2] 可信标签:支持认证发布复核动作和数据库中真实 `RiskDispositionEvent`;不接受评论文本作为标签。
- [C3] 保守聚合:分别计算 observation、completed、runtime failure、candidate hit/labeled/pending、baseline hit/labeled/pending。
- [C4] 门禁转换:仅 `ready` 聚合可生成 `ReleaseEvaluationInput``collecting` 调用转换时明确拒绝。
- [C5] 证据隔离:运行来源、标签来源、操作者均只保存租户内、带密钥版本的
HMAC-SHA-256 指纹;原始 claim、事件和账号值不进入遥测表。
- [C6] 幂等与冲突同一租户、release、阶段、版本、规则和来源形成稳定键相同重放复用首次记录不同载荷冲突。
- [C7] 历史不可变observation 与 label 均只追加;标签纠正追加新 label聚合取最新可信标签不原地覆盖旧事实。
- [C8] 盲审抽样:候选正例和候选/基线分歧样本全量入队,其余负样本按发布策略的 `negative_sample_percent` 与 HMAC 来源伪名生成稳定随机分数。
- [C9] 真值盲化:样本表不保存明文单据 ID业务来源加密保存API 不返回 candidate/baseline hit前端也拒绝接收预测字段。
- [C10] 保守召回:证据未满足时 `false_negative_count / estimated_false_negative_count / recall / recall_lower_bound` 保持 `null`;满足后分别披露,不用点估计替代下界。
## 方案设计
### 证据链与自动判定
```text
[真实 stable / candidate 执行]
[append-only Observation]
tenant + asset + release + stage + version + hit + runtime status
[append-only Audit Sample]
正例全量 + 分歧全量 + 其余负例稳定随机抽样
[盲化可信 Label]
typed disposition / release review / blind release review
[服务端 Aggregate]
runtime + precision + baseline + sampled FN + recall lower bound
┌───────┴────────┐
│ collecting │ ready
▼ ▼
[继续采集/告警] [ReleaseEvaluationInput]
[Release Guard 判定]
shadow → canary → active
或自动 rolled_back
```
该链路中的每一层只消费上一层可验证的结构化事实。离线 Golden Case 仍是进入 shadow 前的回归门禁;线上 telemetry 是进入 Canary、active 及运行中回滚的分布证据,两者不能互相替代。
### 前端
- 发布控制台分别展示运行样本、候选待标注、候选/基线 precision、运行失败、负样本池/抽样/积压、实际 FN、估计 FN、FN 上界、recall 点估计和置信下界。
- 盲审队列只接收服务端安全字段;`candidate_hit / baseline_hit` 即使误入响应也不会进入页面状态。
- 复核按钮使用中性业务语义“存在真实风险 / 确认无该风险”,不使用“模型命中/误报”暗示预测;来源单据在新标签页打开并隔离 opener。
- `null` 指标统一显示“证据不足/暂不可用”,不得渲染成 0`collecting``ready``failed/rolled_back` 使用不同状态。
- `collecting``ready``failed/rolled_back` 使用不同状态,不把空样本或缺标签显示为 100%。
### 后端
- `AgentAssetReleaseTelemetryService.record_expense_risk_result()` 可从当前风险评测返回值生产 shadow 样本和 Canary/active 命中样本。
- `record_manifest_evaluation()` 设计为风险 manifest 执行循环 hook可记录 Candidate 未命中及 `evaluator_error / artifact_integrity_error / unsupported_evaluator / timeout` 等结构化失败。
- `record_review_label()` 只接收类型化 label、认证 actor ID 和 request IDactor 与 request 进入表前被指纹化。
- `AgentAssetReleaseSamplingService` 在 observation 同一事务内完成分层选择与来源加密;加密失败会连同 observation 一起回滚,避免留下无法复核的孤立事实。
- `AgentAssetReleaseReviewService` 只查询当前租户、当前 release 的样本,混排后输出去预测队列;发布发起人不可自审,负样本强制两个不同 actor。
- `agent_asset_release_aggregation` 将精度和召回证据拆开聚合;`agent_asset_release_recall` 使用随机层漏检率与 Wilson 上界生成总体 FN 估计和 recall 下界。
- `record_risk_disposition_label()` 会重新查询数据库中的同租户 `RiskDispositionEvent``RiskObservation`,校验 action、claim/rule 来源指纹和版本,不信任调用方提供的人工结论副本。
- `aggregate()` 只读取同租户、同资产、同 release、同阶段、同版本事实并验证它仍是资产当前 release。
- `to_release_evaluation_input()` 只允许 `ready` 聚合转换;未标注、无候选正例或空样本会抛出 collecting 错误。
- `AgentAssetReleaseMonitor` 与周期调度器只在服务端查询事实并调用上述方法HTTP 入口只接受签名触发,不再接收外部汇总数字。
- Agent 资产基础 CRUD/表格/版本接口与风险规则生成、测试、启停和发布子路由分离;
资产版本只读投影由独立序列化 mixin 承担Release Guard 只编排状态与持久化,
阈值归一化和质量门禁计算下沉为无数据库副作用的纯策略模块。
### 算法/规则
- shadow 同时保留候选与 stable 的命中信息,用同一人工真值分别估计 candidate precision 和 baseline precision。
- Canary 使用稳定路由键分流;必须在 manifest 执行循环记录候选 miss否则只从最终 flag 采集会产生“只有命中样本”的选择偏差。
- candidate 正向命中使用 `confirmed / false_positive` 计算 precision盲审统一使用 `risk_present / risk_absent` 描述业务真值,再在聚合层规范化,不向复核人暴露预测结论。
- candidate miss 只有进入服务端抽样表并完成独立双人盲审后才可形成 FN/TN 证据;未抽中的个体不能直接被标签,也不能由“后续无退回”反推正确。
- 运行失败与业务误报分开:`failure_count` 表示 evaluator/快照/超时等结构化运行失败,`false_positive_count` 只进入 precision。
- 阶段最小样本数、最大错误率、最低 precision 和最大 precision drop 仍由 `ReleaseGuardPolicy` 统一判定。
- `ReleaseGuardPolicy.reviewer_quorum``1..2` 的受控整数随 release 策略固化,
telemetry 只统计不同 actor 指纹的最新票;票数不足或不同复核人结论冲突时保持
`collecting`,不会把部分意见交给 Release Guard。
- 新发布默认开启 recall 门禁,并冻结 `negative_sample_percent / negative_min_reviewed / min_recall / recall_confidence_level`;旧 release 未携带开关时保持兼容,不追溯伪造历史抽样事实。
- recall 门禁只比较 `recall_lower_bound``min_recall`;点估计再高,只要保守下界不足也不能晋级。明显低 precision 可直接失败,不必等待召回样本凑齐。
### 数据
#### `agent_asset_release_observations`
- 身份:`tenant_id / asset_id / release_id / stage / version / rule_code`
- 运行事实:`candidate_hit / baseline_hit / runtime_status / failure_code / business_stage`
- 脱敏来源:`source_kind / source_fingerprint`;不保存 claim ID、单号或业务正文。
- 一致性:租户幂等键唯一,保存 payload fingerprint同一来源不同内容冲突。
#### `agent_asset_release_labels`
- 身份复制tenant、observation、asset、release、stage、version并通过复合外键绑定原 observation。
- 标签:正例判断使用 `confirmed / false_positive`,盲审真值使用 `risk_present / risk_absent`
- 来源:`typed_risk_disposition / release_review / blind_release_review`;数据库组合约束禁止标签语义与来源交叉使用。
- 历史:只追加;纠正写新行,不修改或删除旧标签。
#### `agent_asset_release_audit_samples`
- 身份复制tenant、observation、asset、release、stage、version通过复合外键绑定原 observation。
- 分层:`candidate_positive_census / candidate_disagreement_census / candidate_negative_random`
- 抽样事实:保存入样概率和稳定选择分数;同租户 observation 最多一条样本,重放必须匹配 payload fingerprint。
- 来源保护:原始单据引用使用 SecretBox 加密,表中不保存明文;只有通过租户与复核角色检查的队列读取才解密。
三类模型同时具备 ORM 层 UPDATE/DELETE 拒绝。`0018` 建立 observation/label后继 `0023` 建立 audit sample、扩展标签约束并复用 PostgreSQL append-only 触发器;存在盲审事实或新标签语义时拒绝有损降级。
### 权限
- 所有写入、读取和聚合以 `tenant_id` 为第一条件;租户绑定资产不允许其他租户观察或标签。
- 平台共享资产可为不同租户分别保存 observation/label但各租户样本和 precision 不混算。
- 标签前重新校验资产当前 `release_id + stage + candidate_version`;旧 release、已晋级阶段或已回滚阶段拒绝新增标签。
- 类型化处置标签必须来自数据库中真实存在且同租户的 `RiskDispositionEvent`action 只允许 `confirm / false_positive`
- 专用复核队列只允许 `manager``admin`;租户和 actor 全部来自认证上下文,
跨租户资产返回 404非复核角色返回 403发布发起人自审返回 400。
- 标签写入必须携带 `X-Request-Id`;新客户端只发送 `risk_present / risk_absent`,旧客户端的 `confirmed / false_positive` 仅在复核 API 边界映射为盲审真值。标签、
actor 指纹和 request 来源只追加保存,客户端不能覆盖 tenant、release 或 actor。
- `reviewer_quorum=2` 时必须由两个不同复核人给出相同结论;同一人的重复提交不增加票数,
冲突结论进入待仲裁状态并继续阻止晋级。
### HMAC 与数据真实性边界
- HMAC 可以证明传输请求由持有密钥的一方生成、请求在允许时间窗口内且签名未被修改。
- HMAC 不能证明调用方提交的 `total=100``precision=0.99` 真的来自 100 条数据库 observation也不能证明人工标签存在。
- 因此 HMAC 只保留为自动 Monitor 的传输认证和防重放手段;指标必须由接收端使用当前数据库 observation/label 重新计算。
- 最终 Monitor 请求应只携带受控 release 触发信息或聚合作业游标,而非可被签名后照单采用的质量汇总数字。
- 即使 HMAC 认证失败,也不得影响 stable 规则继续保护业务;应停止晋级、记录安全告警并保持 `collecting`
### 降级策略
- 遥测表或写入暂不可用:不阻断已生效 stable 风险规则和报销主流程,但当前 release 不能晋级,状态保持 collecting 并告警。
- 人工标签迟到:保留 observation待标签追加后重新聚合不使用默认正确值填补。
- 运营端同时展示待标注数量和最早积压时长;超过 24 小时生成结构化逾期告警。
- 处置事件与 observation 无法安全关联:拒绝标签,不按相似文本、姓名或评论做模糊匹配。
- baseline 标签不完整:`baseline_precision = null`;候选指标可继续采集,但不能声称已完成可靠基线比较。
- 负样本未抽中、未完成双人复核或未达到最小复核量recall、估计 FN 和置信下界保持不可用,继续 collecting 并告警;不会用零填充。
- 聚合或 Guard 判定越界:按现有冻结快照恢复 stable回滚不删除候选 observation 和 label。
- 周期聚合异常形成 `release_aggregation_failed` 告警并隔离到单资产;运行失败率、
precision 下降、baseline 不可用和自动回滚分别使用独立告警码,稳定版本继续服务。
## 算法与公式
### 候选精确率
```text
candidate_precision = candidate_confirmed / (
candidate_confirmed + candidate_false_positive
)
```
- 分母只包含候选 `candidate_hit=true` 且已有可信最新标签的 observation。
- 任一候选正向命中仍未标注时,聚合保持 `collecting`,不得将部分 precision 交给 Release Guard 作为通过证据。
### 基线精确率
```text
baseline_precision = baseline_confirmed / (
baseline_confirmed + baseline_false_positive
)
```
- 只使用同一 shadow 样本上 `baseline_hit=true` 的可信标签。
- 任一基线正向命中待标注时baseline precision 显式不可用,不用部分样本制造有利比较。
### 运行错误率
```text
runtime_error_rate = runtime_failure_count / observed_count
```
- `observed_count` 是该 release/stage/version 的真实运行 observation 数。
- `runtime_failure_count` 只统计结构化执行失败,不把业务误报混成技术错误。
- 误报通过 precision 体现;运行错误通过 `ReleaseGuardPolicy.max_error_rate` 体现。
### Recall 与 false negative
```text
recall = TP / (TP + FN)
```
线上负样本分为两层,不能把抽检样本数直接当总体 FN
```text
disagreement_FN = 全量复核(candidate_hit=false, baseline_hit=true)中的真实风险数
random_FN_rate = 随机盲审层真实风险数 / 已完成双人复核的随机样本数
estimated_FN = disagreement_FN + random_FN_rate * random_negative_population
recall_point = TP / (TP + estimated_FN)
random_FN_rate_upper = WilsonUpper(random_FN, reviewed, confidence)
FN_upper = disagreement_FN + random_FN_rate_upper * random_negative_population
recall_lower_bound = TP / (TP + FN_upper)
```
- `false_negative_count` 仅表示已完成法定复核样本中实际观察到的 FN不等于总体 FN。
- `estimated_false_negative_count` 是总体点估计,`false_negative_upper_bound` 是保守上界,二者必须分字段展示。
- 随机层存在但尚无已复核样本、仍有抽中样本待审或未达到最小复核量时,上述估计统一保持 `null`
- 没有随机负例人群时可使用全量复核的精确 recall否则发布门禁只消费 `recall_lower_bound`
- 离线 Golden Case recall 只证明测试集表现,不能冒充线上 recall线上抽样也不能替代 Golden Case 的边界覆盖。
## 测试方案
- 模型:租户/release 复合身份、sample/label 到 observation 复合外键、标签来源组合约束和 append-only。
- 样本生产shadow hit/miss、stable baseline hit、Canary hit、manifest 循环 Canary miss 和结构化运行失败。
- 标签:专用复核、真实 `RiskDispositionEvent`、盲审语义、负样本双人法定票、来源/actor 脱敏。
- 幂等:相同 observation/label 稳定重放;同一来源不同载荷返回冲突。
- 租户与时效:跨租户隐藏、陈旧 release/stage/version 拒绝、错误规则/单据来源拒绝。
- 聚合无样本、无候选正例、无标签、部分标签、完整标签、baseline 部分标签、运行失败和 precision drop。
- 运营告警:待标注数量/最早时长、24 小时积压、运行失败率、聚合失败、baseline
不可用、precision 下降和自动回滚使用去敏结构化告警。
- 证据边界:未抽中负样本拒绝标签;预测字段不进入队列/API抽样不足时 recall/FN 为 null充分时点估计与下界分离。
- 组合回归Telemetry 生成的 `ReleaseEvaluationInput` 可被现有 Release Guard 消费,且不改变 shadow/Canary/回滚状态机。
- PostgreSQL0018/0023 upgrade/downgrade、复合外键、标签组合约束、数据库 append-only、并发同幂等键单赢家、同 actor 去重和标签/阶段竞争。
- 所有验证在 `local-x-financial-linux` 容器内执行,单条命令最大 60 秒。
## 指标与验收
- [A1] 每条线上发布样本可追溯到 tenant、asset、release、stage、version、rule 和来源指纹,且不含业务正文。
- [A2] 相同运行/标签重放只保留一条事实;不同载荷复用同一来源 100% 拒绝。
- [A3] 任一候选正向命中未标注时状态为 collecting不能生成 Release Guard 通过输入。
- [A4] precision 和 baseline precision 只由真实 observation 与可信类型化标签计算,外部汇总值不作为权威事实。
- [A5] recall/FN 证据不足时明确 unavailable充分时同时披露实际 FN、估计 FN、FN 上界、recall 点估计和置信下界,门禁只使用下界。
- [A6] 跨租户、陈旧 release/stage/version、错误处置来源和纯负样本伪标签均被拒绝。
- [A7] 质量越界时自动回滚 stable遥测故障或 HMAC 故障时停止晋级但不关闭既有稳定保护。
- [A8] PostgreSQL 迁移、append-only、并发、后端组合回归、Ruff 和 `git diff --check` 全部在容器内通过。
## 风险与开放问题
- 模型注册、真实 manifest hook、类型化处置标签、盲审抽样、服务端聚合、即时 Guard、
租户周期调度、发布复核队列和运营控制台已经接通;`0023` 后继迁移与完整 PostgreSQL
循环仍须完成最终验证后才能关闭本能力。
- 租户调度器只自动处理租户绑定资产。平台共享资产可以按租户保存隔离样本,但在建设跨租户、加权且可审计的聚合口径前,不能由单租户样本自动回滚全局版本。
- 线上标签可能集中在高风险或有争议样本precision 仍可能受人工复核选择偏差影响;控制台必须同时披露 hit、labeled 和 pending 数量。
- 规则稀有时可能长期没有候选正例;不能为了晋级降低为“零命中等于 100% precision”需要延长 shadow 或补充经审核 Golden Case。
- 标签纠正采用追加新事实和“每个 actor 最新票”聚合;数据库索引、复合外键和只追加
约束已在 PostgreSQL 验证,同 observation/label 的并发单赢家、阶段晋级竞争和调度器
advisory leader lease 均有 PostgreSQL 并发验证。
- HMAC 密钥泄露会让攻击者通过传输认证,但仍不应允许其伪造数据库 observation/label服务端重算是不可省略的第二道边界。
- 线上 recall 已有分层抽样、预测盲化、双人复核、冲突保持 collecting、最小样本量和置信下界仍需用试点数据校准抽样比例、人工一致率与业务风险容忍度不能把默认阈值当行业通用真理。
## 本轮实现记录
- 2026-07-16完成现有 Loader、平台风险评测、shadow_evaluations、风险处置和 AI workflow feedback/outcome 的只读审计,确认 `RiskDispositionEvent` 是当前最可信线上人工标签源,通用学习结果缺少 release 身份不能直接用于门禁。
- 2026-07-16新增独立 observation/label 模型与服务完成脱敏、append-only、稳定幂等、租户/陈旧 release 拒绝、shadow/Canary 样本生产、可信标签和保守聚合。
- 2026-07-16独立切片阶段新增遥测测试 7 项,与当时 Release Guard/Runtime 组合共 19 项通过;该阶段留下的共享注册、迁移和运行 hook 已在后续记录中完成。
- 2026-07-16完成主模型注册、迁移所有权与 `0018`;一次性 PostgreSQL 17 完整迁移循环、复合外键和数据库 append-only 探针通过。
- 2026-07-16真实 shadow/Canary/active manifest 执行已写 observation类型化风险处置自动追加标签并即时触发 Guard相同聚合快照幂等复用测试运行低 precision 或运行失败自动恢复 stable。
- 2026-07-16Release Monitor HTTP 改为空触发 + HMAC禁止调用方提交 precision/total新增租户级周期调度作为即时监控失败的补偿链。发布组合回归 44 项、调度/风险定向回归 24 项通过。
- 2026-07-16完成后端大文件职责拆分`agent_assets.py` endpoint 降至 714 行、
`AgentAssetService` 降至 675 行、`AgentAssetReleaseGuardService` 降至 636 行;
风险规则子路由、资产序列化和发布纯策略分别独立,旧路由路径、公开类型导入和状态机行为保持兼容。
- 2026-07-16发布纯策略新增 `reviewer_quorum`(默认 1、范围 1..2)并随 release state 保存,
专用去敏复核队列按独立 actor 计票,禁止发布人自审,双人同意前或结论冲突时保持 collecting。
- 2026-07-16发布控制台展示真实样本、命中、待审、最早积压时长、运行失败率、
candidate/baseline precision 和 recall 不可用;新增积压、超时、运行故障、聚合失败、
precision 下降、baseline 不可用和自动回滚结构化告警。
- 2026-07-16新增 append-only 盲审样本、正例/分歧全量与其余负例稳定随机抽样来源引用加密保存observation 与 sample 同事务写入,保护失败整体回滚。
- 2026-07-16发布复核队列改为预测盲化混排负样本强制两个不同复核人新增实际/估计 FN、Wilson FN 上界、recall 点估计与保守下界Release Guard 只消费下界。
- 2026-07-16前端拒绝 prediction hit 字段,使用中性业务真值动作,并显示负样本池、抽样进度、积压及置信方法;证据缺失保持不可用,不渲染为零。
- 2026-07-16新增 `0023` 后继迁移和标签来源组合约束;最终 PostgreSQL 全链升级/降级、并发和全量回归结果待验证后回填。

View File

@@ -0,0 +1,120 @@
# AI 分阶段发布真实遥测 开发 TODO
更新时间2026-07-17
## 使用规则
- 每项必须回链 `CONCEPT.md` 对应章节;没有代码、迁移、接口或容器证据不得勾选。
- observation、label、聚合和 Release Guard 输入必须按证据层分开,不能用 HMAC 请求或客户端汇总值替代数据库事实。
- `collecting` 不得包装成通过recall/false-negative 没有达到独立盲审证据阈值时必须保持 unavailable。
- 所有后端、迁移和并发验证只在 `local-x-financial-linux` 容器内执行,单条命令最长 60 秒。
## 1. 调研与边界
- [x] [CONCEPT: 背景与问题] 审计 Loader、平台风险评测、shadow_evaluations、风险处置和 AI workflow feedback/outcome 链路,确认线上发布评测缺少持久真实样本。
证据:`expense_claim_risk_rule_loader.py``expense_claim_platform_risk.py``risk_dispositions.py``expense_workflow_learning.py``ai_learning.py` 只读审计。
- [x] [CONCEPT: 背景与问题] 确认 `RiskDispositionEvent confirm/false_positive` 是当前可绑定风险命中的可信人工结论,通用 feedback/outcome 缺少完整 release 身份。
证据:`risk_disposition.py``risk_dispositions.py``agent_asset_release_telemetry.py` 的可信事件查询和关联校验。
- [x] [CONCEPT: HMAC 与数据真实性边界] 冻结 HMAC 只负责传输认证、防篡改和防重放,不证明汇总指标的数据真实性。
证据:`CONCEPT.md`“HMAC 与数据真实性边界”;现有 `agent_asset_release_monitor_auth.py``ReleaseEvaluationInput` 契约对照审计。
- [x] [CONCEPT: 目标与非目标] 明确未标注 collecting、敏感数据不落遥测表、线上 recall/FN 证据不足不可用。
证据:`CONCEPT.md`“目标与非目标”“Recall 与 false negative”。
## 2. 契约与设计
- [x] [CONCEPT: 数据] 定义 observation 与 label 的 tenant/asset/release/stage/version 复合身份、稳定幂等键和只追加边界。
证据:`server/src/app/models/agent_asset_release_telemetry.py`
- [x] [CONCEPT: 证据链与自动判定] 定义 observation → trusted label → aggregate → ReleaseEvaluationInput → Release Guard 的证据链。
证据:`CONCEPT.md`“证据链与自动判定”、`ReleaseTelemetryAggregate.to_release_evaluation_input()`
- [x] [CONCEPT: 算法/规则] 分开定义运行失败、业务误报、candidate precision、baseline precision 和缺失负样本真值。
证据:`agent_asset_release_telemetry.py``aggregate()``test_agent_asset_release_telemetry.py` 的 collecting、baseline 和 FN 不可用断言。
- [x] [CONCEPT: 权限] 冻结专用发布复核接口的角色矩阵、双人复核阈值和跨租户 HTTP 错误契约。
证据:`require_rule_reviewer_user` 只允许 manager/admin跨租户 404、非角色 403、
发布人自审 400`reviewer_quorum` 限制 1..2 且按不同 actor 指纹计票。
## 3. 独立模型与服务
- [x] [CONCEPT: 数据] 新增 append-only observation/label ORM 模型、复合租户/release 外键和更新/删除拒绝。
证据:`server/src/app/models/agent_asset_release_telemetry.py`
- [x] [CONCEPT: 后端] 实现真实 shadow 结果、Canary/active 命中和 manifest 执行循环样本生产器。
证据:`AgentAssetReleaseTelemetryService.record_expense_risk_result()``record_manifest_evaluation()`
- [x] [CONCEPT: 后端] 实现专用发布复核标签和可信 `RiskDispositionEvent` 标签转换,不接收自由评论。
证据:`record_review_label()``record_risk_disposition_label()`
- [x] [CONCEPT: 后端] 实现租户隔离、陈旧 release/stage/version 拒绝、来源关联校验和稳定幂等冲突。
证据:`_require_current_release()``_observation_replay()``_label_replay()` 及对应测试。
- [x] [CONCEPT: 算法与公式] 实现保守聚合;未标注候选命中保持 collecting基线标签不完整时 baseline precision 不可用。
证据:`ReleaseTelemetryAggregate``aggregate()``to_release_evaluation_input()`
- [x] [CONCEPT: 数据] 实现 claim、事件、actor 来源指纹化,不保存业务正文、评论和账号原值。
证据:模型无自由文本业务字段;`_fingerprint()`;脱敏测试断言。
## 4. 共享注册、迁移与运行接入
- [x] [CONCEPT: 数据] 在 `db/base.py``models/__init__.py` 注册 `AgentAssetReleaseObservation``AgentAssetReleaseLabel`,保证主应用 metadata 与迁移所有权检查可见。
证据:`db/base.py``models/__init__.py``schema_ownership.py``migration_preflight.py` 已登记两张迁移自有表;前置检查 77 项通过。
- [x] [CONCEPT: 数据] 新增后继 `20260716_0018` Alembic 迁移,创建两张表、复合租户/release 约束、检查约束、索引和数据库级 append-only UPDATE/DELETE 触发器。
证据:`20260716_0018_agent_asset_release_telemetry.py`;一次性 PostgreSQL 17 完整升级/降级循环 1 项通过,静态迁移/schema owner 回归 112 项通过。
- [x] [CONCEPT: 算法/规则] 在真实候选 manifest 执行循环接入 `record_manifest_evaluation()`,完整记录 shadow/Canary hit、miss 和结构化执行失败。
证据:`expense_claim_platform_risk.py``expense_claim_release_telemetry.py``test_agent_asset_release_runtime.py` 覆盖 shadow、Canary、active 和损坏快照,`test_agent_asset_release_telemetry.py` 覆盖结构化运行失败。
- [x] [CONCEPT: 后端] 在类型化风险处置事务接入 release label按同租户、同 claim/rule、当前 release 精确关联 observation关联失败保守拒绝。
证据:`agent_asset_release_disposition_labels.py``risk_disposition_release_sync.py`;风险确认/误报会追加标签,误报越界自动回滚 stable。
- [x] [CONCEPT: 后端] 将现有 Release Monitor 从“提交外部汇总数字”改为“触发服务端聚合”,只在 aggregate ready 时构造 `ReleaseEvaluationInput`
证据:`AgentAssetReleaseMonitor.evaluate_current()` 只接受 release 身份HTTP body 为禁止额外字段的空触发契约,真实聚合未 ready 时不调用 Guard。
- [x] [CONCEPT: HMAC 与数据真实性边界] 保留 HMAC 作为 Monitor 传输认证,但禁止签名请求覆盖数据库聚合结果,并补充签名通过但汇总伪造的反向测试。
证据:`agent_asset_releases.py``AgentAssetReleaseMonitorTriggerWrite`;带有效签名的伪造 `precision/total` 请求仍返回 422。
- [x] [CONCEPT: 降级策略] 遥测持久化或聚合失败时保持 stable 规则、停止晋级并记录结构化告警,不让观测故障阻断正常报销。
证据:`ExpenseClaimReleaseTelemetryRecorder``risk_disposition_release_sync.py` 将遥测/标签/监控故障隔离并记录日志;未获得 ready 真实聚合不会写 passed也不会改变 stable 路由。
- [x] [CONCEPT: 后端] 按职责拆分 Agent 资产接口、版本只读投影和 Release Guard 纯策略计算,保持公开 API 与状态机行为稳定。
证据:`agent_assets.py` endpoint 714 行、`agent_asset_risk_rules.py` 534 行、`agent_assets.py` service 675 行、`agent_asset_serialization.py` 217 行、`agent_asset_release_guard.py` 636 行、`agent_asset_release_policy.py` 201 行,相关核心文件均低于 800 行;纯策略模块保存范围为 1..2 的 `reviewer_quorum`,第 5 节已完成复核执行与运营闭环。
## 5. 自动聚合、告警与运营闭环
- [x] [CONCEPT: 证据链与自动判定] 实现按 tenant/asset/release/stage/version 的周期聚合作业与幂等快照。
证据:`agent_asset_release_scheduler.py` 按租户有界扫描;`AgentAssetReleaseMonitor._existing_evaluation()` 复用相同 release 聚合快照,不重复生成测试运行。
- [x] [CONCEPT: 证据链与自动判定] aggregate ready 后自动调用 Release Guardcollecting 只更新采集状态,不写虚假 passed test run。
证据:人工标签提交后即时触发 monitor后台 scheduler 提供失败补偿;`test_agent_asset_release_monitor.py` 覆盖 collecting 不调用 Guard、低 precision/运行失败自动回滚与相同快照幂等。
- [x] [CONCEPT: 降级策略] 增加待标注数量/时长、运行失败率、precision 下降、baseline 不可用、聚合失败和自动回滚告警。
证据:`agent_asset_release_alerts.py``AgentAssetReleaseMonitor._metrics()`24 小时
待审逾期和单资产聚合失败均返回结构化告警,定向 Monitor/Telemetry/Runtime 29 项通过。
- [x] [CONCEPT: 前端] 在发布控制台展示 observed/hit/labeled/pending、候选/基线 precision、运行失败和召回证据。
证据:`AuditReleaseMonitorPanel.vue` 展示负样本池、抽样进度、积压、实际/估计 FN、FN 上界、recall 点估计/下界/置信方法;空值不渲染为零。
- [x] [CONCEPT: 权限] 实现认证发布复核队列、操作审计和需要时的双人复核,不允许规则发布人独自伪造所有标签。
证据:`agent_asset_release_review.py``agent_asset_release_label_votes.py`、专用 GET/POST
API`X-Request-Id`、append-only label、actor HMAC 指纹、发布人隔离和独立双人同意均有测试。
- [x] [CONCEPT: Recall 与 false negative] 实现独立分层负样本抽样、预测盲化、双人标注、冲突保持 collecting 和保守召回估计。
证据:`agent_asset_release_sampling.py``agent_asset_release_review.py`
`agent_asset_release_aggregation.py``agent_asset_release_recall.py`;前端只发送
`risk_present / risk_absent`,负样本要求两个不同 actor门禁只读取 recall 下界。
- [x] [CONCEPT: 数据] 完成 `0023` audit sample 迁移注册、数据库标签组合约束、append-only 触发器和无损降级验证。
证据:`20260716_0023_agent_asset_release_blind_audit.py``release_telemetry_migration_assertions.py`fresh PostgreSQL 完整迁移链、约束、触发器及降级/再升级均通过。
## 6. 测试与验证
- [x] [CONCEPT: 测试方案] 独立模型/服务测试覆盖 shadow、Canary、baseline、真实 typed disposition、脱敏、幂等、租户、陈旧 release、append-only、collecting 和 FN 不可用。
证据:容器内 `test_agent_asset_release_telemetry.py` 7 项通过。
- [x] [CONCEPT: 测试方案] 与现有 Release Guard 和 Runtime 组合回归通过。
证据:容器内 `test_agent_asset_release_guard.py``test_agent_asset_release_runtime.py``test_agent_asset_release_telemetry.py` 共 19 项通过Ruff 通过,`git diff --check` 通过。
- [x] [CONCEPT: 测试方案] 新增 0018 upgrade/downgrade、schema owner、复合外键和数据库 append-only PostgreSQL 验证。
证据:一次性 `pgvector/pgvector:pg17` 数据库中完整迁移循环 1 项通过;迁移运行探针验证复合租户外键、幂等唯一约束和两张表的数据库级 UPDATE/DELETE 拒绝。
- [x] [CONCEPT: 测试方案] 新增 PostgreSQL 并发同 observation、同 label、标签与阶段晋级竞争和幂等冲突测试。
证据:一次性 PostgreSQL 17 中 `test_agent_asset_release_telemetry_concurrency_postgres.py` 4 项通过;相同重放只保留一条,不同载荷单赢家,阶段转换持锁后旧标签保守拒绝。
- [x] [CONCEPT: 测试方案] 验证 audit sample 并发单赢家、同 actor 重复不增加负样本法定票、第二独立 actor 完成双人复核,以及预测字段不进入前端队列状态。
证据:`test_agent_asset_release_telemetry_concurrency_postgres.py` 覆盖单赢家和独立双人票;`test_agent_asset_release_telemetry.py``agent-release-monitor-panel.test.mjs` 验证队列不暴露 candidate/baseline 命中预测。
- [x] [CONCEPT: 测试方案] 跑通真实风险循环 → observation → disposition label → aggregate → Release Guard 回滚端到端。
证据:`test_agent_asset_release_runtime.py``test_risk_dispositions.py` 覆盖真实执行样本、类型化处置标签、服务端聚合、低 precision 自动恢复 stable发布组合回归 44 项通过。
- [x] [CONCEPT: 测试方案] 验证职责拆分后的资产服务、风险规则子路由、Release Guard/Runtime、Monitor/Scheduler/Telemetry 和 API schema 兼容性。
证据:容器内资产服务 27 项、Guard/Runtime 13 项、Monitor/Scheduler/Telemetry 22 项、风险规则生成/解释 30 项、修订/反馈 14 项通过;迁移后的既有 publish HTTP 防绕过用例通过Ruff 与 `git diff --check` 通过。
- [x] [CONCEPT: 指标与验收] 验证 HMAC 失败、遥测库失败、标签积压和聚合失败均停止晋级但不破坏 stable 业务保护。
证据:`test_agent_asset_release_runtime.py` 覆盖签名失败;`test_agent_asset_release_monitor.py` 覆盖 collecting、积压和聚合失败`ExpenseClaimReleaseTelemetryRecorder` 隔离遥测持久化异常stable 报销路径继续服务。
- [x] [CONCEPT: 指标与验收] 完成相关后端全量回归、Ruff、迁移完整循环和 `git diff --check`,逐项回填 A1-A8 证据。
证据:遥测组合 45 项通过fresh PostgreSQL 总探针 `87 passed / 0 skipped / 0 failed`,其中发布遥测并发 7 项Web 全量 815 项及 Vite build 通过;新增 Python 文件 Ruff 与 `git diff --check` 通过。全仓既有 Ruff 基线债不伪装为本轮新增错误。
## 7. 文档收尾
- [x] [CONCEPT: 本轮实现记录] 新建独立 CONCEPT/TODO记录证据边界、已实现切片和共享集成缺口。
证据:`document/development/2026-07-16/feature/ai-release-real-telemetry/CONCEPT.md``TODO.md`
- [x] [CONCEPT: 风险与开放问题] 共享集成完成后回填 0018、运行 hook、自动作业、PostgreSQL 和端到端证据。
证据:本 TODO 第 4-6 节与 `CONCEPT.md`“本轮实现记录”已回填发布面板已展示积压、失败、precision 和 recall生产运营效果由下一条真实流量验收单独保留。
- [x] [CONCEPT: 指标与验收] 与上位 AI 闭环 TODO 对齐代码与容器验证状态。
证据:`document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md` 已回填 Golden、Canary、盲审和回滚的工程证据。
- [ ] [CONCEPT: 指标与验收] 使用生产真实流量、独立复核样本和试点阈值验证 Golden/Canary/自动回滚运营效果。
证据要求:目标企业生产 observation/label、盲审样本、阈值签字和真实回滚演练本地测试不得替代。

View File

@@ -0,0 +1,237 @@
# 商业计量、客户 ROI 与可持续定价 概念文档
更新时间2026-07-17
## 功能一句话
把套餐、订阅、权益、真实用量、内部成本、客户确认价值和平台毛利拆成可审计事实,并只在成本与价值证据同时成立时给出可持续定价走廊。
## 背景与问题
- 平台要成为可长期经营的产品,既要证明客户省了钱,也要知道每个租户消耗了多少 OCR、AI、存储、连接器和支持成本。
- “风险金额”“预计节省”“流程耗时”不能直接作为客户 ROI平台收入、平台内部成本和客户价值也不能混在一个指标里。
- 只有套餐创建而没有暂停、取消、历史查询和配额硬门禁,商业后台无法真正运营。
- 仅在执行前读取 `SUM(usage)` 再放行无法抵抗并发;两个工具可同时看到剩余额度并一起执行,事后计量再准确也已形成不可逆超卖。
- 固定拍一个价格无法适配客户规模、真实成本和价值覆盖。需要先计算平台可持续下限,再计算客户价值可接受上限;没有交集时不能强行报价。
- 试点期通常缺少 30/90 天真实成本和财务确认收益,因此系统必须显示“采集中”,而不是用 mock 或估计值伪造单位经济性。
本能力是 `2026-07-13/feature/ai-expense-closed-loop-and-value-proof` 中商业模式与价值证明部分的实施拆分。
## 目标与非目标
### 目标
- [G1] 建立租户隔离、版本化的套餐、订阅和权益配置。
- [G2] 建立追加式用量与内部成本事实,支持幂等、冲回、配额和并发门禁。
- [G3] 把商业权益门禁与安全门禁做收紧式合并,付费不能绕过高风险人工审核。
- [G4] 严格分开客户收费、内部成本、平台贡献毛利、财务确认现金节省、客户 ROI 和工时价值。
- [G5] 支持套餐/订阅/权益生命周期、历史查询、用量成本查询和数据质量状态。
- [G6] 根据真实成本和财务确认节省给出基础费下限、价值上限和可封顶成功费,不自动改合同。
- [G7] 形成“试点采集 → 基础订阅 → 基础费 + 封顶成功费”的可验证商业演进路径。
- [G8] 用不可变账期承载收费、用量、成本与配额历史,并对商业配置和自动续期保留脱敏追加式审计。
### 非目标
- [NG1] 不在代码中硬编码某个客户的最终价格、税率、折扣或合同条款。
- [NG2] 不把风险暴露、预计机会、未确认结果、未锁定汇率或未经客户认可的工时估值计入价值定价。
- [NG3] 不让套餐或配额放宽审批、风险、租户和人工确认门禁。
- [NG4] 不自建开票、税务、收款或第三方订阅扣费网络;只保存受控外部订阅引用。
- [NG5] 不跨币种直接求和,也不在缺少同币种成本/价值时生成综合 ROI。
- [NG6] 不把定价场景结果自动写成生效套餐,最终合同仍需平台商业负责人审批。
## 用户与场景
### 用户
1. 平台商业管理员:维护套餐版本、订阅、权益、状态和定价场景。
2. 平台运营/财务:查看用量、成本、毛利和异常数据质量。
3. 客户 CFO/财务负责人:查看自己租户的当前套餐、用量、配额和客户价值口径。
4. 产品运行时:在执行 OCR、AI 或连接器能力前检查配额,并写入真实用量。
### 核心场景
1. 试点客户先配置 `pilot` 套餐和明确的合同周期,开始采集真实用量、成本与已确认价值。
2. 运行时检查某项权益;只有商业配额允许且安全决策为 allow 时才最终允许。
3. 同一用量事件重试返回首次结果;不同内容复用幂等键返回冲突。
4. 客户暂停、逾期、取消或过期时,商业权益即时失败关闭,历史用量和成本不被删除。
5. 商业负责人选择 90 天窗口,输入目标贡献毛利率和客户最大价值分享比例,系统按币种输出定价走廊。
6. 成本下限高于价值上限时,系统建议先优化单位经济性或扩大可信价值,不生成强行报价。
## 功能能力
- [C1] 套餐版本subscription、usage、hybrid、pilot、custom支持生效区间和旧版本退役。
- [C2] 订阅快照:合同周期、计费周期、席位、基础费、外部订阅引用和状态历史。
- [C3] 权益与配额feature、metered、unlimited包含量、硬上限、重置周期和超额策略。
- [C4] 用量事实usage、credit、adjustment、reversal保存主体、来源、correlation 和首次请求指纹。
- [C5] 成本事实AI、OCR、存储、连接器、支持、实施、基础设施、支付等分类及汇率快照。
- [C6] 商业分析:收费、成本、贡献毛利、确认节省、客户 ROI 和工时价值分账展示。
- [C7] 定价走廊:最低可持续收费、最高价值对齐收费、最大成功费和证据状态。
- [C8] 生命周期与查询:暂停、恢复、逾期、取消、过期,以及套餐/订阅/权益/用量/成本历史。
- [C9] 账期与审计:月/季/年自动续期、合同边界失败关闭、账期历史和商业管理追加审计。
## 方案设计
### 前端
- 商业工作台分为“当前账户”“套餐与订阅”“权益与配额”“用量与成本”“价值与定价”五块。
- 客户 ROI 与平台贡献毛利必须使用不同卡片、不同说明,不允许用一个“综合收益”混合展示。
- 多币种按币种分行;缺成本、缺确认价值、仅有试点数据和证据冲突使用不同状态。
- 暂停、取消和定价场景均需要确认;终态操作明确提示不能原地恢复。
- 普通客户财务只能查看本租户账户;平台级配置、成本、毛利和定价仅平台管理员可见。
### 后端
- `CommercialAdminService` 管理套餐、订阅、权益和状态转换。
- `CommercialBillingPeriodService` 签发和定位不可变账期;用量、成本和运行时预占必须绑定真实账期编号。
- `CommercialSubscriptionRolloverService` 在订阅行锁内按月/季/年边界幂等续期;合同制或跨越 `ends_at` 时失败关闭。
- `CommercialRolloverScheduler` 以 PostgreSQL advisory lock 选举单一执行者,再按订阅行锁串行签发到期账期。
- `CommercialAdminAuditService` 只记录字段白名单快照,排除合同正文、配置、外部订阅编号、元数据和凭证类字段。
- `CommercialQueryService` 负责租户范围内历史与追加事实查询。
- `CommercialEntitlementService` 计算配额和商业/安全合并门禁。
- `CommercialMeteringService` 写追加式用量和成本、执行幂等与冲回。
- `CommercialRuntimeReservationService` 在订阅/权益锁内完成执行前额度预占,管理 reserved、committed、released、expired、reconciliation_required 和 committed_reconciliation_required 状态。
- `CommercialRuntimeBridge` 把可信 AgentRun、中央工具执行、真实 AgentToolCall 和商业事实接成预占—执行—结算链;未配置商业计量时保持兼容。
- `CommercialRuntimeReconciler` 只根据可验证的工具/运行终态补偿过期预占,不按超时猜测业务是否发生。
- `CommercialAnalyticsService` 按窗口和币种分账聚合,不伪造缺失数据。
- `CommercialPricingService` 只读分析真实成本和确认节省,输出价格区间,不写套餐。
- HTTP 管理入口仅平台管理员可用;租户账户读取仅 finance、executive 或平台管理员可用。
### 算法/规则
- 用量硬配额在数据库锁内计算,最终用量超过限制时整个写入失败。
- 真实工具执行前按 `已用量 + 有效预占 + 本次预占 <= 硬上限` 原子判断;成功时真实量不得超过预占,失败/阻断只释放预占,不写用量或成本。
- call 基准可直接预占一次token、duration 等变量基准必须由执行器声明并强制最大量。缺少可信 hard max 时拒绝执行,不用正文长度或估算值代替。
- 已有相同 tool call 的预占请求按指纹稳定重放;真实 AgentToolCall 的用量/成本继续使用追加式幂等键。用量已写入但成本失败时进入 `committed_reconciliation_required`,重试只补缺失成本,不重复占用额度或追加用量。
- 权益已有用量后,配额、定价和有效期不可回改,只允许暂停/恢复;结构变化必须新建订阅版本。
- 客户 ROI 只使用财务确认 canonical 现金节省与客户收费。
- 贡献毛利只使用平台收费与内部成本,不混入客户节省。
- 定价只在相同币种内计算,并保留成本/价值证据状态。
### 数据
- `tenant_commercial_plans`:租户、套餐编码、版本、价格模型、基础费、币种、生效期和合同条款摘要。
- `tenant_subscriptions`:租户、套餐、周期、基础费快照、状态、席位、外部引用和版本。
- `commercial_entitlements`:租户、订阅、权益键、计量键、配额、状态和有效期。
- `usage_meter_events`:追加式用量、冲回引用、幂等键、请求指纹和关联链。
- `commercial_cost_events`:追加式内部成本、原币/报告币、汇率、分摊键和冲回引用。
- `commercial_runtime_reservations`:工具执行前的可变运营占位,保存 tenant/subscription/entitlement/run/tool call、基准、预占量、真实量、周期、配置快照、状态和补偿原因它参与配额但不是客户用量事实。
- `commercial_billing_periods`:不可变账期签发事实,保存订阅/套餐引用、窗口、顺序、币种、基础费、席位、计价模式和来源快照PostgreSQL 禁止更新和删除。
- `commercial_admin_events`:套餐、订阅、权益、账期和续期动作的脱敏追加审计,保存 tenant、actor、`X-Request-Id`、原因、动作、资源版本和白名单 before/after。
- `usage_meter_events.period_key` 表示真实账期键,`quota_period_key` 独立表示权益重置周期;成本与运行时预占同样通过 `billing_period_id` 绑定账期,避免订阅当前周期滚动后污染历史。
- 事实表在 PostgreSQL 使用触发器禁止 UPDATE/DELETE租户复合外键防止跨租户引用。
运行时占位状态如下:
```text
reserved -> committed # 成功且 actual <= reserved
reserved -> released # 工具失败或执行前阻断
reserved -> expired # 运行已终止且没有真实工具调用
reserved -> reconciliation_required # 已发生调用但缺少执行前预占等人工补偿场景
committed -> committed_reconciliation_required -> committed
# 用量已提交、成本等后续事实失败,幂等补齐后恢复
```
过期但运行仍在进行、运行记录缺失或工具终态不确定时继续保留额度,不允许补偿器仅因 TTL 到期释放后造成超卖。
`committed_reconciliation_required` 已有真实用量事实,因此不再计入有效预占;配额只消费一次,同时保留明确的待补偿队列。
### 权限
- 平台管理员可配置所有租户商业账户,但商业配置不能授予财务确认或风险审批能力。
- finance/executive 只读本租户当前账户,不读取平台内部成本或其他租户数据。
- manager、employee 默认无商业账户与商业分析权限。
- 所有管理接口从认证上下文判断平台管理员,目标租户来自受控路径参数。
### 降级策略
- 无订阅:返回 unavailable 和明确说明,不自动赠送无限权益。
- 订阅非 active/trialing配额保留展示但最终消费失败关闭。
- 缺成本:贡献毛利和可持续价格下限不可用。
- 缺财务确认价值:客户 ROI、价值上限和成功费不可用建议继续试点采集。
- 多币种缺少共同币种:分别展示,禁止跨币种净额。
- 并发或幂等冲突:返回 409不覆盖首次事实。
- 生产中央工具路径未配置 runtime meter兼容执行且不生成商业事实配置只在当前订阅周期和权益有效期内参与门禁历史过期配置不会误触发 enforcement。
- 已发生的旧直接工具路径缺少执行前预占:不补写为正常用量,持久化 `reconciliation_required` 并冻结对应容量,等待受控补偿;即使当前合同已暂停,也保留其唯一可验证的租户、订阅和权益归属。
- 用量已追加但内部成本写入失败:持久化 `committed_reconciliation_required`,配额以真实用量为准且预占归零;按相同 tool call 重试只补成本,完成后回到 committed。
- 自动续期只处理 `trialing/active + auto_renew`;月、季、年按自然月边界滚动。合同制、缺少可推导边界或下一完整账期越过 `ends_at` 时失败关闭,不创建部分账期或猜测续约。
- 调度器即使发生重复扫描或多进程竞争,也先获取 leader lease再锁定订阅账期窗口和幂等键的唯一约束保证同一周期最多签发一次。数据库触发器还会按租户与订阅获取事务级 advisory lock并拒绝任何半开区间重叠账期防止绕过服务层直接写入破坏时间线。
## 算法与公式
### 客户 ROI
```text
customer_roi = (verified_cash_savings - customer_charges) / customer_charges
```
- `verified_cash_savings` 只包含独立财务确认、canonical、已计冲回的现金节省。
- `customer_charges` 来自订阅基础费快照和有可信同币种单价的用量计费。
- 分母必须大于 0否则状态为 unavailable。
### 平台贡献毛利
```text
contribution_margin = customer_charges - internal_costs
contribution_margin_rate = contribution_margin / customer_charges
```
- 内部成本来自追加式成本账本,不使用估算页面数字。
### 可持续定价走廊
```text
minimum_sustainable_charge = internal_costs / (1 - target_margin_rate)
maximum_value_aligned_charge = verified_cash_savings * max_value_share
maximum_success_fee = max(0, maximum_value_aligned_charge - minimum_sustainable_charge)
```
-`minimum_sustainable_charge <= maximum_value_aligned_charge` 时,建议 hybrid基础费不低于成本下限成功费封顶为剩余价值空间。
- 只有成本证据时建议 subscription成本与价值都不足时建议 pilot_collecting。
- 成本下限高于价值上限时建议 optimize_unit_economics不自动提高客户报价。
## 测试方案
- 模型/迁移:租户复合外键、状态检查、金额符号、冲回引用和 append-only。
- 服务:套餐版本、订阅终态、权益不可回改、配额硬门禁、幂等、冲回和多币种。
- 权限平台管理员、租户财务、manager、employee 与跨租户访问。
- 分析:收费/成本/节省/毛利/ROI 分账,缺证据状态和 `as_of` 回放。
- 定价:可行区间、成本高于价值、仅成本、完全无证据和多币种。
- 前端:状态、权限、操作确认、空态、错误态、币种分组与生产构建。
- PostgreSQL并发配额、同幂等键、成本冲回单赢家和迁移完整性。
- PostgreSQL 账期0020→0021→0020 升降级、账期/审计 UPDATE/DELETE 拒绝、重叠账期 INSERT 拒绝、历史事实账期绑定和双线程续期单赢家。
- 运行时预占:无配置兼容、成功结算、失败释放、变量 hard max、真实量超预占、幂等重放、历史配置隔离、直接路径补偿和过期补偿。
- 所有命令在 `local-x-financial-linux` 容器内执行,单次最长 60 秒。
## 指标与验收
- [A1] 每个商业消费可追溯到租户、订阅、权益、用量事件、来源和 correlation。
- [A2] 并发不能突破硬配额;重复事件稳定重放,冲突内容被拒绝。
- [A3] 付费状态不能绕过安全或人工审核门禁。
- [A4] 客户 ROI、平台毛利、客户节省和工时价值在 API/UI 中不混算。
- [A5] 暂停、恢复、取消、过期和历史查询可操作,终态不可原地复活。
- [A6] 定价场景只使用真实同币种成本与确认节省;证据不足时不输出虚假价格。
- [A7] 相关后端、PostgreSQL、前端、构建、Ruff 与迁移验证在容器内通过。
## 风险与开放问题
- 真实计费仍需把 OCR、LLM、存储、连接器和支持运行事件自动接入用量/成本账本;手工管理员写入只能用于校验,不是最终生产采集。
- 中央 Orchestrator 工具已经执行前预占;绕过中央执行器的其他生产入口仍须逐一迁移到 permit 契约,当前只会形成可见补偿积压,不会伪装成正常计量。
- 合同制自动续期仍不推断新合同窗口;管理员或外部订阅连接器必须先提供显式续约事实,再创建后继合同/订阅版本。
- 首个客户的实际套餐金额、席位、包含量、毛利目标、价值分享比例和折扣需要商业负责人确认。
- 发票、税率、回款、坏账、渠道分成与收入确认尚未接入,当前 `customer_charges` 是合同/用量计费基准,不等同已收现金。
- 价值分享合同必须定义基线、排除项、冲回、确认人、封顶和争议期。
- 工时价值默认不进入现金 ROI只有客户确认活跃工时基线、角色成本和可释放比例后才单独披露。
## 本轮实现记录
- 2026-07-16完成五张商业事实表与 0016 迁移、套餐/订阅/权益、用量/成本、配额门禁、幂等和冲回服务。
- 2026-07-16完成客户收费、内部成本、贡献毛利、财务确认现金节省、客户 ROI 和工时价值分账分析;修正 ROI 为净收益口径并收紧角色、时区和权益历史不可变边界。
- 2026-07-16补齐订阅暂停/逾期/取消/过期、恢复和套餐/订阅/权益/用量/成本历史查询。
- 2026-07-16完成基于真实成本下限与确认价值上限的定价场景禁止风险暴露、预计节省或跨币种混入报价。
- 2026-07-16完成商业工作台五块能力、订阅生命周期确认、用量/成本与价值分账、按币种定价场景两步确认;全量前端 802 项、code-size 和生产构建通过。
- 2026-07-16一次性 PostgreSQL 17 并发配额、幂等和成本冲回 3 项通过;真实开票、回款与客户合同参数继续保留为外部试点边界,不以 mock 标记完成。
- 2026-07-16新增 `0019` 运行时预占迁移和中央 Agent 工具 permit在订阅/权益锁内按已用量加有效预占原子控额,成功按真实 AgentToolCall 结算,失败/阻断释放,变量用量超过预占拒绝写入。
- 2026-07-16新增持久补偿状态和过期补偿器旧直接调用缺少预占时冻结容量并进入 reconciliation_required运行终态不确定时不按 TTL 误释放;用量已提交但成本失败进入 committed_reconciliation_required相同预占与用量/成本均可幂等重试。
- 2026-07-16新增 `0021` 不可变账期和商业管理审计;订阅创建即签发首期,月/季/年到期由 leader 调度器和订阅行锁幂等滚动,合同制与越界周期失败关闭。
- 2026-07-16用量、成本、运行时预占和配额查询改为绑定 `billing_period_id`,并以独立 `quota_period_key` 保留权益重置语义;客户收费基础费改从账期快照聚合,不再读取可变订阅当前周期。
- 2026-07-16容器内商业/迁移前置定向 131 项、前端商业 35 项和 Vite 构建通过;一次性 PostgreSQL 17 商业并发 5 项通过。0021 的升级、模型/约束/触发器(含账期重叠阻断)/运行不变量及 0021→0020 降级通过;全链 44/45 唯一失败来自后继 0023 尚未接管的盲审临时表,不属于 0021。
- 2026-07-16容器内商业/Agent/迁移组合 183 项通过、1 项因未显式配置外部迁移库跳过;运行时定向 27 项、PostgreSQL 商业并发 4 项和 0019→0020 完整迁移循环通过;全局 code-size 仅剩共享 `RiskRuleGenerationService` 817 行既有门禁失败,本轮所有相关核心类低于 800 行。

View File

@@ -0,0 +1,87 @@
# 商业计量、客户 ROI 与可持续定价 开发 TODO
更新时间2026-07-17
## 使用规则
- 每项必须回链 `CONCEPT.md` 对应章节。
- 只有代码、接口或容器验证提供证据后才能勾选。
- 客户价值、平台收入、内部成本和工时估值必须分账mock 与手工事件不得标记为生产事实。
## 1. 调研与边界
- [x] [CONCEPT: 背景与问题] 明确商业权益、用量、成本、客户 ROI、平台毛利和定价不是同一事实。
证据:`CONCEPT.md`“背景与问题”“目标与非目标”。
- [x] [CONCEPT: 目标与非目标] 确认不硬编码客户价格、不混算风险暴露、不跨币种求和、不让付费绕过安全门禁。
证据:`CONCEPT.md`“目标与非目标”。
## 2. 契约与设计
- [x] [CONCEPT: 数据] 定义套餐、订阅、权益、用量和内部成本五类事实及状态。
证据:`commercial.py` 模型与 schema、`20260716_0016_commercial_metering.py`
- [x] [CONCEPT: 算法与公式] 定义客户 ROI、贡献毛利和可持续定价走廊公式。
证据:`CONCEPT.md`“算法与公式”、`commercial_analytics.py``commercial_pricing.py`
- [x] [CONCEPT: 权限] 定义平台配置、租户只读和商业/安全门禁分离。
证据:`commercial_access_policy.py``commercial_entitlements.py`
## 3. 后端实现
- [x] [CONCEPT: 数据] 新增五张商业表、复合租户约束、幂等、冲回和 append-only 迁移。
证据:`models/commercial.py``20260716_0016_commercial_metering.py`、迁移/模型测试。
- [x] [CONCEPT: 数据] 新增运行时预占运营表、状态约束、复合租户外键、全局 tool call 幂等和迁移所有权。
证据:`models/commercial_runtime.py``20260716_0019_commercial_runtime_reservations.py``commercial_migration_assertions.py`0019→0020 一次性 PostgreSQL 完整升降级循环通过。
- [x] [CONCEPT: 后端] 实现套餐版本、订阅、权益、配额、用量、成本与商业分析服务。
证据:`commercial_admin.py``commercial_entitlements.py``commercial_metering.py``commercial_analytics.py`
- [x] [CONCEPT: 生命周期与查询] 实现暂停、逾期、取消、过期、恢复与五类历史查询。
证据:`CommercialAdminService.transition_subscription()``commercial_queries.py``/commercial/admin/tenants/{tenant_id}/...` 分资源接口。
- [x] [CONCEPT: 定价走廊] 实现成本下限、确认价值上限、成功费封顶和商业模式建议。
证据:`commercial_pricing.py``POST /commercial/admin/tenants/{tenant_id}/pricing-scenarios`
- [x] [CONCEPT: 后端] 把中央 Orchestrator 工具接入执行前预占、真实 AgentToolCall 结算和失败释放。
证据:`orchestrator_tool_execution.py``agent_runs.py``commercial_runtime_bridge.py`;无配置兼容,配置后先 reserve 再执行,成功只追加真实用量/成本,失败和阻断不计量。
- [x] [CONCEPT: 降级策略] 持久化缺预占和计量故障补偿状态,并安全处理过期预占。
证据:`commercial_runtime_reservations.py``commercial_runtime_reconciler.py`;直接调用形成 reconciliation_required运行中/未知终态继续持有额度,终态无调用才过期释放;用量成功但成本失败形成 committed_reconciliation_required重试只补成本且不重复冻结额度。
- [x] [CONCEPT: 风险与开放问题] 把已识别的权威运行入口迁移到 permit 契约。
证据:中央 Orchestrator、`ocr_commercial.py``runtime_chat_commercial.py``financial_connector_commercial.py``expense_claim_attachment_commercial.py` 已接通预占/结算/释放;资源组合 63 项通过。
- [ ] [CONCEPT: 风险与开放问题] 对后续新增的知识库/ONLYOFFICE 存储、实施和支持等资源入口持续执行 meter 盘点,不允许绕过 permit。
证据要求:新增真实资源入口时提供权威数量口径、事务边界、成本来源和回归测试;当前不把未发生的未来入口伪装成已计量。
- [x] [CONCEPT: 风险与开放问题] 通过不可变 billing period 和幂等 rollover 实现 `auto_renew` 周期滚动。
证据:`20260716_0021_commercial_billing_periods.py``commercial_billing_periods.py``commercial_subscription_rollover.py``commercial_rollover_scheduler.py`;月/季/年自动滚动,合同制和 `ends_at` 越界失败关闭PostgreSQL 双线程仅生成一个账期,数据库触发器拒绝重叠账期。
- [x] [CONCEPT: 数据] 让用量、成本、运行时预占和配额历史绑定不可变账期,并分离配额重置键。
证据:`UsageMeterEvent``CommercialCostEvent``CommercialRuntimeReservation``billing_period_id`,以及 usage/reservation 的 `quota_period_key`;收费分析从账期快照读取基础费和币种。
- [x] [CONCEPT: 权限] 为套餐、订阅、权益和续期建立脱敏追加式审计与租户安全历史 API。
证据:`commercial_admin_events``commercial_admin_audit.py``commercial_billing.py`;管理写接口强制 `X-Request-Id` 和原因,普通 finance/executive 只能读本租户账期,审计仅平台管理员可读。
- [ ] [CONCEPT: 非目标] 对接真实开票/收款/订阅提供商并区分合同计费、已开票与已收现金。
证据:等待目标客户和 provider 选择,不使用 mock 冒充完成。
## 4. 前端实现
- [x] [CONCEPT: 前端] 完成商业工作台的账户、套餐/订阅、权益/配额、用量/成本、价值/定价五块。
证据:`CommercialWorkspace.vue` 组合账户、生命周期、权益、用量成本、价值分析与定价场景面板。
- [x] [CONCEPT: 前端] 接入订阅暂停/取消/恢复、历史查询和操作确认。
证据:`useCommercialWorkspace.js``CommercialSubscriptionLifecyclePanel.vue`;终态和定价均有确认步骤,操作后按租户重新加载。
- [x] [CONCEPT: 前端] 严格分开展示客户 ROI 与平台毛利,并支持多币种和证据缺口状态。
证据:`CommercialValueAnalysisPanel.vue``CommercialPricingScenarioPanel.vue``commercialWorkspaceModel.js`按币种分组null/unavailable 显示“不可用”,不跨币种合计。
- [x] [CONCEPT: 前端] 接入现有应用入口、权限态、移动端和生产构建。
证据:`OverviewView.vue`/顶部导航已接入“商业化管理”;商业定向 35 项、全量 web 802 项、code-size 与 Vite 2246 modules 构建通过。
## 5. 测试与验证
- [x] [CONCEPT: 测试方案] 后端模型、服务、HTTP、权限、生命周期、查询和定价回归通过。
证据:容器内 Ruff 通过;`test_commercial_models.py``test_commercial_services.py``test_commercial_endpoints.py` 当前 12 项通过。
- [x] [CONCEPT: 测试方案] 一次性 PostgreSQL 并发用量、原子预占、硬配额和成本冲回验证通过并记录当前命令结果。
证据:一次性 PostgreSQL 17 中 `test_commercial_concurrency_postgres.py` 4 项通过;两个并发工具竞争 1 份额度时仅一个 reservation 成功。
- [x] [CONCEPT: 测试方案] 运行时预占、结算、释放、幂等、变量上限、历史配置和补偿回归通过。
证据:`test_commercial_runtime_metering.py``test_commercial_runtime_reservations.py` 27 项通过;商业/Agent/权限/迁移相关组合 183 项通过、1 项因未显式配置外部迁移库跳过Ruff、compileall 和相关类 800 行检查通过。
- [x] [CONCEPT: 测试方案] 前端行为测试、全量 web 测试、code-size 门禁和 Vite 构建通过。
证据:商业定向 35 项、全量 web 802 项通过code-size 通过Vite production build 转换 2246 个模块。
- [x] [CONCEPT: 测试方案] 不可变账期、脱敏审计、自动续期和调度器验证通过。
证据:容器内商业/迁移前置定向 131 项、前端商业 35 项及 Vite build 通过PostgreSQL 17 商业并发 5 项通过含双线程续期单赢家0021 升级、重叠账期阻断、运行不变量和 0021→0020 降级通过。
- [x] [CONCEPT: 指标与验收] 逐项核对 A1-A7并回填最终文件、接口和容器证据。
证据:商业模型/服务/API/前端、硬配额、账期、生命周期、ROI/毛利分账和定价走廊均有回归;资源边界组合 63 项、PostgreSQL 商业并发 5 项、Web 全量 815 项及 Vite build 通过。
## 6. 商业与试点收尾
- [ ] [CONCEPT: 风险与开放问题] 用真实试点 30/90 天数据冻结目标毛利率、最大价值分享、包含量、超额策略和封顶。
- [ ] [CONCEPT: 风险与开放问题] 确认发票、税率、回款、坏账、渠道和收入确认边界。
- [x] [CONCEPT: 本轮实现记录] 同步更新上位闭环文档与工程验收手册,不删除证据不足项。
证据:上位 AI 闭环 TODO 与 `engineering-closure-and-production-readiness` CONCEPT/TODO 已区分工程完成、生产上线和真实试点。

View File

@@ -0,0 +1,226 @@
# 财务连接器与支付对账闭环 概念文档
更新时间2026-07-17
## 功能一句话
把经过租户、来源、密钥版本和请求路径共同认证的 production-mode 财务事件契约接入付款、ERP、对账与 Savings 闭环,同时让所有非生产回执严格停留在只读模拟事实层;真实外部现金仍以目标 provider 联调为准。
## 背景与问题
当前报销单可以由财务人员在平台内确认“已付款”,并能联动申请归档和 Savings 实现记录,但这只是内部业务状态,不是银行、支付平台或 ERP 的外部现金事实。若继续把单一状态当成真实回执,会留下重复付款、金额/币种错配、回执伪造、凭证缺失、对账异常未处置和虚假现金节省等风险。
本功能把外部财务系统接入收敛成统一、可审计的连接器契约。首期允许使用 mock adapter 验证协议和流程,但 mock 必须完整模拟签名、幂等、失败、重试、乱序、退款、凭证和对账差异,不以直接写“已付款”代替连接器事实。
## 目标与非目标
### 目标
- 建立租户隔离、来源可验证、追加式的支付/银行/ERP 事件账本。
- 支持支付批次、结算成功、支付失败、退款/冲回、ERP 入账凭证和对账结果。
- 只有单据、金额、币种、外部引用和签名均通过校验时,才推进报销付款状态。
- 把外部事件与 Expense Case、Business Event、Savings Evidence、归档事件用同一 correlation 链关联。
- 对重复、冲突、乱序和不完整事件 fail-closed并提供可人工处理的对账异常。
- 以版本化 activate/disable/rotate 状态机管理连接器密钥,所有配置动作形成不含密钥材料的追加式审计。
### 非目标
- 不自建银行清算、企业支付网络、税务开票网络或 ERP 总账。
- 不保存银行卡号、银行流水原文、完整付款人账号或连接器密钥明文。
- 不允许连接器绕过报销审批、风险门禁、租户权限或财务确认。
- 不把 mock 回执标记为生产级外部现金证据;运行环境和证据等级必须显式区分。
## 用户与场景
- 财务付款员:提交或查看付款批次,处理失败与待匹配回执。
- 财务复核员:复核金额/币种/收款主体和对账异常,确认或拒绝处置。
- 财务负责人/CFO查看已匹配、待对账、失败、退款和未入账金额。
- 平台管理员:配置连接器公钥/密钥版本、来源白名单和健康状态,但不能代替财务确认业务结果。
- 外部连接器:按租户和来源签名推送支付、银行或 ERP 事件,安全重试并读取幂等结果。
## 功能能力
- 连接器注册与密钥版本:来源、环境、允许事件、时钟偏差、启停和轮换状态。
- 统一事件信封tenant、provider、event ID、event type、occurred at、payload hash、correlation、signature version。
- 支付批次与回执:批次创建、提交、受理、成功、失败和部分成功。
- ERP 入账:凭证号、会计期间、入账时间、受控摘要和原始内容哈希。
- 对账:按单据、金额、币种和外部引用自动匹配;差异进入人工处置。
- 冲回:退款、撤销和补付使用新事件,不更新或删除原事件。
- 可观测性:最近成功时间、失败率、重试次数、积压、签名失败和对账差异。
## 方案设计
### 模块职责
- `financial_connector_auth`:验证来源、签名、时间戳、密钥版本和重放窗口。
- `financial_connector_ingestion`:规范化事件、计算指纹、幂等写入、生产/模拟分流和冲突检测。
- `financial_connector_simulation`:只读校验 test/mock/staging 回执,不创建或修改 Claim、对账、ERP、Business Event 或 Savings。
- `financial_connector_mock_adapter`:平台管理员显式触发的非生产场景适配器;用已激活配置的真实签名链确定性生成成功、失败、乱序、重复、冲突、退款和 ERP 回执,但不提供任意 payload 注入能力。
- `financial_connector_observability`:按租户和配置聚合最近成功、失败率、重试、积压、签名失败、冲突和对账异常;只读取最小化事实与追加式运行事件。
- `financial_connector_payment_evidence`:从 Claim 的付款事实中提取统一证据 DTO明确区分 production-mode 外部回执分类、非生产模拟回执和人工付款内部状态;分类本身不证明真实 provider 已接通。
- `financial_connector_config_lifecycle`:执行带 expected version 的激活、停用和原子密钥轮换。
- `payment_reconciliation`:匹配 Claim、金额、币种和状态生成 matched / exception 结果。
- `financial_connector_actions`:在可信匹配后调用现有付款动作;支付失败、退款和 ERP 入账分别旁写事件。
- `financial_connector_projection`:为财务工作台提供脱敏列表、详情、差异和健康度。
- 供应商 adapter 只负责供应商字段映射,不直接修改 Claim、Savings 或预算。
### 数据与契约
首期新增以下 migration-owned 表,均带 `tenant_id`
- `financial_connector_configs`provider、environment、allowed event types、secret/key version、status、last success/error只保存密钥引用或不可逆验证材料。
- `financial_connector_config_events`created、activated、disabled、rotation started/replacement created 的追加式配置审计;保存 actor、request、reason、expected version 和脱敏前后状态,不保存 `secret_ref`
- `financial_connector_events`:方向、事件类型、外部事件 ID、请求指纹、原始内容哈希、发生/接收时间、验证等级、处理状态、关联单据/Case 和错误码UPDATE/DELETE 禁止。
- `payment_reconciliation_cases`Claim、期望/实际金额与币种、匹配状态、差异、处置版本、负责人和最后事件;作为可变投影,历史动作另存事件。
- `payment_reconciliation_events`:创建、自动匹配、人工确认、拒绝、重开、退款和关闭的追加式审计事件,保存请求指纹和首次响应。
- `financial_connector_operational_events``0022`):保存 `replay / auth_failure / payload_conflict` 三类运行事实的 tenant/config/provider/environment、受控原因码、两类 HMAC 指纹、幂等键和发生时间;不保存原始 payload、签名、外部事件 ID、Claim 引用、correlation 或密钥材料。复合租户外键防止跨租户归属PostgreSQL 触发器禁止 UPDATE/DELETE存在运行事实时拒绝有损降级。
关键约束:
- `(tenant_id, provider, external_event_id)` 唯一。
- 配置从 `disabled/version=1` 创建;激活和停用必须命中 expected version轮换原子地产生新 active key version 并把旧版本置为 rotating。
- 同幂等键不同 payload hash 返回冲突,不能覆盖首次事件。
- 同一个运行事实 candidate 的补偿写入按 `(tenant_id, idempotency_key)` 幂等;不同 HTTP 尝试即使请求内容相同,也因可信发生时间不同而分别计数,避免把真实重放次数永久压成一次。
- Claim、Case、配置和对账记录使用复合租户外键。
- 退款/冲回必须引用同租户原结算事件。
- 生产事件必须通过已激活密钥验证test/mock/staging 即使签名和业务字段全部匹配,也只能生成 `projection_scope=simulation_only` 的连接器事实,绝不进入核心财务状态机。
### 接口
- `POST /api/v1/integrations/financial-events`:连接器签名事件入口;返回稳定接收/重放结果。
- `POST /api/v1/financial-connectors/admin/tenants/{tenant}/configs/{id}/activate|disable|rotate`带版本、actor、request ID 和 reason 的配置状态机。
- `POST /api/v1/financial-connectors/admin/tenants/{tenant}/configs/{id}/simulate`平台管理员运行确定性非生产场景production 配置和未激活配置 fail-closed。
- `GET /api/v1/financial-connectors/admin/tenants/{tenant}/config-events`:读取不含密钥材料的配置审计时间线。
- `GET /api/v1/financial-connectors/admin/tenants/{tenant}/observability`:平台管理员读取目标租户脱敏运行指标;从 config/event/reconciliation/operational event 聚合指定窗口真实值。
- `GET /api/v1/financial-connectors/observability`:财务角色读取当前租户脱敏运行指标;返回 `window_started_at / as_of / generated_at / source_revision`,以及 replay、认证失败、签名失败、payload conflict 的真实计数与最近发生时间。`0022` 起四类指标均标记 `available`,没有事实时真实返回零而不是“待采集”占位。
- `GET /api/v1/financial-connectors/payment-evidence/{claim}`:有单据读取权限的当前租户用户读取付款证据等级,不返回原始连接器内容。
- `GET /api/v1/financial-reconciliation/cases`:财务角色分页查看匹配与异常。
- `GET /api/v1/financial-reconciliation/cases/{id}`:查看脱敏证据和追加式时间线。
- `POST /api/v1/financial-reconciliation/cases/{id}/confirm`:独立财务确认差异或人工匹配。
- `POST /api/v1/financial-reconciliation/cases/{id}/reject`:拒绝错误回执并记录原因。
- 连接器配置管理接口仅平台管理员可用,业务确认接口仅财务角色可用,两类权限不互相继承。
### 匹配算法
1. 验证 tenant/provider/key version/timestamp/signature 和事件类型HMAC canonical request 同时绑定固定 HTTP method/path禁止共享密钥跨 provider 或 key version 重放。
2. 以 canonical JSON 生成 payload hash按外部事件 ID 与幂等键检查首次请求。
3. 通过受控引用解析 Claim不允许仅凭模糊姓名或备注自动匹配。
4. 校验 Claim 已完成审批且处于待付款;比较金额、币种、外部业务引用和事件方向。
5. 只有 `production_verified` 且完全一致时才自动 matched非生产来源只生成模拟投影任何生产差异进入 exception 且不改变 Claim。
6. matched 事件在同事务调用付款动作、记录 Business Event并把外部事件哈希作为 Savings 证据。
7. ERP posted 只表示入账不重复触发付款refund/reversal 追加负向业务与 Savings 冲回候选。
### 权限与安全
- 连接器入口不使用普通用户会话,使用租户绑定的签名认证;普通 Bearer token 不能伪装连接器。
- HMAC/签名比较使用常量时间函数,限制时间窗口并记录 nonce/外部事件 ID 防重放。
- 激活和轮换前由服务端解析 `secret_ref` 并校验至少 128-bit HMAC 密钥;数据库和审计 DTO 均不返回引用或明文。
- 日志、响应和 DTO 最多暴露外部引用后八位或不可逆摘要,不返回原始 payload、签名或密钥。
- replay 随成功重放事务提交;认证失败与 payload 冲突先回滚失败业务事务,再独立提交脱敏运行事实,审计写入异常不得覆盖原始 401/409 响应。
- 只有服务端按 tenant/provider/key version 解析出 active 配置,并成功解析该配置的服务端密钥后,才构造可归属的 operational context。缺认证头、请求 tenant 不一致、未知/未激活配置或密钥不可用均不接受客户端自报租户,只写不带租户归属的结构化警告;签名、时间窗和事件白名单失败才可安全归入已解析配置。
- 运行表只保存 `hmac-sha256:` 请求/外部事件指纹和 `sha256:` 幂等键。HMAC 输入可以包含外部 ID 和业务引用但这些原值不会进入表、DTO 或错误日志。
- 不可变连接器事实的 normalized payload 不再保存完整 `claim_reference``0020` 受控迁移会删除历史冗余值,保留内容哈希、受控 Claim ID 与必要尾号。
- 跨租户资源统一按 404 隐藏;配置管理员不能确认对账,付款申请人不能确认自己的异常。
- 连接器故障、未知密钥、签名异常、金额/币种不一致和数据库异常均 fail-closed。
### 状态转换
- 连接器事件:`received → verified → processed`,失败进入 `rejected`;事实本身追加只读。
- 连接器配置:`disabled → active → rotating → disabled`;正常启用走 `disabled → active`,轮换时旧 active 原子进入 rotating、新 key version 以 active 创建。
- 对账记录:`pending → matched | exception → confirmed | rejected`;退款可从 confirmed 进入 `reopened`,重新处置后关闭。
- Claim 仅在可信 `payment_settled + matched` 后从 `pending_payment` 进入 `paid`
- ERP 凭证从 `pending_posting` 进入 `posted | posting_failed`,不反向伪造支付成功。
### 降级策略
- 连接器离线:保留待付款,不自动标记已付;显示积压和最后成功时间。
- 回执乱序:先保存事实,等待前置事件或进入 pending不猜测状态。
- 回执冲突:保留首次事实并返回 409生成对账异常。
- ERP 未接入:付款事实可进入已付,但“已入账/凭证号”保持待采集。
- test/mock/staging返回 `simulation_only`,只保留追加式 connector fact/response projection不创建对账 Case、不修改 Claim/ERP/归档/Savings也不进入生产现金证明。
- 显式 mock adapter 只能选择预定义场景和当前租户 Claim事件 ID、correlation 和受控外部引用由 tenant/config/scenario/request ID 确定性派生。重复执行同一请求只产生稳定重放,不能借模拟接口注入生产配置或任意字段。
- 升级前若已有非生产事件且响应曾关联对账 Case`0020` 将其标记为 `legacy_nonproduction_effect_unknown` 供审计,不伪装成新策略下的无副作用模拟事实,也不在迁移中猜测性冲回历史财务状态。
### 兼容策略
- 保留现有人工“确认已付款”作为低等级内部证据;付款证据 DTO 使用 `internal_manual_payment`,生产连接器使用 `external_cash`,非生产连接器使用 `simulated_connector`/`staging_connector`。UI 必须同时展示来源标签和可信等级,不能只显示“已付款”。
- 新连接器路径复用现有幂等付款动作、Case 时间线和 Savings 实现服务,不复制第二套状态机。
- 现有 `risk_flags_json` 付款摘要继续只读兼容,新连接器事实进入正式事件表。
## 测试方案
- 单元签名、时间窗口、canonical hash、幂等重放、冲突、乱序和字段白名单。
- 权限:跨租户、普通用户伪造、管理员越权、申请人自证和密钥停用。
- PostgreSQL复合外键、唯一键、append-only、并发同事件、不同 payload 冲突和安全降级。
- 集成:申请 → 票据 → 报销 → 预审 → 审批 → 外部付款 → 对账 → ERP 入账 → 归档 → Savings 待确认。
- 反向:支付失败不推进、金额/币种错配不推进、重复回执只写一次、退款追加冲回。
- adapter逐场景验证确定性结果、重复请求、跨租户 Claim 隐藏、production 拒绝,以及 Claim/对账/ERP/Business Event/Savings 零副作用。
- 可观测性:验证租户隔离、财务/管理员权限、窗口边界、签名失败与重放计数,并断言 DTO/日志无原始 payload、签名和密钥。
- 运行事件:验证同 candidate 并发/补偿重试单赢家、不同请求尝试分别计数、冲突回滚后独立持久化、复合租户外键、HMAC 格式检查和数据库 append-only。
- 所有验证只在 `local-x-financial-linux` 容器内执行,每条命令最长 60 秒。
## 算法与公式
本能力不做概率预测,核心是确定性门禁:
```text
canonical_effect_allowed = (
verification_level == production_verified
AND signature_valid
AND tenant_provider_key_path_bound
AND claim_amount_currency_reference_match
)
```
任一条件为假都不得产生核心财务副作用;非生产环境无论其他条件是否为真,`canonical_effect_allowed` 固定为 false。
运行指标使用确定性窗口聚合,不从应用日志估算:
```text
operational_count(type, window) = COUNT(
tenant_id = current_tenant
AND event_type = type
AND window_started_at <= occurred_at <= as_of
)
operational_idempotency_key = SHA256(
source_revision, tenant, config, type, reason,
HMAC(request), HMAC(external_event), occurred_at
)
```
`source_revision=20260716_0022` 表示当前运行指标的数据源与聚合契约版本;它不是 provider 协议版本。发生时间属于本次接收尝试,因此同一 candidate 重试仍稳定,而新尝试会形成新的真实计数。
## 指标与验收
- 100% 外部结算事件具有租户、来源、签名版本、payload hash、外部 ID 和接收时间。
- 重复相同事件稳定重放,冲突 payload 100% 拒绝。
- 任何金额/币种/Claim/审批状态不一致均不会推进已付款。
- 外部支付、ERP 凭证、对账处置、归档和 Savings 证据可由 correlation 链回放。
- 连接器离线或异常时不出现虚假“已付款”“已入账”或现金节省。
- observability 响应 100% 标明窗口起止和 source revision三类运行事实按租户真实计数并提供各类最近发生时间。
## 风险与开放问题
- 首期真实 provider、签名算法、事件字段和 SLA 需要目标客户确认。
- 部分 ERP 只有批次级凭证,需要明确批次到单据的拆分和舍入规则。
- 多币种付款需要锁定汇率来源和会计期间;本功能不自行猜测汇率。
- 退款、补付、员工自担调整是否进入现金节省,仍需客户财务签字口径。
- 对账大额阈值及双人复核需按企业策略配置。
## 本轮实现记录
- 2026-07-16完成现有内部付款、申请归档、Business Event、Savings 实现链路盘点,并冻结统一连接器、安全认证、对账状态和完整 E2E 方案;实现与容器证据保留在同目录 TODO 中继续执行。
- 2026-07-16完成 `0017` 四表迁移、HMAC/密钥版本/时间窗认证、租户隔离、追加式事件、幂等冲突和对账投影;一次性 PostgreSQL 17 迁移循环与 2 项并发探针通过。
- 2026-07-16支付结算只在金额、币种、Claim、审批状态与来源全部匹配时复用正式付款动作ERP、失败、退款/冲回、Savings 失效和财务处置均进入同一 correlation 审计链,连接器服务/HTTP 7 项通过。
- 2026-07-16真实 provider、批次拆分、汇率、会计期间和大额双人复核仍由目标客户确认当前 mock/test 证据始终标记 simulated不冒充生产现金事实。
- 2026-07-16补齐 `0020` 配置版本与追加式审计迁移;新配置只能停用创建,激活前校验服务端密钥,轮换原子切换 key versionHTTP 时间线不暴露 `secret_ref`
- 2026-07-16把 test/mock/staging 六类事件收口到 `simulation_only` 只读投影生产事件继续完成付款、ERP、归档、Savings 和冲回,生产冲回也不能引用模拟原事件。
- 2026-07-16normalized payload 删除完整 `claim_reference`,历史冗余值由 `0020` 受控脱敏HMAC v2 继续以内容哈希和 tenant/provider/key version/path 证明请求边界。
- 2026-07-16容器内连接器/配置/费用价值链组合 16 项、PostgreSQL 并发 3 项、迁移静态 124 项及全新 PostgreSQL 17 完整升降级循环通过;独立 0019→0020 探针确认版本回填、历史脱敏和 legacy 不确定性标记符合契约。
- 2026-07-16新增显式非生产 adapter以 tenant/config/claim/scenario/request ID 派生稳定签名回执,覆盖成功、失败、乱序、重复、冲突、退款和 ERP 场景HTTP 与服务测试确认 simulation-only 且 Claim、对账、Business Event、ERP 与 Savings 零副作用。
- 2026-07-16新增租户级脱敏可观测性 API 与财务看板面板,现有 config/event/reconciliation 表提供最后成功、失败率、积压和对账异常真实值retry/auth_failure 在 `0022` 运行事件落地前明确显示 unavailable不伪造零值。
- 2026-07-16新增付款证据等级 DTO 和 UI 口径,通过 production-mode 签名契约的回执分类为 `external_cash`,人工确认标为低等级 `internal_manual_payment`,模拟/预发布回执明确不进入核心账;当前本地自签测试不作为真实现金或 provider 接通证据。
- 2026-07-17完成 `0022` 追加式运行事实迁移与服务接入,耐久记录 replay、可信归属后的 auth failure 和 payload conflict请求与外部事件只保存 HMAC 指纹,失败事务回滚后独立补偿提交。
- 2026-07-17可观测性改为返回 `20260716_0022` source revision、明确窗口、真实计数和最近时间同一 candidate 补偿重试幂等,不同 HTTP 尝试分别计数。迁移总头继续串到既有 `0023``0022` 不越界创建后继 AI 表。
- 2026-07-17全新一次性 PostgreSQL 17 完整迁移循环 51 项、连接器并发 4 项、后继盲审并发 7 项通过验证租户复合外键、HMAC 格式、运行事实 append-only 和并发单赢家。

View File

@@ -0,0 +1,76 @@
# 财务连接器与支付对账闭环 TODO
更新时间2026-07-17
## 使用规则
- 每项必须回链 `CONCEPT.md`;只有代码、迁移、接口或容器验证提供证据后才能勾选。
- 外部回执、内部付款状态、ERP 入账和财务确认必须分开mock 不得伪装成生产现金事实。
## 1. 契约与安全
- [x] [CONCEPT: 背景与问题] 盘点内部付款、申请归档、Business Event、Savings 实现和证据边界。
证据:`expense_claim_approval_flow.py``expense_claim_application_handoff.py``expense_cases.py``savings_realization.py` 只读审计。
- [x] [CONCEPT: 目标与非目标] 冻结签名事件、幂等、对账、ERP 凭证、冲回和 mock 环境边界。
证据:`CONCEPT.md`“目标与非目标”“数据与契约”“匹配算法”“降级策略”。
- [x] [CONCEPT: 权限与安全] 实现连接器签名认证、密钥版本、时间窗口、来源白名单和防重放。
证据:`financial_connector_auth.py``financial_connector_ingestion.py`HMAC 使用常量时间比较tenant/provider/key version/timestamp/method/path 均进入签名边界,共享密钥跨 provider/key version 重放被拒绝,相同事件幂等重放、冲突 payload 返回 409。
- [x] [CONCEPT: 权限与安全] 实现平台配置权限与财务处置权限分离、跨租户 404 和申请人自证拒绝。
证据:`financial_connectors.py``financial_connector_projection.py`HTTP/服务回归覆盖普通用户、平台管理员、财务角色、跨租户隐藏与申请人自证拒绝。
## 2. 数据与迁移
- [x] [CONCEPT: 数据与契约] 新增配置、外部事件、对账投影和对账事件模型。
证据:`models/financial_connector.py``schemas/financial_connector.py`
- [x] [CONCEPT: 数据与契约] 新增后继 Alembic 迁移、迁移所有权、复合租户外键、唯一/检查约束和 append-only 触发器。
证据:`20260716_0017_financial_connector_reconciliation.py``schema_ownership.py``migration_preflight.py`;一次性 PostgreSQL 17 完整迁移循环通过。
- [x] [CONCEPT: 数据与契约] 实现外部事件与退款/冲回引用、首次响应和 payload 指纹冲突。
证据:`financial_connector_ingestion.py``payment_reconciliation.py`;同外部事件不同 payload 拒绝,退款/冲回必须绑定同租户已处理结算原事件。
- [x] [CONCEPT: 数据与契约] 新增配置 version、追加式配置审计和历史 normalized payload 脱敏迁移。
证据:`20260716_0020_financial_connector_config_lifecycle.py``FinancialConnectorConfigEvent`;配置审计使用 PostgreSQL append-only trigger历史 `claim_reference` 从不可变事件的 normalized payload 中受控移除,内容哈希继续保留;全新 PostgreSQL 17 完整升降级循环 1 项通过,另一个独立库验证 0019→0020 历史数据脱敏与 legacy 标识。
## 3. 服务与接口
- [x] [CONCEPT: 模块职责] 拆分认证、ingestion、reconciliation、action 和 projection 服务,核心文件不超过 800 行。
证据:`financial_connector_auth.py``financial_connector_ingestion.py``payment_reconciliation.py``financial_connector_actions.py``financial_connector_projection.py` 职责独立,最大核心文件低于 800 行。
- [x] [CONCEPT: 接口] 实现统一事件入口、连接器配置、对账列表/详情、确认和拒绝接口。
证据:`api/v1/endpoints/financial_connectors.py`
- [x] [CONCEPT: 接口] 实现带 expected version、actor、request ID、reason 的 activate/disable/rotate 状态机与脱敏审计查询。
证据:`financial_connector_config_lifecycle.py``financial_connector_config_audit.py``FinancialConnectorConfigLifecycleAction``FinancialConnectorConfigRotateAction`;激活/轮换前解析服务端密钥并校验强度,轮换原子切换新旧 key version。
- [x] [CONCEPT: 匹配算法] 完全匹配时复用现有幂等付款动作;任何金额、币种、单据或审批状态差异不产生付款副作用。
证据:`FinancialConnectorActionService` 复用 `ExpenseClaimService.mark_claim_paid_from_connector()`;反向测试验证 mismatch/failure/conflict 无付款副作用。
- [x] [CONCEPT: 证据与审计] 把外部事件哈希写入 Expense Case/Business Event/Savings 证据 correlation 链。
证据连接器结算动作写入脱敏内容哈希、verification/evidence classification 与 correlation服务 E2E 可回放付款、Case、Business Event 和 Savings evidence。
- [x] [CONCEPT: 状态转换] 实现支付失败、ERP posted/posting_failed、退款/冲回和对账重开。
证据:`PaymentReconciliationService` 对六类生产事件分流ERP 不重复付款,生产退款/冲回恢复 Claim 并追加 Savings 冲回事实。
- [x] [CONCEPT: 降级策略] test/mock/staging 六类事件只写 simulation-only connector fact/response projection不修改核心财务状态。
证据:`financial_connector_simulation.py``financial_connector_ingestion.py``test_financial_connector_services.py` 参数化覆盖三类非生产环境和六类事件Claim、申请归档、对账、Business Event、ERP 与 Savings 均保持不变。
## 4. Mock 与可观测性
- [x] [CONCEPT: 降级策略] 实现明确标识 test/mock 的 adapter覆盖成功、失败、乱序、重复、冲突、退款和 ERP 回执。
证据:`financial_connector_mock_adapter.py``FinancialConnectorSimulationCreate/Read` 与平台管理员 simulate API仅 active test/mock/staging 可运行tenant/config/claim/scenario/request ID 确定性派生事件production、disabled 和跨租户请求 fail-closed7 场景服务/HTTP 回归通过。
- [x] [CONCEPT: 可观测性] 从现有事实输出最后成功时间、失败率、积压和对账异常,并在安全 DTO/UI 声明未采集指标。
证据:`financial_connector_observability.py`、当前租户/管理员 observability API、`FinancialConnectorHealthPanel.vue`;所有查询先绑定 tenant仅聚合 config/event/reconciliation 最小化事实,不返回原始 payload、签名和密钥。
- [x] [CONCEPT: 可观测性] 用后继 `0022` 追加式运行事件补齐 replay、auth_failure 和 payload conflict 耐久计数。
证据:`20260716_0022_financial_connector_operational_events.py``financial_connector_operational_events.py``financial_connector_auth.py``financial_connector_ingestion.py``financial_connector_observability.py`;只有可信配置与服务端密钥解析后才归属认证失败,表内只保存 HMAC 指纹;同 candidate 重试幂等、不同接收尝试分别计数API 返回窗口、source revision、真实数量和最近时间。
- [x] [CONCEPT: 兼容策略] 保留人工付款为低等级内部证据,并在 DTO/UI 区分外部回执和内部确认。
证据:`financial_connector_payment_evidence.py`、payment-evidence API、`FinancialPaymentEvidenceRead` 与面板证据口径;人工付款=`internal_manual_payment`,通过 production-mode 契约验证的外部回执分类=`external_cash`,非生产回执=`simulated_connector|staging_connector`。真实 provider 仍待联调。
## 5. 测试与验收
- [x] [CONCEPT: 测试方案] 签名、防重放、幂等、冲突、字段白名单和错误恢复单元测试通过。
证据:`test_financial_connector_services.py``test_financial_connector_endpoints.py``test_financial_connector_config_lifecycle.py` 与费用价值链 E2E 共 16 项通过;乱序原事件缺失保守进入 exception不推进付款。
- [x] [CONCEPT: 测试方案] PostgreSQL 迁移、复合租户约束、append-only、并发和安全降级验证通过。
证据fresh PostgreSQL 17 最终迁移总探针 62 项通过;`financial_connector_migration_assertions.py` 验证配置/事件/运行事实 trigger、租户约束、HMAC 格式和 version check`test_financial_connector_concurrency_postgres.py` 4 项通过,覆盖同事件、冲突补偿事实、配置单版本胜者和 operational candidate 单赢家;最终 head 为 `20260717_0028`
- [x] [CONCEPT: 测试方案] 申请 → 票据 → 报销 → 预审 → 审批 → 外部付款 → 对账 → ERP 入账 → 归档端到端通过。
证据:`test_expense_financial_value_chain_e2e.py` 使用测试密钥自签 production-mode HMAC 事件覆盖申请审批、报销审批、ERP 入账、独立财务确认、Savings、商业价值与退款冲回契约与连接器定向组合共 16 项通过,不代表真实 provider 回执或现金。
- [x] [CONCEPT: 测试方案] 支付失败、金额/币种错配、重复回执和退款反向链路通过。
证据:`test_financial_connector_services.py` 覆盖 production-mode 失败/错配无副作用、稳定重放、ERP、reversal/refund 追加 Savings 冲回,以及非生产回执完全不创建 Savings。
- [x] [CONCEPT: 容器验证] 相关 pytest、Ruff、前端测试和构建均在 `local-x-financial-linux` 内通过。
证据:历史连接器/配置/费用价值链/迁移组合 `140 passed, 1 skipped`adapter/观测/证据 DTO 与既有连接器回归 `21 passed, 3 skipped`。2026-07-17 的 `0022` 收尾在容器内新增/定向回归 `115 passed`,全新 PostgreSQL 17 完整迁移循环 `51 passed`,连接器并发 `4 passed`,后继 `0023` 并发 `7 passed`;相关 Ruff、文件行数和全树 `git diff --check` 通过。历史连接器前端 3 项与 Vite 生产构建已通过;共享前端曾有 5 项旧路径测试失败,已单独记录且不属于本切片。
## 6. 客户配置待确认
- [ ] [CONCEPT: 风险与开放问题] 确认首个 provider、签名算法、字段映射、事件 SLA 和重试窗口。
- [ ] [CONCEPT: 风险与开放问题] 确认批次到单据映射、多币种汇率、会计期间、大额双人复核和退款口径。

View File

@@ -0,0 +1,373 @@
# 节省事实账本与 CFO 经营价值看板 概念文档
更新时间2026-07-17
## 功能一句话
把费用优化从“发现风险和预计能省”推进为“执行、实际结果、独立财务确认、可回放冲回”的事实账本,并让 CFO 只看到有来源、有基线、有证据、可去重的企业价值。
## 背景与问题
- 现有财务看板能够回答支出、单量、待付款、预算使用和风险分布,但不能可信回答企业已经节省了多少钱。
- 风险观察金额、暂缓付款、未使用预算和未执行建议都不是现金节省;如果直接汇总,会形成虚假 ROI。
- 当前最接近真实节省的链路是“住宿超标准 → 用户接受职级标准重算 → 审批 → 付款”。它已经真实降低报销金额,但尚未形成独立机会、付款后结果、财务签字、去重和冲回记录。
- `ExpenseClaim`、预算和应付旧表没有结构化租户键Claim 只能通过 `ExpenseCaseLink` 判断租户;旧 `FinanceDashboardService` 仍有全表读取和跨租户快照复用风险,不能作为客户 ROI 的事实源。
- 现有 `accept_standard_adjustment` 优先使用客户端传入的原金额,客户端理论上可以放大差额;任何节省计算必须改为只使用服务端锁定的明细金额和政策计算快照。
- 当前“已付款”是财务人员在系统内确认的业务状态,尚没有银行流水、支付回执或 ERP 凭证。因此付款只能把机会推进到“实际结果待确认”,不能直接进入财务确认 KPI。
- 报销提交时间到同租户首个 `payment_completed` 业务事件可以形成可审计的端到端流程周期,但它包含等待与系统处理,不是人工活跃工时;在没有人工计时基线、角色成本和客户认可释放比例前,工时价值必须显示“待采集”,不能伪造为 0 或现金节省。
本方案是 `2026-07-13/feature/ai-expense-closed-loop-and-value-proof` 中 P2“费用经营与价值证明”的实施拆分。
## 目标与非目标
### 目标
- [G1] 建立租户安全的 Savings Ledger完整区分风险暴露、预计机会、执行中、实际结果、财务确认和冲回。
- [G2] 建立不可变基线和证据链,所有金额都能追溯到费用事件、单据明细、政策版本、执行动作、付款事件和确认人。
- [G3] 建立经济收益去重键和归因约束,避免同一单据、付款义务或政策差额被多个风险重复计入。
- [G4] 建立严格状态机、乐观版本、幂等响应和数据库并发约束,防止重复确认、陈旧操作和跨租户访问。
- [G5] 建立 CFO 价值看板,分开展示财务确认现金节省、可释放工时价值、安全智能直通率、经营漏斗和风险护栏。
- [G6] 支持部门、项目、费用类型、供应商、城市、时间、负责人、来源和单据下钻,并显示数据覆盖、口径和新鲜度。
- [G7] 持久化费用基线快照,记录窗口、样本量、算法版本、政策版本、来源指纹和数据质量。
- [G8] 修复旧财务聚合与快照的租户边界,禁止真实接口失败时回退成看似真实的演示数字。
### 非目标
- [NG1] 不把风险关联金额、暂缓付款金额、未采纳建议、未使用预算或预计金额计入已确认节省。
- [NG2] 首个切片不宣称已具备外部银行或 ERP 付款凭证;后续通过连接器补齐。
- [NG3] 首个切片不使用缺少租户、合同价、采购数量和付款凭证的 `AccountsPayableRecord` 计算供应商节省。
- [NG4] 不把申请金额与最终报销差额默认归因给 AI缺少具体 AI 决策、采纳动作和结果链时AI 归因金额为 0。
- [NG5] 不把流程经过时长换算为人工工时,不直接暴露个人薪酬或个人成本。
- [NG6] 不跨币种直接求和;没有锁定汇率的金额只按原币展示并进入数据质量提醒。
- [NG7] 不删除、覆盖已确认收益;补付、退款、申诉或归因修正使用追加负向冲回事件。
- [NG8] 不在本阶段重写整个 Overview也不把不可信的预算中心模拟数据接入价值看板。
## 用户与场景
### 目标用户
1. CFO/管理层:查看企业已经确认的现金价值、价值兑现速度和风险护栏。
2. 财务运营:复核机会、实际结果、重复归因、凭证和冲回事项。
3. 费用治理负责人:领取机会、执行动作、补充结果和跟进逾期。
4. 预算负责人:只在授权部门或成本中心范围内查看机会与驱动。
5. 审计/风控:回放基线、政策、执行、付款、确认、冲回和操作事件。
6. 普通员工:仅在自己的费用事件中看到与本人相关的调整说明,不访问企业 CFO 汇总。
### 核心场景
1. 员工接受住宿职级标准重算。服务端锁定明细原金额、城市、天数、职级、政策版本和可报销上限,同事务生成唯一节省机会。
2. 机会进入执行后仍只展示预计金额;审批未通过、单据取消或超过期限时保留失败/到期事实,不从兑现率分母中消失。
3. 单据完成付款业务事件后,系统根据冻结差额记录实际结果,但不进入财务确认 KPI。
4. 与机会负责人和结果填报人不同的财务人员检查证据、去重、币种和成本后确认;确认后才计入 CFO 现金节省。
5. 后续发生例外补付或申诉时,追加负向冲回并保留原确认,历史月报按报告 `as_of` 可回放。
6. CFO 从价值总览下钻到部门、项目、费用类型、城市、负责人和具体单据,查看基线、建议、执行、实际、确认人和证据。
7. 费用治理负责人查看异常集中维度和只读政策模拟准备项;历史中位数只能作为异常信号,缺少正式政策反事实时不显示预计节省,也不自动创建机会。
### 异常场景
- 服务端政策无法计算、明细金额缺失或差额不为正:原报销流程可继续,但不创建可货币化节省机会。
- Claim 没有合法 `ExpenseCaseLink` 或租户不一致fail-closed不自动归入默认租户。
- 相同请求重复发送:返回首次不可变响应;相同请求 ID 内容不同409 拒绝。
- 陈旧版本、重复付款事件或重复经济收益:通过版本锁、事件唯一键和收益去重键拒绝。
- 缺少付款/凭证、汇率、独立确认或证据不完整:停留在实际待确认,不进入主 KPI。
- 看板接口失败、无权限、无数据、基线不足或快照过期:分别展示明确状态,绝不使用模拟数字伪装真实指标。
## 功能能力
- [C1] 机会发现:从服务端核验的政策调整、后续分析洞察或风险复核创建机会。
- [C2] 状态管理:支持 identified、accepted、in_progress、realized、verified、rejected、expired 和 reversed 事实。
- [C3] 实现记录:保存实际毛收益、新增执行成本、净收益、发生时间、币种和结果证据。
- [C4] 财务确认:独立确认人复核去重、证据、汇率、成本和归因后签字。
- [C5] 证据与审计只追加事件、内容指纹、before/after、首次响应和 correlation 全链路回放。
- [C6] 基线快照:按员工、部门、费用类型、供应商、城市、项目和流程持久化窗口、样本量和版本。
- [C7] 价值分析:经营漏斗、兑现率、周期、逾期、来源、责任人和数据质量。
- [C8] CFO 看板真实指标、全局筛选、URL 恢复、下钻、移动端和口径抽屉。
- [C9] 安全边界:租户、角色、数据范围、自证禁止、管理员业务权限分离和字段白名单。
- [C10] 冲回能力:补付、退款、申诉或归因修正只能追加负向记录,不改历史。
## 方案设计
### 前端
- 在现有“分析看板”增加 `value` / “经营价值看板”,复用统一时间筛选,不新增一级导航。
- `OverviewView.vue` 只负责挂载独立 `CfoValueDashboard.vue`;价值加载、筛选和展示模型拆到 `useCfoValueDashboard.js``cfoValueDashboardModel.js``analyticsValue.js`,避免继续扩大接近 800 行的 `useOverviewView.js`
- 默认视图从上到下为:主 KPI 与护栏、价值漏斗、现金节省趋势、来源/组织驱动、机会执行表、数据质量和口径说明。
- 全局筛选只保留时间、部门、费用类型和价值类型;项目、供应商、城市、负责人、状态和置信度进入高级筛选。
- 看板状态同步到 URL query当前机会使用 `value_opportunity` 保存,刷新、浏览器前进/后退和分享链接能够恢复同一抽屉。非法 ID、403/404、跨租户不可见或不再符合当前筛选/时间窗口的机会会 fail-closed 清理,避免残留上一租户详情。
- 机会详情展示基线、建议、执行、实际结果、财务确认、去重与证据时间线;证据只使用服务端可见性 DTO。来源动作由独立 helper 根据 Claim、Expense Case、AI Decision、维度和 Evidence Resource 构造,不在抽屉组件内拼接路由规则。
- 单据来源进入 `app-document-detail`;风险来源优先进入关联单据,并只携带风险 focus、观察/决策 ID 与现有锚点。详情返回动作恢复 `dashboard=value`、时间窗口和 `value_*` 查询。
- 预算来源进入 `app-budget` 的“预算配置视图”,按授权范围应用部门和费用类型焦点;页面明确说明配置、阈值及当前演示金额不是该机会的真实预算事实。未配置的费用科目显示“未找到配置”,不得解释为预算为零。
- 部门、项目、费用类型、供应商、城市、负责人和来源维度可返回 CFO 看板相应筛选;切换维度时移除旧机会 ID避免筛选与抽屉详情不一致。
- 实际结果登记必须具备真实付款事件或可追溯外部凭证;外部凭证上传/连接器尚未接入时,前端隐藏无证据手工登记并解释下一步,不发送必然失败或可能污染价值账本的空证据请求。
- 真实为 0、无数据、基线不足、无权限、接口失败、部分数据和快照过期使用不同状态。
- 禁止复用 `data/metrics.js``BudgetCenterView` 静态种子或遗留 `demoTotals` 作为 CFO 真实回退。
### 后端
- `SavingsDiscoveryService` 只负责从可信业务事实发现/创建机会,不提交事务。
- `SavingsActionService` 负责机会状态动作、版本、权限、幂等和事件。
- `SavingsRealizationService` 负责付款后实际结果、财务确认、拒绝和冲回。
- `SavingsQueryService` 负责租户安全分页、详情和可见动作投影。
- `SavingsFactScopeReader` 只读取当前租户与授权部门中的已归档报销事实,并以报告窗口和 `as_of` 排除未来单据、修改和完成事件。
- `SavingsBaselineGenerationService` 分开冻结金额中位数与流程历时中位数;流程只使用提交时间和首个付款完成业务事件,指标固定为 elapsed minutes。
- `SavingsAnomalyAttributionAnalyzer` 只生成描述性异常集中归因和政策模拟准备项,不声称因果,不写 `SavingsOpportunity`
- `CfoValueAnalyticsService` 只从 Savings Ledger、风险事实和明确资格快照聚合不从 UI mock 或风险金额推导节省。
- 标准重算只使用数据库行锁中的 `ExpenseClaimItem.item_amount` 作为原金额;客户端原金额和可报销金额只可作为展示输入,不能成为节省事实。
- 标准重算在同一事务内写 Claim 调整、机会、证据、Savings 事件和 `saving_opportunity_created` 业务事件API 边界统一提交。
- 付款动作在 Claim → Opportunity 的固定锁顺序中创建 actual realization 和 `saving_action_completed`,与 `payment_completed` 同事务。
- 财务确认写 `saving_confirmed`,拒绝和冲回写对应只追加事件;相同请求安全重放。
-`/analytics/finance-dashboard` 必须接收可信 `CurrentUserContext`Claim 通过 `ExpenseCaseLink` 限定租户;快照键至少包含租户与数据权限范围,后台任务必须显式指定租户。
### 算法与规则
#### 第一条可信机会
```text
server_original_amount = locked ExpenseClaimItem.item_amount
policy_target_amount = server policy calculator result
estimated_net_saving = max(0, server_original_amount - policy_target_amount)
```
- 仅当政策计算成功、输入快照完整、币种一致、差额大于 0 时创建可货币化机会。
- 机会唯一键首期为 `tenant + claim + item + policy_version + policy_input_fingerprint`
- 接受重算表示建议已采纳,机会进入 `in_progress`;付款完成后进入 `realized`,独立财务确认后进入 `verified`
- 员工自行承担差额同时是员工体验护栏,必须跟踪申诉和例外补付率,防止通过不合理转嫁美化节省。
#### 流程基线、异常归因与政策模拟
```text
workflow_elapsed_minutes
= first_tenant_payment_completed_event.occurred_at - claim.submitted_at
```
- 流程窗口按首个付款完成事件归属;提交时间缺失、完成早于提交、跨租户事件、`as_of` 之后完成或截止后被修改的单据全部排除。
- 流程快照使用 `median_submission_to_payment_elapsed_minutes``minutes` 单位、独立算法版本和来源指纹;证据元数据固定声明 `elapsed_cycle_not_active_labor`
- 异常归因按部门、费用类型、城市和项目聚合质量合格的历史偏离候选,只表示异常集中度,不表示该维度导致支出。
- 政策模拟候选只输出版本化政策、生效期、适用范围、限额和例外规则等必需输入;历史中位数不是政策反事实,缺少正式反事实时 `estimated_savings=None`
- 预算预测复用现有预算分配和核销事实,以 `min(as_of, window_end)` 为截止点;旧预算表没有租户字段时仅允许 default 租户,部门权限优先按稳定部门 ID 收紧。
- 供应商缺少核验 ID、数量和单位价格时继续返回 unavailable不读取 `AccountsPayableRecord` 演示或应付种子。
#### 收益去重
- `benefit_key` 表达同一个经济结果,不表达同一个风险观察。
- 多条风险可指向一个机会;同一发票、付款义务、报销明细或价格变化只能有一个 canonical 确认收益。
- 同一收益多个动作的归因比例之和不得超过 1。
- 确认后大额、超预计、手工基线、缺外部凭证和归因异常进入二次复核或数据质量队列。
#### 状态转换
```text
identified -> accepted -> in_progress -> realized -> verified -> reversed
| | | |
+-------- rejected -------+------------+
+-------- expired --------+
```
- `identified`:冻结基线、方法、价值类型、币种、预计净值、负责人、截止时间、去重键和来源证据。
- `accepted`:负责人明确采纳。
- `in_progress`:保存执行动作、执行人、开始时间和动作证据;预计值不得静默上调。
- `realized`:保存实际结果、净值、发生时间、归因和付款/结果证据,但不计主 KPI。
- `verified`:完成去重、币种、成本、证据和独立财务确认。
- `reversed`:追加负向冲回,原确认不可删除。
- `rejected/expired`:保留失败事实,防止只保留成功机会美化兑现率。
### 数据与契约
#### `profile_baseline_snapshots`
- 租户、基线类型、稳定维度 ID、指标、单位和原币。
- 基线值、窗口开始/结束、样本量、方法、查询指纹和数据质量。
- 算法版本、政策版本、冻结时间/人和有效期。
- 历史群组基线强制窗口与样本量;政策反事实基线强制政策版本、生效区间和目标明细。
- 金额基线按员工、部门、费用类型、城市和项目分组;流程基线按稳定流程键分组,使用独立 metric/unit不能与币种金额比较或求和。
#### `savings_opportunities`
- 租户、费用事件、Claim 软引用、来源类型/ID、类别和价值类型。
- 风险暴露只作护栏;基线、目标、预计毛收益、预计成本、预计净收益和区间分开保存。
- 原币、报告币、负责人、截止时间、状态、版本、`benefit_key` 和去重组。
- 部门、项目、费用类型、供应商、城市和流程维度使用明确快照字段或受控 JSON。
- 唯一约束至少覆盖 `(tenant_id, opportunity_key)`
#### `savings_realizations`
- 租户、机会、费用事件、Claim、BusinessEvent 和实际发生时间。
- 实际毛收益、新增执行成本、实际净收益、原币、报告金额和汇率快照。
- 归因方法/比例、`benefit_key`、去重状态和 canonical realization。
- 财务确认/拒绝/冲回人、时间、说明和证据。
- 只追加金额事实;确认投影可更新,但每次变更必须有不可变事件。
#### `savings_evidence_links` 与 `savings_events`
- 证据保存实体、证据角色、资源类型/ID、来源系统、外部事件 ID、内容哈希、发生/采集时间和验证状态。
- 事件保存动作、请求 ID、操作人、版本、指纹、before/after、首次响应、correlation 和时间。
- PostgreSQL 触发器禁止修改或删除 `savings_events`
### 权限
- `finance``executive` 可读取租户 CFO 汇总;预算负责人仅看被授权部门/成本中心。
- 普通员工、普通经理不得读取 CFO 汇总;只能看到本人费用事件中的最小调整说明。
- 机会接受、拒绝和指派需要 finance/executive 或明确负责人权限。
- 财务确认必须是 finance/executive且不能是机会负责人或实际结果填报人。
- 只有 `admin` 而没有财务角色时允许运维只读,不允许业务确认。
- 所有 API、聚合、快照、导出和后台任务强制 `tenant_id` 与数据范围;不允许默认全表扫描。
- Claim 通过 `ExpenseCaseLink` 校验租户;缺 Link 的非默认历史数据不自动猜测归属。
### 降级策略
- 政策或基线服务失败:不创建货币化机会,原报销主流程保留人工处理。
- 外部付款/ERP 连接器未接入:付款业务事件只能推进到 realized必须人工财务确认。
- 汇率缺失:保留原币明细,不进入跨币种总计。
- 工时基线缺失:显示“待采集”,不显示 0不计扩展 ROI。
- 流程历时可用但活跃工时缺失:只展示 elapsed cycle 驱动指标CFO 工时价值仍保持 collecting。
- CFO 聚合失败:显示错误和重试,不加载演示值;旧财务支出看板独立可用。
- 快照过期:展示过期提示并触发受控刷新,不能跨租户复用旧快照。
## 算法与公式
### 主 KPI 1财务确认净现金节省
```text
verified_net_cash_savings
= sum(actual_gross_saving - incremental_execution_cost + reversal_amount)
where value_kind = cash
and confirmation_status = finance_confirmed
and dedupe_status = canonical
and confirmed_at <= report_as_of
```
-`realized_at` 归属业务期间,按 `confirmed_at` 和报告 `as_of` 保证历史可回放。
- 风险暴露、预计金额、执行中金额和未确认实际金额不得进入。
- 多币种只有存在锁定汇率时才折算;否则按原币分组。
### 主 KPI 2财务确认可释放工时价值
```text
verified_releasable_labor_value
= max(0, baseline_active_minutes_per_unit - actual_active_minutes_per_unit)
* eligible_units
* approved_role_cost_per_minute
* approved_releasable_ratio
```
- 现金与工时分账、分卡、分报告,默认不相加。
- 缺少上线前后活跃分钟、角色完全成本、生效期或客户认可释放比例时不可计算。
### 主 KPI 3安全智能直通率
```text
safe_straight_through_rate
= qualified_completed_cases_without_manual_correction_or_return
and no_major_post_audit_issue
/ eligible_completed_cases_frozen_at_creation
```
- 必须保存 eligibility 快照、策略版本、必要审批完成和事后抽检结果。
### 驱动指标
- 现金兑现率:同一成熟机会队列的财务确认净现金 / 冻结预计净现金。
- 机会到财务确认 P50 天数、逾期负责人占比。
- 提交至首个付款完成的端到端 P50 elapsed minutes它与每单人工活跃分钟分开后者在采集前保持不可用。
- 人工触点、首次提交完整率和 AI 字段采纳率。
### 风险护栏
- 开放且已确认的高危/重大风险暴露,按单据或经济义务去重;它不是节省。
- 重大风险漏检率、事后审计重大问题率、误报率和人工覆盖率。
- 财务确认后冲回率、实际超过预计异常率、去重待复核金额和证据不完整金额。
- 标准重算员工申诉/例外补付率。
## 测试方案
### 后端
- 状态机合法/非法转换、确认人独立性、管理员只读和角色权限。
- 机会/实现/确认/冲回幂等、请求内容冲突、陈旧版本和租户隔离。
- 服务端明细金额锁定、客户端放大原金额无效、政策快照完整性。
- 付款事件重复、经济收益去重、跨币种、成本扣除、冲回和归因上限。
- 看板按租户、时间、部门、项目、费用类型、城市、来源和负责人聚合对账。
- 六维基线验证员工、部门、费用类型、城市、项目和流程的窗口、样本量、算法版本、来源指纹、租户/部门范围与稳定重放。
- 洞察验证预算截止点、描述性归因、政策模拟准备项、供应商 unavailable、所有无反事实候选 `estimated_savings=None` 且不会创建机会。
- 旧财务看板 Claim、预算和缓存键租户隔离回归。
- Alembic 空库升级、重复升级、约束、append-only 触发器、无损降级和 PostgreSQL 并发。
### 前端
- API snake/camel 归一化、部分数据和过期快照。
- verified、realized、estimated 和 risk exposure 严格分区,不得混算。
- loading/error/empty/partial/stale/permission-denied/baseline-missing 状态。
- 时间、部门、费用类型、价值类型筛选与 URL 恢复。
- 机会详情证据链、可用动作、版本冲突、幂等重放和确认反馈。
- 单据、风险、预算和维度下钻参数。
- 机会 `value_opportunity` 的恢复、关闭清理、非法格式、403/404、跨筛选和时间窗口清理。
- 风险来源最小定位参数、单据返回 CFO、预算配置焦点和“非真实预算金额”口径。
- 响应式、键盘操作、44px 触控目标和生产构建。
### 集成
- 住宿超标准 → 服务端重算 → 机会 → 审批 → 付款 → actual realization → 独立财务确认 → CFO 看板。
- 相同重算/付款/确认并发只产生一个经济收益和一条对应版本事件。
- 确认后补付/申诉 → 负向冲回 → 历史报告 `as_of` 可回放。
- 多租户同单号、同员工名、相同 request ID 和快照缓存隔离。
### 容器验证
所有 pytest、Alembic、PostgreSQL 并发、前端测试和构建必须在 `local-x-financial-linux` 容器内完成,单条命令超时不超过 60 秒。
## 指标与验收
- [A1] 任一 verified cash saving 可追溯到费用事件、明细原金额、政策快照、执行动作、付款事件、确认人、去重键和证据。
- [A2] 风险暴露、预计、执行中、实际待确认、财务确认和冲回在 API、数据库和 UI 中均不混算。
- [A3] 客户端伪造原金额、跨租户访问、陈旧版本、自证确认和重复经济收益被服务端拒绝。
- [A4] CFO 三个主 KPI 有口径、时间窗口、来源、新鲜度、数据覆盖和护栏;缺数据时明确“待采集”。
- [A4.1] 流程 elapsed cycle 有独立 metric/unit/算法版本/来源指纹,且不会进入工时价值或现金节省。
- [A5] 价值看板支持部门、项目、费用类型、供应商、城市、时间、负责人、来源和单据下钻。
- [A6] 真实接口失败不出现演示数字;零值、无数据、无权限、错误和过期可区分。
- [A7] 迁移在一次性 PostgreSQL 空库完成升级、重复升级、约束验证和安全降级边界测试。
- [A8] 相关后端、前端、Ruff、构建、端到端和并发测试全部在容器内通过。
## 风险与开放问题
### 风险
- 员工承担差额不一定等同企业创造价值;需要跟踪申诉、补付和政策公平性,避免激励扭曲。
- 当前付款是人工状态,不是外部现金事实;确认页必须清晰披露证据等级。
- 旧 Claim/预算缺租户列,读取必须经过 Case Link 或正式迁移,不能依赖默认租户猜测。
- 数据稀疏且包含模拟种子,试点目标值必须在真实基线采集后冻结。
- 当前预算中心仍是配置演示视图CFO 来源入口只用于定位部门/费用类型配置,页面和价值计算均不得把其中金额当成预算事实或节省事实。
- 多币种、分摊收益和跨期冲回会显著增加财务口径复杂度,必须保留原始事实和版本。
- 工时价值若没有活跃时间采集与客户认可成本率,容易被夸大,因此默认不计现金 ROI。
- 历史异常集中不是因果,历史中位数也不是政策反事实;模拟候选必须在正式政策版本和客户确认适用口径补齐后才可货币化。
### 已处理决策
- 首条闭环选择“住宿职级标准重算”,不选择缺少付款事实的重复支付阻止。
- 价值看板进入现有分析看板,独立拆组件和 composable。
- 只设 3 个主 KPI其余作为驱动和护栏现金与工时分开披露。
- Savings Ledger 直接带结构化租户键,不复用旧财务快照作为价值事实源。
- 流程基线采用提交至首个付款完成的端到端 elapsed cycle人工活跃工时继续作为独立待采集事实。
- 异常归因仅做描述性集中度,政策模拟候选保持只读,不自动写入节省机会。
### 待后续真实客户确认
- 独立财务确认是否要求双人复核及大额阈值。
- 报告币、汇率来源和月末汇率锁定规则。
- 工时价值是否进入扩展 ROI、角色成本口径和可释放比例。
- 标准重算差额在客户会计政策中属于现金节省、成本避免还是员工自担调整。
- 试点 30/90 天目标、CFO 月报签字人和节省分成合同边界。
## 本轮实现记录
- 2026-07-16完成现有财务、预算、风险、付款、标准重算、前端入口与 KPI 口径盘点;确认旧财务聚合租户边界和客户端原金额信任问题。
- 2026-07-16冻结 Savings Ledger、CFO KPI、状态机、权限、去重、证据和首条纵向闭环方案尚未完成的代码与验证全部保留在同目录 TODO 中继续执行。
- 2026-07-16完成 Savings Ledger 五表与 0015 迁移、租户/权限/幂等/并发/证据/冲回契约,并把住宿标准重算和付款动作接入同事务价值链。
- 2026-07-16完成独立财务确认、证据复核留痕、canonical 收益去重、负向冲回、`as_of` 回放和历史标准调整安全回填。
- 2026-07-16完成 CFO 价值分析 API只将已确认 canonical 现金结果计入主 KPI多币种分组工时、安全直通率和审计事实缺口显式披露不使用演示数据回退。
- 2026-07-16完成 CFO 经营价值前端入口、URL 筛选恢复、主 KPI、漏斗、趋势、驱动维度、护栏、数据质量、机会证据链与响应式状态详情明确展示财务确认人、时间和说明。
- 2026-07-16收紧无证据手工登记边界。真实付款事件仍可自动形成待确认实际结果在支付/银行/ERP 凭证连接器接入前,页面不再提交空证据,并向用户解释必须先完成付款或等待外部回执。
- 2026-07-16完成租户隔离的员工、部门、费用类型、城市和项目五维历史中位数冻结基线以及预算预测偏差、重复小额模式和历史中位数偏离候选非政策反事实信号只披露暴露不生成节省金额并补齐 `as_of` 基线时间一致性门禁。
- 2026-07-16补齐第六维流程周期基线以同租户首个付款完成业务事件冻结提交到完成的 elapsed minutes窗口、样本量、算法版本、来源指纹、质量状态和证据完整保存明确禁止推算人工活跃工时。
- 2026-07-16新增部门/费用类型/城市/项目异常集中归因和版本化政策模拟准备项;复用预算预测并收紧配置/交易截止点,所有缺少反事实的候选保持 `estimated_savings=None`、零机会副作用,供应商分析继续明确 unavailable。
- 2026-07-16完成机会抽屉 `value_opportunity` 深链、刷新/前进/后退恢复和非法/越权/跨筛选安全清理;来源动作可跳关联单据、风险证据位置、预算配置视图及 CFO 维度筛选,单据返回时恢复经营价值查询状态。
- 2026-07-16预算来源只应用授权部门与费用类型配置焦点未覆盖科目显示“未找到配置”所有入口和页面均明确演示预算金额不是当前机会的真实预算事实。

View File

@@ -0,0 +1,141 @@
# 节省事实账本与 CFO 经营价值看板 开发 TODO
更新时间2026-07-17
## 使用规则
- 每条 TODO 必须回链 `CONCEPT.md` 的章节或语义段落。
- 只有代码、接口或容器验证提供真实证据后才能勾选 `[x]`
- 现金节省、工时价值、风险暴露和预计机会始终分开,不以演示数据代替缺失事实。
- 实施顺序为:安全前置 → 账本 → 首条闭环 → 基线/分析 → CFO 前端 → PostgreSQL/E2E → 文档收口。
## 1. 调研与边界
- [x] [CONCEPT: 背景与问题] 盘点财务看板、预算、Claim、Expense Case、Business Event、风险、审批、付款和标准重算事实源。
证据:`finance_dashboard.py``finance_dashboard_snapshot.py``financial_record.py``expense_cases.py``expense_claim_standard_adjustment.py``expense_claim_approval_flow.py` 的只读审计。
- [x] [CONCEPT: 目标与非目标] 确认风险金额、未用预算、预计机会和未确认结果不得进入已确认节省。
证据CONCEPT「目标与非目标」「算法与公式」。
- [x] [CONCEPT: 用户与场景] 选择住宿职级标准重算作为首条纵向闭环,不选择证据不足的重复支付或供应商议价。
证据:现有标准重算和付款业务事件可形成最短可信证据链;`AccountsPayableRecord` 已有租户字段,但仍缺合同价、数量、单位价格和外部付款事实。
- [x] [CONCEPT: 风险与开放问题] 识别旧财务聚合全表读取、快照键缺租户、接口缺角色限制和标准重算信任客户端原金额问题。
证据:`FinanceDashboardMetricMixin._fetch_claims()``_fetch_budget_allocations()``FinanceDashboardSnapshotService._cache_key()``accept_standard_adjustment()`
## 2. 契约与设计
- [x] [CONCEPT: 数据与契约] 定义 baseline、opportunity、realization、evidence 和 append-only event 的职责与关键字段。
证据CONCEPT「数据与契约」。
- [x] [CONCEPT: 算法与规则] 定义 identified → accepted → in_progress → realized → verified → reversed 状态和 rejected/expired 终态。
证据CONCEPT「状态转换」。
- [x] [CONCEPT: 算法与规则] 定义 benefit key、canonical realization、归因比例和追加冲回规则。
证据CONCEPT「收益去重」。
- [x] [CONCEPT: 权限] 定义租户、finance/executive、预算范围、管理员只读、自证禁止和数据范围。
证据CONCEPT「权限」。
- [x] [CONCEPT: 算法与公式] 定义 3 个主 KPI、驱动指标、风险护栏和缺数据边界。
证据CONCEPT「算法与公式」。
## 3. 安全前置
- [x] [CONCEPT: 后端] 让旧财务看板显式接收可信租户与数据范围Claim/预算查询不得全表混算。
证据:`finance_dashboard_access_policy.py``finance_dashboard_scope.py``finance_dashboard_budget.py``finance_dashboard.py`Claim 直接按结构化 `ExpenseClaim.tenant_id` 首层过滤,预算历史兼容仅限 default 租户。
- [x] [CONCEPT: 后端] 给财务快照缓存键和后台任务加入租户/数据范围,禁止跨租户复用。
证据:`finance_dashboard_snapshot.py``finance_dashboard_scheduler.py`;快照键包含 tenant 与 scope fingerprint调度入口必须显式携带租户。
- [x] [CONCEPT: 权限] 给财务与 CFO 分析接口增加后端角色和范围校验,管理员身份不自动获得业务确认权。
证据:`finance_dashboard_access_policy.py``savings_access_policy.py``agent_run_access_policy.py``GET /api/v1/analytics/cfo-value`;容器租户/缓存/角色回归 17 项通过。
- [x] [CONCEPT: 第一条可信机会] 标准重算只使用锁定数据库明细原金额,客户端原金额不能影响结果和节省。
证据:`expense_claim_standard_adjustment.py`;原金额只读锁定 `ExpenseClaimItem.item_amount`,政策结果只由服务端重算,陈旧版本使用内容指纹并降低基线质量等级。
- [x] [CONCEPT: 后端] 将标准重算、审计和账本写入收口到同一事务边界。
证据:`expense_claim_standard_adjustment.py``commit=False` 写审计,同一 API 事务写 Claim、Baseline、Opportunity、Evidence、SavingsEvent 和 BusinessEvent事务失败整体回滚。
## 4. Savings Ledger 后端
- [x] [CONCEPT: 数据与契约] 新增 `profile_baseline_snapshots``savings_opportunities``savings_realizations``savings_evidence_links``savings_events` 模型。
证据:`savings.py`(模型)及 `test_savings_models.py`;五类事实分离保存基线、机会、结果、证据和不可变操作。
- [x] [CONCEPT: 数据与契约] 新增 Alembic 0015、迁移所有权、复合租户外键、唯一键、检查约束、索引和 append-only 触发器。
证据:`20260716_0015_savings_value_ledger.py``migration_preflight.py``schema_ownership.py`;一次性 PostgreSQL 17 空库完整迁移循环通过。
- [x] [CONCEPT: 后端] 实现 discovery、action、realization、query、access policy 和 response builder 独立服务。
证据:`savings_discovery.py``savings_actions.py``savings_realization.py``savings_query.py``savings_access_policy.py``savings_read_projection.py``savings_protocol.py`
- [x] [CONCEPT: 后端] 实现分页、筛选、排序、详情、可用动作、证据和事件 DTO。
证据:`GET /api/v1/savings/opportunities``GET /api/v1/savings/opportunities/{id}``savings.py`schema/API`test_savings_endpoints.py`
- [x] [CONCEPT: 算法与规则] 实现版本锁、请求指纹、不可变响应重放、收益去重和归因比例约束。
证据:`SavingsRequestProtocol`、数据库唯一/检查约束与 `test_savings_concurrency_postgres.py`;同 request 并发仅一次写入,同 benefit 双确认仅一个 canonical winner。
- [x] [CONCEPT: 状态转换] 实现 accept/start/record/verify/reject/expire/reverse 合法与非法转换。
证据:`savings_actions.py``savings_realization.py``test_savings_ledger_services.py`;负向 reversal 只追加,不改写原已确认金额。
- [x] [CONCEPT: 证据与审计] 把 Savings 关键动作写入同 Case 的 `BusinessEvent` 并保留 correlation/causation。
证据:`ExpenseCaseService` 资源链接与 `savings_*` 服务;端到端测试核验 opportunity/payment/action/confirm/reverse 事件同 Case 可回放。
- [x] [CONCEPT: 后端] 新增历史标准重算机会 dry-run/apply 回填脚本;缺 Case/租户/政策证据只输出数据质量报告。
证据:`savings_standard_adjustment_backfill.py``backfill_standard_adjustment_savings.py`;显式租户/目标库、指纹重算、批次锁、稳定键重放,容器直接测试 8 项通过。
## 5. 首条真实纵向闭环
- [x] [CONCEPT: 第一条可信机会] 接受住宿标准重算时冻结服务端原金额、政策输入/版本、目标金额、差额、维度和证据。
证据:`SavingsDiscoveryService`冻结 `ProfileBaselineSnapshot` 与内容指纹,政式发布版本为 complete内容指纹版本显式标记 partial。
- [x] [CONCEPT: 第一条可信机会] 同事务以稳定 opportunity key 创建或重放机会,并进入 in_progress。
证据:稳定键由 tenant + claim + item + policy version + calculation fingerprint 生成;重试返回原机会。
- [x] [CONCEPT: 后端] 付款业务事件后同事务创建 actual realization重复付款事件不重复计入。
证据:`expense_claim_approval_flow.py`调用 `realize_paid_claim()`PostgreSQL 同付款事件并发结果为 `[0, 1]`,仅一条 realization 和一条完成事件。
- [x] [CONCEPT: 财务确认] 实现独立财务 verify/reject未确认实际结果不得进入主 KPI。
证据:所有人工实际结果必须至少一条可追溯证据;独立确认人会固化证据复核人/时间pending 结果在 CFO 口径中为 0。
- [x] [CONCEPT: 冲回能力] 实现补付/申诉/归因修正的负向 reversal原确认不可删除。
证据:原 actual 保留 `finance_confirmed`,新增负向 canonical reversal`as_of` 可回放冲回前结果。
- [x] [CONCEPT: 权限] 验证申请人、机会负责人、结果填报人、纯管理员和跨租户用户不能自证或越权。
证据:`SavingsAccessPolicy`与 PostgreSQL 并发安全测试owner/recorder/admin-only 自证拒绝,独立 finance 可确认,跨租户无副作用。
## 6. 费用基线与经营分析
- [x] [CONCEPT: 基线快照] 按员工、部门、费用类型、城市、项目和流程从租户安全真实数据生成基线窗口、样本量和版本。
证据:`savings_fact_scope.py``savings_baseline_generation.py``savings_insights.py`;金额五维使用已归档明细中位数,流程维度只使用同租户报销提交时间与首个 `payment_completed` 业务事件,冻结 elapsed minutes、窗口、样本量、算法版本、查询指纹、质量、证据和审计事件`source_workflow_cycle_count` 明确披露覆盖,活跃工时保持 unavailable。
- [x] [CONCEPT: 基线快照] 供应商数据缺少租户/合同事实时显示 coverage gap不从应付模拟种子生成可信基线。
证据:基线和洞察 API 均返回 `supplier_dimension_unavailable` / `supplier_price_drift_unavailable`,要求核验供应商、数量和单位价格;不会读取 `AccountsPayableRecord` 模拟事实或创建货币化机会。
- [x] [CONCEPT: 算法与规则] 实现预算预测、描述性异常归因、重复小额浪费、历史偏离和只读政策模拟准备项;证据不足时不自动货币化。
证据:`savings_insight_budget.py` 复用预算配置/核销事实并以 `min(as_of, window_end)` 截止;`savings_insight_analysis.py``savings_insight_attribution.py` 输出历史偏离、部门/费用类型/城市/项目异常集中和版本化政策模拟必需输入。所有缺少正式反事实的候选 `estimated_savings=None``created_opportunity_ids=[]`,稳定重放不写 `SavingsOpportunity`
- [x] [CONCEPT: 后端] 实现 `GET /analytics/cfo-value`,统一时间、维度、币种、状态和 `as_of` 口径。
证据:`cfo_value.py`API/schema`cfo_value_analytics.py`;租户、角色、预算范围、时间和维度过滤均在服务端执行。
- [x] [CONCEPT: 算法与公式] 实现确认现金、工时待采集、安全直通率资格、漏斗、兑现周期、逾期、来源和护栏聚合。
证据:确认现金只求和 finance_confirmed + canonical + cash工时和安全直通率显式返回 collecting漏斗、趋势、驱动、逾期、冲回和数据质量不与主 KPI 混算。
- [x] [CONCEPT: 降级策略] 多币种、工时、外部付款和审计结果缺失时返回明确数据质量状态,不伪造 0。
证据:多币种按原币分组;无人工活跃时间/审计事实时返回 collecting/unavailable 和 coverage gap不使用 mock 回退。
## 7. CFO 前端
- [x] [CONCEPT: 前端] 在分析看板新增 `value` 入口,并把 dashboard 状态同步 URL。
证据:`useTopBarOverviewRange.js``AppShellRouteView.vue``OverviewView.vue``dashboard=value` 与价值筛选/页码写入 query刷新和返回可恢复。
- [x] [CONCEPT: 前端] 新增独立 CFO 组件、composable、API service 和展示模型,不继续扩大 `useOverviewView.js`
证据:`CfoValueDashboard.vue``CfoValueTrendChart.vue``CfoValueOpportunityDrawer.vue``CfoValueActionDialog.vue``useCfoValueDashboard.js``analyticsValue.js``cfoValueDashboardModel.js`;业务组件和状态职责已拆分,核心文件均低于 800 行。
- [x] [CONCEPT: CFO 看板] 实现主 KPI、护栏、价值漏斗、趋势、来源/组织驱动、机会表和数据质量。
证据:`CfoValueDashboard.vue``cfo-value-dashboard.css`;现金、工时和直通率分卡,趋势按币种切换,预计/实际/确认/冲回不混算,缺数据显式展示。
- [x] [CONCEPT: 前端] 实现时间、部门、费用类型、价值类型筛选和项目/供应商/城市/负责人高级筛选。
证据:顶部时间窗口与价值 query 联动,`createEmptyValueFilters``readValueFiltersFromQuery``writeValueFiltersToQuery` 和 API query 白名单覆盖全部筛选字段。
- [x] [CONCEPT: 前端] 实现基线、建议、执行、实际、确认、去重和证据详情。
证据:`CfoValueOpportunityDrawer.vue` 展示冻结基线、机会状态、实际净值、记录人、canonical 去重、财务确认人/时间/说明、证据索引和不可变事件;无可追溯凭证时不开放手工实际结果登记。
- [x] [CONCEPT: 前端] 实现单据、风险、预算、维度下钻与返回状态恢复。
证据:`cfoValueSourceLinks.js` 统一构造来源路由Claim 进入 `app-document-detail`,风险携带最小 focus/观察/决策参数和现有锚点,预算进入 `app-budget` 配置视图并应用部门/费用类型焦点,维度返回 `app-overview?dashboard=value` 相应筛选。`useCfoValueDashboard.js``value_opportunity` 恢复抽屉,并在非法 ID、403/404、跨租户不可见或不符合当前筛选/时间窗口时安全清除;`useAppShell.js` 从单据详情恢复 CFO 查询。限制:预算中心仍是演示配置视图,页面明确金额不是当前机会的真实预算事实;未覆盖费用科目显示未配置而不是零预算。
- [x] [CONCEPT: 降级策略] 区分零、无数据、基线不足、无权限、失败、部分数据和快照过期;删除 CFO 演示回退。
证据:`classifyCfoDashboardState``buildValueKpis` 与页面状态区;接口失败不读取 `data/metrics.js` 或 demo/fallback 数字。
- [x] [CONCEPT: 前端] 完成移动端、键盘、焦点、44px 触控和无障碍状态提示。
证据CFO 样式移动断点、44px 按钮、语义化 `label`/`role=alert`/`aria-live`、抽屉关闭标签及趋势表格降级;生产构建通过。
## 8. 测试与验证
- [x] [CONCEPT: 测试方案] 后端状态、金额、权限、租户、幂等、证据、去重、冲回和看板聚合单测通过。
证据:容器组合回归 84 项通过;本轮 Savings/CFO 基线、洞察、端点、账本、回填与 E2E 组合 `34 passed, 6 skipped`6 项为未配置 PostgreSQL 专用 URL 的预期跳过。
- [x] [CONCEPT: 测试方案] 标准重算客户端金额伪造、政策失败、Case 缺失和事务回滚测试通过。
证据:`test_expense_claim_service.py -k standard_adjustment` 8 项加付款集成 1 项通过HTTP 标准重算 1 项通过。
- [x] [CONCEPT: 测试方案] 前端数据归一化、状态、筛选、URL、证据动作和响应式测试通过。
证据:容器内 `cfo-value-dashboard.test.mjs` 14 项通过,新增机会 URL 恢复/清理、筛选上下文、风险/单据/预算/维度链接和预算非事实口径断言;与 App Shell 返回链、路由加载和筛选样式组合回归 37 项通过;带凭证序列化和无证据入口 fail-closed 均有断言。
- [x] [CONCEPT: 测试方案] 一次性 PostgreSQL 空库迁移、重复升级、约束、append-only、无损降级和并发测试通过。
证据:一次性 PostgreSQL 17 迁移循环通过;`test_savings_concurrency_postgres.py` 6 项通过覆盖重放、canonical 竞态、跨租户、独立确认、付款单事实和 append-only DB 触发器。
- [x] [CONCEPT: 集成] 住宿标准重算 → 机会 → 审批 → 付款 → 实际 → 财务确认 → CFO 看板 E2E 通过。
证据:`test_savings_value_e2e.py` 从服务端政策差额、付款动作、待确认排除、独立财务确认到 CFO 金额对账单项通过。
- [x] [CONCEPT: 集成] 确认后负向冲回和报告 `as_of` 回放 E2E 通过。
证据:`test_savings_value_e2e.py``test_cfo_value_analytics.py`同时验证当前净值归零和冲回前历史金额回放。
- [x] [CONCEPT: 容器验证] 相关 pytest、Ruff、前端测试和生产构建均在 `local-x-financial-linux` 内通过。
证据Savings/CFO 相关切片与端到端均通过fresh PostgreSQL 总探针 `87 passed / 0 skipped / 0 failed`,其中 Savings 并发 6 项Web 全量 `815 passed / 0 failed` 与 Vite build 通过;新增 Python 文件 Ruff 和 `git diff --check` 通过。
## 9. 文档收尾
- [x] [CONCEPT: 指标与验收] 逐项核对 A1-A8并把文件、接口、迁移、测试和运行结果写回证据。
证据A1 纵向闭环由 `test_savings_value_e2e.py`A2-A4/A4.1 由 Savings schema、`cfo_value_analytics.py`、基线/分析测试A5-A6 由 CFO 组件、来源下钻和前端状态测试A7 由 0015 迁移与 PostgreSQL 并发A8 由本节最终容器汇总证明。
- [ ] [CONCEPT: 风险与开放问题] 记录付款证据等级、汇率、双人复核、工时口径和试点目标的最终边界。
证据:
- [x] [CONCEPT: 本轮实现记录] 同步更新上位 `ai-expense-closed-loop-and-value-proof` 文档,不删除历史证据。
证据:上位 TODO 已回填 Savings Ledger、状态机、CFO 看板、下钻、证据详情和 E2E历史证据原样保留。

View File

@@ -0,0 +1,13 @@
## 修复记录
- 13:08修复 AgentAsset 全局读写、跨租户子记录注入、风险样本串租户、审核身份伪造和 ONLYOFFICE 匿名/可重放回调问题。
- Git 提交检查:已执行 `git fetch --all --prune``git status -sb``git log HEAD..@{u}``git log @{u}..HEAD``origin/main` 无新提交,本地 `main` ahead 17工作区包含多智能体并行未提交变更且未被清理或覆盖。
- 本地 ahead 摘要:`242d68c3` 审批任务与豁免、`28b834ed` 审批动作幂等回放、`4940ebc4` 风险处置流程、`ee88a36b` 租户安全分层学习、`6bdf65bc` 权威预审、`ae3f02c3` 零录入票据关联、`54754b55` 个人报销记忆、`211f85d9` 已验证申请工作流、`5b246307` 申请预览决策、`a662cfe6` AI 反馈账本、`5ed34c2b` 历史申请回填、`11275e4b` 迁移 ownership 安全、`1347366b` 时间线与草稿事件安全、`22669a90` 统一费用时间线、`a616b30c` AI 申请事务、`653eda05` 不透明会话、`661990b2` 费用案例事务事件。
- 修改:为 AgentAsset、Version、Review、TestRun 和 RuleFeedback 增加结构化 `tenant_id + scope`所有资产、发布、监控、召回、遥测、调度、foundation 和风险运行时查询接入企业/平台作用域;同编码按企业优先、平台回退解析,跨企业资源统一不可见,平台资产仅平台管理员可写。
- 修改:真实风险场景强制显式目标企业并先按 `ExpenseClaim.tenant_id` 过滤;测试证据归目标企业;版本、审核和规则表变更主体改为登录会话中的稳定 employee/username 标识,客户端 actor/reviewer 不能覆盖审计事实。
- 修改:新增 DB-backed ONLYOFFICE content/callback 会话和 `active → processing → consumed|failed` 原子状态机token 绑定租户、资源、资产、document key/version/fingerprint、权限、actor、audience、时间和 JTI平台只读、跨资产、旧版本、错 key、过期和重放回调均拒绝。
- 修改:回调复用安全下载器,限制配置 origin校验 DNS 全部地址并固定已验证公网 IP拒绝重定向、超限、错误 MIME、危险 ZIP 和异常 OOXML新增 `20260717_0026` 迁移并对无法归属的旧数据和有企业事实的 downgrade fail-closed。
- 操作:拆出 AgentAsset access/serialization/ONLYOFFICE security 与风险规则字段推断模块;将核心文件控制在 800 行以内;补齐功能 CONCEPT/TODO 和迁移、权限、安全回归测试。
- 验证:容器内发布/监控/召回/运行时/调度/遥测/租户安全/ONLYOFFICE 汇总 58 项通过AgentAsset service/foundation 28 项通过;风险生成/修订/golden 49 项通过;相关文件 Ruff、py_compile、ORM mapper84 张表)和 `git diff --check` 均通过。
- 验证:一次性 PostgreSQL 探针完成旧平台数据升级、同编码多企业、scope/check、跨租户复合外键和有事实 downgrade 保护;新库 `base → head(0028)``head → 0025 → head` 均成功AgentAsset 与 Knowledge ONLYOFFICE 会话表正常创建。
- 影响:企业只能读取本企业和平台只读资产,无法观察或修改其他企业的资产、版本、审核和测试证据;规则测试不会抽取其他企业费用;审计身份不可由请求伪造;文档回写失败时保持原文件不变,也不会向任意或内部地址发起下载。

View File

@@ -0,0 +1,10 @@
## 修复记录
- 13:40修复 0026 Agent 资产租户安全迁移在真实历史建表路径下无法完整回退的问题。
- Git 提交检查:已执行 `git fetch --all --prune``git status -sb``git rev-parse --abbrev-ref --symbolic-full-name @{u}``git log HEAD..@{u}``git log @{u}..HEAD``origin/main` 无新提交,本地 `main` ahead 17工作区包含多智能体并行变更未合并、覆盖或提交。
- 本地 ahead 摘要17 个提交覆盖审批任务与幂等回放(`242d68c3``28b834ed``4940ebc4`)、租户安全费用学习/预审/票据关联/申请记忆与反馈(`ee88a36b``6bdf65bc``ae3f02c3``54754b55``211f85d9``5b246307``a662cfe6`)、历史回填与迁移安全(`5ed34c2b``11275e4b`)、费用时间线与事务(`1347366b``22669a90``a616b30c``661990b2`)及不透明认证会话(`653eda05`)。
- 根因:空库先经过 0026 时 Agent 资产旧表尚不存在迁移会按设计跳过这些表之后旧表由当前模型补建PostgreSQL 为列级租户外键生成 `*_tenant_id_fkey`,而 0026 回退硬编码删除 `fk_*_tenant`,首个 `agent_asset_rule_feedback` 约束不存在即中断。
- 修改:`20260717_0026_agent_asset_tenant_security.py` 新增带 PostgreSQL 标识符引用的约束安全删除器0026 回退对它负责的复合外键、租户外键、范围检查和唯一约束统一使用 `DROP CONSTRAINT IF EXISTS`;升级路径与最终升级约束保持不变,模型自动命名的列级外键仍随租户列删除安全清理。
- 操作:在 `financial-internal` 网络启动独立 `postgres:16-alpine` 一次性数据库,使用应用容器和项目 venv 执行真实 Alembic 循环;验证结束后停止探针,并确认 `--rm` 已删除容器。
- 验证:相关 Ruff 检查通过PostgreSQL-only 迁移防误用测试 42 项通过;一次性 PostgreSQL 上的完整迁移循环 1 项通过7.00 秒),覆盖空库升级至 0028、回退至 0008、再次升级、完整回退至 base、最终再次升级至 0028同时验证约束存在与缺失两种删除路径。
- 影响:真实历史路径与当前模型补建路径都能安全回退 0026缺少迁移命名约束时不再失败已有约束仍被正常移除且不会改变 head 升级结构。

View File

@@ -0,0 +1,8 @@
## 修复记录
- 13:25记录 bug 修复:未配置商业计量器时独立 SQLite Session 回滚调用方事务。
- Git 提交检查:执行 `git fetch --all --prune` 后,`HEAD..origin/main` 无新提交;本地 `main` ahead 17 个既有提交,依次为 `242d68c3` 审批任务与豁免、`28b834ed` 审批幂等响应、`4940ebc4` 风险处置、`ee88a36b` 分层费用学习、`6bdf65bc` 权威预审、`ae3f02c3` 票据零入口归集、`54754b55` 个人申请记忆、`211f85d9` 申请流程统一、`5b246307` 预览决策、`a662cfe6` 反馈账本、`5ed34c2b` 历史费用 Case 回填、`11275e4b` migration ownership、`1347366b` 时间线与草稿事件、`22669a90` 费用时间线、`a616b30c` AI 申请提交事务、`653eda05` bearer session、`661990b2` 费用 Case 事务事件;这些提交均早于本轮且未改写。
- 修改:`commercial_direct_operation.py` 增加调用方 `lookup_session` 的只读未配置短路OCR、RuntimeChat、金融连接器和附件 observer 均显式传入当前 Session。这样没有计量器时不再创建第二个 Session也不会在 SQLite `StaticPool` 共用连接上意外 rollback 已 flush 的费用明细。
- 操作:先用附件归集回归复现 `expense_claim_items expected to update 1 row; 0 were matched`,再将未配置判断前移到调用方 Session保留真正配置计量器时的独立事务预占与结算。
- 验证:容器内附件归集与票据夹 `31 passed`;商业资源边界 `9 passed`;报销端点与连接器组合 `38 passed`;相关 Ruff 通过。
- 影响:未启用商业计量的开发、测试和兼容租户不再因为商业探测破坏调用方事务;生产 PostgreSQL 的独立商业事务语义保持不变。

View File

@@ -0,0 +1,8 @@
## 修复记录
- 13:25记录 bug 修复:业务回滚释放预占后相同请求无法安全重试。
- Git 提交检查:执行 `git fetch --all --prune` 后,`HEAD..origin/main` 无新提交;本地 `main` ahead 17 个既有提交,依次为 `242d68c3` 审批任务与豁免、`28b834ed` 审批幂等响应、`4940ebc4` 风险处置、`ee88a36b` 分层费用学习、`6bdf65bc` 权威预审、`ae3f02c3` 票据零入口归集、`54754b55` 个人申请记忆、`211f85d9` 申请流程统一、`5b246307` 预览决策、`a662cfe6` 反馈账本、`5ed34c2b` 历史费用 Case 回填、`11275e4b` migration ownership、`1347366b` 时间线与草稿事件、`22669a90` 费用时间线、`a616b30c` AI 申请提交事务、`653eda05` bearer session、`661990b2` 费用 Case 事务事件;这些提交均早于本轮且未改写。
- 修改:`commercial_runtime_reservations.py` 在相同指纹、相同订阅/权益/账期下允许 `released → reserved`,重开时重新锁定合同、校验 meter 快照并执行硬配额判断;跨账期重试继续失败关闭。
- 操作:补充 Direct operation 的 not-sent 后重试测试,并把金融连接器“业务 rollback 后同一外部事件重试并提交”加入资源边界回归。
- 验证:容器内 Direct + 商业资源组合 `22 passed`,商业资源边界 `9 passed`;回滚阶段无 usage重试提交后仅一个 usage 且预占终态为 committed。
- 影响:数据库瞬时失败或显式回滚不再把同一幂等业务请求永久卡在 released重试仍受当前硬配额和账期约束不会绕过额度。

View File

@@ -0,0 +1,13 @@
# 员工导入与成员资格部分提交
日期2026-07-17
文档路径document/development/2026-07-17/dev-logs/bugs/employee-import-membership-transaction.md
## 修复记录
- 14:07记录 bug 修复员工导入与成员资格部分提交。bug-log:242d68c3
- Git 提交检查:已执行 `git fetch --all --prune`、状态、upstream 和双向日志检查;`origin/main` 无新提交,本地 `main` ahead 17。ahead 摘要17 个提交覆盖审批/风险处置、租户安全费用学习、权威预审、票据关联、个人记忆、历史回填、迁移安全、费用事件事务和不透明认证会话;本轮未改写这些提交。
- 根因:`EmployeeImportCoordinator._apply_import_rows()` 在写员工和上级关系后先 commit`EmployeeService.import_employees()` 才补 `TenantMembership` 并第二次 commit成员资格失败时接口报错但员工已永久落库。
- 修改:协调器只 flush 员工、角色、组织、上级和变更日志,不再拥有 commit外层服务仅在成功结果后补齐租户成员资格并对两部分执行一次统一 commit任一异常统一 rollback。
- 操作:新增成员资格同步注入失败测试,导入新员工后故意抛错并验证员工记录不存在;校验失败结果不触发成员资格或无意义提交。
- 验证:容器内员工服务、导入、认证和行为画像 35 项通过;差旅计算器 5 项通过;目标 Ruff、format check、compileall、代码体积门禁和 `git diff --check` 通过。
- 影响:员工批量导入现在满足“全部员工数据与认证成员资格一起成功或一起失败”,不会出现接口失败但部分账号已经创建/修改的状态。

View File

@@ -0,0 +1,11 @@
## 修复记录
- 13:19修复用户会话结算测试身份错配、缓存命中后旧部门不再归一化以及 Excel 导入清空上级时误清空员工租户的问题。
- Git 提交检查:已执行 `git fetch --all --prune``git status -sb``git log HEAD..@{u}``git log @{u}..HEAD``origin/main` 无新提交,本地 `main` ahead 17工作区仍包含多智能体并行变更未做清理、覆盖或提交。
- 本地 ahead 摘要17 个提交覆盖审批任务与幂等回放(`242d68c3``28b834ed``4940ebc4`)、租户安全费用学习/预审/票据关联/申请记忆与反馈(`ee88a36b``a662cfe6`)、历史回填与迁移安全(`5ed34c2b``11275e4b`)、费用时间线/事务(`1347366b``22669a90``a616b30c``661990b2`)及不透明认证会话(`653eda05`)。
- 修改:会话结算正向用例改用会话真实所有者认证,保留服务端 username ownership 校验;新增其他用户不能关闭该会话的反向测试,避免用放宽授权掩盖 `durationMs=0`
- 修改:目录建表/种子初始化继续按 bind 与租户缓存,但缓存命中时仍以租户过滤查询旧部门编码并持久化映射到规范部门;不再因初始化缓存永久跳过外部同步产生的旧编码。
- 修改Employee 与 OrganizationUnit 的复合租户关系只把 `organization_unit_id``manager_id` 标记为 SQLAlchemy 可同步外键,`tenant_id` 只参与关联过滤;清空部门或上级不会再把员工租户写成 `NULL`,数据库复合外键仍阻止跨租户关联。
- 测试:补充会话所有权反例、导入后 `tenant_id/manager_id` 持久化断言,以及旧部门归一化后租户与数据库组织归属断言。
- 验证:容器内三个原失败点与新增反例 4 项通过员工服务、Excel 导入、行为画像/会话和认证会话相关回归 31 项通过ORM mapper 确认关系同步列仅为 `organization_unit_id/manager_id`;相关文件 Ruff 与 `git diff --check` 均通过。
- 影响:会话时长能在正确登录主体下正常结算,其他用户仍不能关闭该会话;旧组织编码会持续收敛到标准部门;员工导入或资料更新清空上级/部门时不会破坏不可为空的租户归属。

View File

@@ -0,0 +1,13 @@
# 报销审批身份解析跨租户串读
日期2026-07-17
文档路径document/development/2026-07-17/dev-logs/bugs/expense-claim-approver-tenant-isolation.md
## 修复记录
- 13:48记录 bug 修复报销审批身份解析跨租户串读。bug-log:242d68c3
- Git 提交检查:已执行 `git fetch --all --prune``git status -sb`、upstream 解析及双向日志检查;`origin/main` 无新提交,本地 `main` ahead 17。ahead 摘要:`242d68c3/28b834ed/4940ebc4` 为审批任务、不可变回放和风险处置,`ee88a36b/6bdf65bc/ae3f02c3/54754b55/211f85d9/5b246307/a662cfe6` 为租户安全费用学习、预审、票据关联、个人记忆和申请反馈,`5ed34c2b/11275e4b` 为历史回填与迁移安全,`1347366b/22669a90/a616b30c/661990b2` 为费用时间线及事务,`653eda05` 为不透明会话;本轮未改写这些提交。
- 修改:`expense_claim_access_policy.py` 的当前员工、申请人、直属领导、部门预算负责人和财务负责人解析全部先绑定认证用户或报销单的结构化 `tenant_id`;员工、组织和下属子查询增加租户首层谓词,避免相同姓名、邮箱前缀、部门或角色在另一企业命中。
- 修改:把身份候选、申请人回填和唯一姓名判断拆入 `expense_claim_employee_resolver.py`,保持公开策略 API 不变,并将访问策略主文件降到 701 行;无可信租户、跨租户关联或结构化归属冲突统一失败关闭。
- 操作:同步补齐审批任务、风险并发、层级记忆和报销测试夹具的显式企业归属;没有放宽生产授权,也没有用默认企业兼容掩盖跨租户错误。
- 验证:容器内 `test_expense_claim_service.py` 121 项通过,访问策略文件大小与租户作用域定向 10 项通过审批任务、PostgreSQL 并发与全量后端分片均通过fresh PostgreSQL 专项最终 `87 passed / 0 skipped / 0 failed`;新 Python 文件 Ruff 和 `git diff --check` 通过。
- 影响:审批队列、审批人快照、退回/通过权限和历史回填不会再因另一企业存在同名员工或同名部门而串租户;多租户环境下报销审批保持可解释且失败关闭。

View File

@@ -0,0 +1,8 @@
## 修复记录
- 13:33记录 bug 修复:财务驾驶舱租户安全测试仍按旧 Case Link 契约构造报销单。
- Git 提交检查:`git fetch --all --prune` 后未发现 upstream 新提交;本地相对 `origin/main` ahead 17 个既有提交,分别为 `242d68c3` 审批任务工作流、`28b834ed` 不可变响应重放、`4940ebc4` 风险处置工作流、`ee88a36b` 租户安全分层费用学习、`6bdf65bc` 权威预审、`ae3f02c3` 零录入票据关联、`54754b55` 个人申请记忆、`211f85d9` 申请工作流统一、`5b246307` 申请预览决策、`a662cfe6` 反馈账本、`5ed34c2b` 历史 Expense Case 回填、`11275e4b` 迁移所有权安全、`1347366b` 时间线与草稿事件安全、`22669a90` 统一事件时间线、`a616b30c` AI 申请事务统一、`653eda05` 不透明 bearer 会话、`661990b2` 事务化 Expense Case 事件;本次未改写这些提交。
- 修改:`test_finance_dashboard_tenant_security.py` 的 Claim 构造器显式接收并写入 `tenant_id`,租户夹具不再只依赖历史 `ExpenseCaseLink`;新增“结构化 Claim 租户与旧 Link 不一致时以 Claim 为准”的隔离回归。
- 操作:先在容器内单跑复现 3 个失败,确认不是测试顺序或全局 monkeypatch 污染,再执行最小夹具修复并串行复跑财务、连接器与 Hermes 相邻测试组。
- 验证:单文件 `8 passed`;排序相邻组 `67 passed, 4 skipped`;目标文件 Ruff 检查与 `git diff --check` 均通过。
- 影响:财务驾驶舱测试与新的结构化租户模型保持一致,同时固定了旧关联索引不能移动报销单租户归属的安全边界;生产 fail-close 与首条 SQL 租户过滤没有放宽。

View File

@@ -0,0 +1,13 @@
# 财务连接器运行事件计数与固定时钟偏差
日期2026-07-17
文档路径document/development/2026-07-17/dev-logs/bugs/financial-connector-operational-counting-clock.md
## 修复记录
- 11:39记录 bug 修复:财务连接器相同载荷的不同运行尝试被永久合并,固定时钟场景的接收时间偏离观测窗口。
- Git 提交检查:已执行 `git fetch --all --prune`upstream `origin/main` 无新提交;本地 ahead 17 条,包含 `242d68c3 feat(approval): add task workflow and waiver decisions``28b834ed fix(approval): replay immutable action responses``4940ebc4 feat(approval): add safe risk disposition workflow``ee88a36b feat(ai): add tenant-safe hierarchical expense learning` 等共享工作区既有提交,本次未改写这些提交。
- 修改:`financial_connector_operational_events.py` 把规范化 UTC 发生时间纳入运行事实幂等命名空间,使同一 candidate 的补偿重试保持单条、不同 HTTP 尝试分别计数;`financial_connector_ingestion.py` 让注入的可信 `now_epoch` 同时驱动接收时间和配置健康时间;`financial_connector_observability.py` 统一把数据库时间规范为 UTC避免 SQLite/驱动返回 naive datetime 时窗口结果不稳定。
- 操作补充运行事实服务、HTTP 冲突、可信认证归属、敏感原值不落库和 PostgreSQL 并发探针;所有 Python、pytest、Ruff 与迁移操作均在 `local-x-financial-linux` 容器中执行PostgreSQL 验证使用新建的一次性 `disposable-probe` 容器,没有连接项目配置中的外部数据库。
- 验证:容器内连接器与迁移前置定向 `115 passed`,全新 PostgreSQL 17 完整迁移循环 `51 passed`,连接器并发 `4 passed`,后继 0023 并发 `7 passed`;相关 Ruff 和 `git diff --check` 通过。
- 影响:可观测性不会再把多次真实重放压成一次,也不会因测试/模拟可信时钟与实际系统日期不同而漏掉事件;同一补偿 candidate 仍由数据库唯一约束保证幂等。

View File

@@ -0,0 +1,8 @@
## 修复记录
- 13:15修复本体参考目录、员工画像、Hermes 扫描/看板/提醒、财务报告和内部 Orchestrator 的跨租户读取与错误归属风险。
- Git 提交检查:已执行 `git fetch --all --prune``git status -sb``git log HEAD..@{u}``git log @{u}..HEAD``origin/main` 无新增提交,本地 `main` ahead 17。ahead 摘要:`242d68c3/28b834ed/4940ebc4` 审批与风险处置,`ee88a36b/54754b55/211f85d9/5b246307/a662cfe6` AI 费用学习与申请流程,`6bdf65bc/ae3f02c3/5ed34c2b/1347366b/22669a90/a616b30c/661990b2` 费用预审、费用事件与时间线,`11275e4b` migration ownership`653eda05` opaque bearer session本轮未改写这些提交。
- 修改:本体解析在模型前创建租户化 Agent Run并对员工、组织、费用、应收、应付和项目目录首 SQL 过滤;员工画像新增租户字段、复合员工外键和本人/直属领导/财务/高管/管理员访问矩阵Hermes 风险、画像、线索、提醒和看板逐租户运行;财务报告按企业配置收件人、内容、路径和幂等账本;内部 Orchestrator 缺失、停用、不存在或冲突租户时在建 Run 前拒绝。
- 操作:新增 `20260717_0028` 迁移和租户财务报告配置/运行表upgrade/downgrade 在任何 DDL 前拒绝非 PostgreSQL完成 fresh、旧结构回填、回滚重升及复合外键 PostgreSQL 探针,验证后停止并自动清理一次性探针容器。
- 验证:容器内新增安全/提醒测试 9 项、旧本体与 Orchestrator 88 项、Hermes/看板/财务报告 8 项、画像/鉴权/关联草稿 25 项通过Alembic 静态回归 61 项通过、1 项因未提供外部 PostgreSQL DSN 跳过;相关文件 Ruff check/format check 和 `git diff --check` 通过。
- 影响:企业 A 的模型提示词、员工画像、风险扫描、提醒、看板、报告附件和邮件收件人不再包含企业 B 数据;无可信租户的内部任务不能生成不可归属记录;历史无双租户快照的 Run 不会被企业看板错误统计。

View File

@@ -0,0 +1,8 @@
## 修复记录
- 12:45修复知识文件、元数据与 LightRAG/Qdrant 全局共享导致的跨租户串读和覆盖风险。
- Git 提交检查:已执行 `git fetch --all --prune``git status -sb``git log HEAD..@{u}``git log @{u}..HEAD`upstream 无新提交,本地 `main` ahead 17包含 `242d68c3``28b834ed``4940ebc4``ee88a36b``11275e4b``653eda05` 等既有审批、AI、迁移与鉴权提交本次未改写或合并这些提交。
- 修改:新增 tenant/platform 存储作用域,把文件、`.index.json``.lightrag`、运行时缓存和 Qdrant workspace 分区;旧全局制度无损复制到平台只读层;知识 API、同步、Orchestrator 查询和后台索引统一从认证用户或数据库 Agent Run 获取可信 tenant。
- 操作:按职责拆出 scope、index state 和 RAG scoring 小模块;删除知识文件工具中已废弃的弱 ONLYOFFICE token 代码;所有命令在 Docker 容器 `local-x-financial-linux``/app` 下运行,未提交、未推送、未删除旧知识资料。
- 验证:容器内租户隔离 6 项、既有知识回归 31 项和相关 Agent Run/鉴权 20 项通过;目标 Ruff 与 compileall 通过。
- 影响租户只能看到和操作自己的知识数据并可读取平台只读制度RAG 本地状态、缓存和向量 workspace 不再跨企业复用,缺 tenant 时统一 fail-closed。

View File

@@ -0,0 +1,8 @@
## 修复记录
- 12:45修复匿名 ONLYOFFICE 回调可重放、可跨资源覆盖并可借下载 URL 发起 SSRF 的高风险问题。
- Git 提交检查:已执行 `git fetch --all --prune``git status -sb``git log HEAD..@{u}``git log @{u}..HEAD`upstream 无新提交,本地 `main` ahead 17既有提交未被改写。
- 修改:新增 DB-backed 一次性会话与 `20260717_0027` 迁移token 绑定租户、资源、文档、key、版本、权限、audience、过期时间和 JTI预览禁止写入编辑回调原子 claim 后只能消费一次;下载器拒绝错误 origin、私网/回环/链路本地解析、DNS 重绑定、重定向、超限、错误 MIME 和异常 OOXML。
- 操作:把 content/callback 解析与网络下载从主 KnowledgeService 拆出content token 保持短时callback session 默认 4 小时;失败会话记录受控原因且原文件保持不变。
- 验证:容器内 ONLYOFFICE 租户安全 8 项通过,覆盖平台只读、错 key 不 claim、过期、成功单次回写、重放、SSRF、IP pinning、大小/MIME/OOXML迁移/preflight/ownership 163 项通过、1 项静态套件因未配置测试 DSN 跳过;共享 PostgreSQL 探针另完成 `base → 0028``0028 → 0025 → 0028`Knowledge/AgentAsset 会话表均正常落库。
- 影响:未签名、过期、跨租户、跨文档、旧版本、只读和重放回调均不能覆盖文件;文档服务下载失败时安全拒绝,不向内部网络或任意 URL 发请求。

View File

@@ -0,0 +1,8 @@
## 修复记录
- 12:45修复知识索引调度器使用默认租户、后台线程信任传入上下文导致任务错误归属的问题。
- Git 提交检查:已执行 `git fetch --all --prune``git status -sb``git log HEAD..@{u}``git log @{u}..HEAD`upstream 无新提交,本地 `main` ahead 17既有提交未被改写。
- 修改:调度器查询租户注册表并逐个处理 `status=active` 的租户;无活跃租户时跳过;内部 Hermes 身份显式携带 tenant索引 worker 从数据库 Agent Run 的 route/ontology 重新取得并比对 tenant拒绝缺失或冲突上下文。
- 操作同步任务、活跃任务复用、stale 状态回收和 ingest 状态写入均绑定当前 tenant不再构造无作用域 KnowledgeService。
- 验证:容器内 active/suspended 调度隔离、Agent Run tenant 冲突、知识同步和既有知识组合测试通过;扫描全部 `KnowledgeService`/`KnowledgeRagService` 调用点,生产代码仅保留显式 tenant 或显式 platform 构造。
- 影响:定时知识任务不会再落入默认企业或把一个租户的文档写进另一个租户的索引;后台参数被篡改或丢失时任务 fail-closed。

View File

@@ -0,0 +1,12 @@
# 后台报销与 Steward 可信租户上下文缺失
日期2026-07-17
文档路径document/development/2026-07-17/dev-logs/bugs/steward-linked-reimbursement-tenant-context.md
## 修复记录
- 02:09记录 bug 修复:后台报销与 Steward 可信租户上下文缺失。bug-log:242d68c3
- Git 提交检查:已执行 `git fetch --all --prune`upstream `origin/main` 无新提交;本地 ahead 17 条,包含 `242d68c3 feat(approval): add task workflow and waiver decisions``28b834ed fix(approval): replay immutable action responses``4940ebc4 feat(approval): add safe risk disposition workflow``ee88a36b feat(ai): add tenant-safe hierarchical expense learning` 等共享工作区既有提交,本次未改写这些提交。
- 修改:`linked_reimbursement_draft_jobs.py` 将已认证 `current_user` 显式传给 Orchestrator避免后台 AgentRun 丢失 tenant`steward.py` 为 plans、slot-decisions、runtime-decisions、plans/stream 注入认证用户,用登录态覆盖 payload 身份字段,并补充会话归属校验和申请候选单租户过滤;对应测试覆盖未认证、空 tenant、伪造 tenant、跨租户会话及候选单隔离。
- 操作:所有静态检查和测试均在 Docker 容器 `local-x-financial-linux``/app` 下执行;没有修改 commercial、迁移、连接器、AI 发布或 Savings 代码,也没有提交或推送。
- 验证:容器内定向 pytest 两组共 54 项通过Ruff 排除目标旧文件既有 E501 长行后全部规则通过。完整 Ruff 仍报告目标旧文件历史 E501不涉及本次新增安全逻辑。
- 影响:后台报销任务不再因漏传登录态落入缺失/default tenant四类 Steward AI 入口统一要求登录,客户端伪造 tenant、用户或权限字段不能覆盖服务端身份跨租户会话和申请候选单读取会 fail-closed。

View File

@@ -0,0 +1,13 @@
# 差旅计算器员工解析跨租户命中
日期2026-07-17
文档路径document/development/2026-07-17/dev-logs/bugs/travel-calculator-employee-tenant-isolation.md
## 修复记录
- 14:07记录 bug 修复差旅计算器员工解析跨租户命中。bug-log:242d68c3
- Git 提交检查:已执行 `git fetch --all --prune`、状态、upstream 和双向日志检查;`origin/main` 无新提交,本地 `main` ahead 17。ahead 摘要:`242d68c3/28b834ed/4940ebc4` 为审批与风险处置,`ee88a36b``a662cfe6` 为费用学习/预审/票据/记忆闭环,`5ed34c2b/11275e4b` 为回填和迁移安全,`1347366b/22669a90/a616b30c/661990b2` 为费用事件与事务,`653eda05` 为认证会话;本轮未改写这些提交。
- 根因:差旅计算器按邮箱、工号或姓名查员工时没有租户谓词,也忽略认证上下文的 `employee_id/employee_no`;员工身份现为租户内唯一,不同企业可以合法存在相同值,首条命中会把另一企业职级和办公城市带入计算。
- 修改:`travel_reimbursement_calculator.py` 先校验可信 `tenant_id`,将 `employee_id` 加入最高优先候选,所有 ID/邮箱/工号/姓名查询都以 `Employee.tenant_id` 为首层条件。
- 操作:新增两企业相同邮箱、工号和姓名的反向测试,故意先插入另一企业员工,确认当前登录企业的 employee ID、职级和地点始终胜出。
- 验证:容器内差旅计算器 5 项通过;员工服务、导入、认证和行为画像 35 项通过;目标 Ruff、format check、compileall、代码体积门禁和 `git diff --check` 通过。
- 影响:差旅住宿、补助和交通估算不会再读取另一企业员工的职级或办公地点;无合法租户时直接失败关闭。

View File

@@ -0,0 +1,13 @@
# 差旅计算器只读流程触发规则资产提交
日期2026-07-17
文档路径document/development/2026-07-17/dev-logs/bugs/travel-calculator-rule-sync-transaction.md
## 修复记录
- 13:48记录 bug 修复差旅计算器只读流程触发规则资产提交。bug-log:242d68c3
- Git 提交检查:已执行 `git fetch --all --prune``git status -sb`、upstream 解析及双向日志检查;`origin/main` 无新提交,本地 `main` ahead 17。ahead 摘要:`242d68c3/28b834ed/4940ebc4` 为审批与风险处置,`ee88a36b``a662cfe6` 为 AI 费用学习和申请闭环,`5ed34c2b/11275e4b` 为历史回填与迁移安全,`1347366b/22669a90/a616b30c/661990b2` 为费用事件与事务,`653eda05` 为认证会话;本轮未改写这些提交。
- 根因:差旅计算器为读取规则先调用 `AgentAssetService.list_assets()`,该入口可能执行资产初始化并提交共享 Session导致外层报销事务被提前提交同时规则目录未带认证企业企业覆盖规则可能退回到错误的全局目录。
- 修改:`travel_reimbursement_calculator.py` 删除计算路径中的资产同步调用,改为使用当前 Session 和认证用户 `tenant_id` 直接只读加载 `ExpenseRuleRuntimeService` 目录;规则初始化只保留在启动/管理边界,不再混入金额计算事务。
- 操作:保留地点、职级、住宿、补助和交通估算算法,仅收紧规则读取的事务与租户边界;测试夹具同步补齐结构化企业身份。
- 验证:容器内差旅计算器 4 项金额、地区拒绝和地点归一化回归通过AgentAsset service/foundation 28 项、报销服务 121 项及后端全量分片通过;相关新文件 Ruff、compileall 和 `git diff --check` 通过。
- 影响:用户计算差旅标准时不会意外提交正在编辑的报销单;企业规则按当前登录企业解析,读取失败会明确报错而不会静默使用另一企业或全局可写状态。

View File

@@ -0,0 +1,188 @@
# Agent 资产多租户隔离与安全规则编辑 概念文档
更新时间2026-07-17
## 功能一句话
让规则、技能、MCP、任务及其版本、审核、测试和编辑会话都拥有可验证的租户归属并以“企业资产可写、平台资产只读”的双层模型安全贯通规则生成、真实场景验证、审核、发布和 ONLYOFFICE 编辑。
## 背景与问题
原 AgentAsset 数据模型没有结构化 `tenant_id``scope`,部分读取和内部查询可以在没有用户上下文时返回全局资产。版本、审核、测试、反馈和发布链路主要依赖 `asset_id` 或业务约定关联,无法由数据库阻止跨租户子记录注入。同编码资产也不能由不同企业独立维护。
规则场景测试可以在未声明目标企业时抽取费用数据,审核主体还可能使用客户端传入的 actor/reviewer 字段,导致测试证据和盲审身份缺乏稳定、可追溯的企业边界。
规则表的 ONLYOFFICE 内容与回调接口原先缺少持久化的一次性会话。回调下载地址、文档 key、版本、租户和编辑权限之间没有不可变绑定存在匿名读取、跨资产回写、身份伪造、重放和服务端请求伪造风险。
## 目标与非目标
### 目标
- AgentAsset、Version、Review、TestRun、RuleFeedback 和 ONLYOFFICE Session 均保存结构化租户作用域。
- 租户用户只能看到本企业资产和平台资产;同编码时企业资产优先覆盖平台默认资产。
- 跨租户详情、版本、审核、发布、测试和反馈统一表现为不存在,避免泄露资源存在性。
- 平台资产对所有租户只读,仅平台管理员可新增、修改、发布或编辑。
- 所有 HTTP 入口使用认证会话中的 `CurrentUserContext.tenant_id`,不接受客户端覆盖租户。
- 写入、版本和审核操作使用 RuleEditor、RuleReviewer 或平台管理员权限,并以稳定身份写入审计证据。
- 真实风险场景必须显式声明当前企业 `target_tenant_id`,费用样本的第一层 SQL 条件就是该租户。
- ONLYOFFICE 内容和回调使用不同 audience/scope 的签名 token并绑定数据库一次性会话。
- 回调下载拒绝错误 origin、非公网解析、DNS 重绑定、重定向、超限和非安全 OOXML 文件。
### 非目标
- 不允许普通企业用户创建或修改平台资产。
- 不把所有企业资产放在全局结果集中后仅靠前端过滤。
- 不允许平台管理员借普通企业会话修改其他企业的私有资产;跨企业运维需要独立受控流程。
- 不提供允许私网、回环或任意下载地址的 ONLYOFFICE 安全降级开关。
- 不在本切片中重做 Agent 资产管理前端视觉或商业定价页面。
## 用户与场景
- 企业规则编辑者:维护本企业规则资产、上传规则表并创建新版本。
- 企业规则审核者:以稳定登录身份进行盲审、驳回或批准规则版本。
- 企业风控人员:用本企业真实费用申请生成测试样本和质量证据。
- 普通企业用户:读取本企业资产和平台只读资产,但不能写入。
- 平台管理员:维护跨企业可见的平台基础规则和模板。
- 运行时与调度器:在显式租户范围内加载、测试、监控和召回资产,不进行全局扫描。
- ONLYOFFICE 文档服务:使用资源专用 token 读取一次文档,并通过单次 callback session 回写允许编辑的当前版本。
## 功能能力
- 双层可见性:`tenant:{tenant_id}``platform:platform`
- 企业内唯一编码:数据库唯一键为 `(tenant_id, scope, code)`,不同企业可拥有同编码资产。
- 确定性覆盖:按编码加载时先找当前企业资产,再回退平台资产。
- 服务端可信租户HTTP 服务由登录会话构造 `AgentAssetAccessScope`;无用户上下文的内部读取只允许平台作用域。
- 稳定审计主体:优先记录 `employee:{employee_id}`,没有员工 ID 时记录大小写归一的 `username:{username}`
- 真实样本隔离:场景请求必须传 `target_tenant_id`且必须等于登录企业TestRun 记录样本所属企业。
- 平台资产可在企业场景中验证,但测试证据仍归目标企业,不能变成平台或其他企业事实。
- ONLYOFFICE content token 有效期 15 分钟callback session 有效期 4 小时。
- 回调状态机保证同一个 JTI 最多一次进入写入阶段。
## 方案设计
### 前端契约
- AgentAsset DTO 提供 `tenantId``scope`,前端可明确标识企业资产和平台资产。
- 平台资产在非平台管理员会话中必须隐藏或禁用修改、发布、上传和 ONLYOFFICE 编辑动作。
- 场景测试请求必须携带 `targetTenantId`;它是目标企业的显式确认,不是可切换企业的授权参数。
-`X-Actor`/reviewer 头仅为兼容保留,服务端忽略其身份值并使用登录会话主体。
- ONLYOFFICE 配置根据权限返回 `view``edit`;内容和回调 token 只供文档服务使用。
### 后端职责
- `agent_asset_scope`:定义 platform/tenant 常量和合法作用域基础规则。
- `agent_asset_access`:从可信用户构造访问范围、生成稳定主体并提供可见/可写谓词。
- `agent_asset` repository所有列表、详情、版本、审核、测试和反馈查询都注入结构化租户条件。
- `agent_assets`:编排资产 CRUD、版本、审核与序列化不通过未过滤 ORM 关系返回子记录。
- `agent_asset_risk_rule_testing`:校验目标企业、先按租户过滤 ExpenseClaim再生成测试证据。
- 发布、监控、召回、调度、遥测和风险运行时服务:沿资产租户作用域读取和写入,禁止全局 asset id 查询。
- `agent_asset_onlyoffice_security`:签发/验证持久化会话并原子消费 callback JTI。
- `agent_asset_onlyoffice`按会话中的租户、资产、key、版本和指纹定位文档安全下载后创建新版本。
- `knowledge_onlyoffice_security`:复用统一安全下载器,执行 origin、DNS/IP、响应和 OOXML 校验。
### 数据
`20260717_0026_agent_asset_tenant_security.py` 负责以下结构:
- `agent_assets`:新增 `tenant_id``scope`,建立作用域约束、租户外键和企业内 code 唯一键。
- `agent_asset_versions``agent_asset_reviews`:新增租户作用域,并以 `(tenant_id, scope, asset_id)` 复合外键绑定父资产。
- `agent_asset_test_runs``agent_asset_rule_feedback`:保存证据所属企业;平台资产的企业测试/反馈也归企业事实域。
- `agent_asset_onlyoffice_sessions`:保存 JTI、租户、资源 scope、asset、document key/version/fingerprint、audience、权限、actor、过期时间和状态。
- 旧资产默认回填为平台资产;能从父资产确定的历史版本、审核和证据同步回填。
- 无法确认租户的历史数据或不完整旧表结构会 fail-closed不猜测企业归属。
### 权限与信任边界
- 当前企业只来自 `CurrentUserContext.tenant_id`;空值、`platform` 伪企业或请求参数不能构造企业访问范围。
- 租户读取谓词是“当前企业或平台”,写入谓词只允许当前企业;平台写入还要求 `is_admin=true`
- 跨租户资源统一返回 404权限不足的本作用域操作返回受控错误。
- RuleEditor 可维护规则和版本RuleReviewer 执行审核;平台资产的任意写操作额外要求平台管理员。
- 版本 created_by、审核 reviewer、规则表变更 actor 都由稳定登录主体生成。
- 后台 bootstrap/foundation 按平台作用域精确查找种子资产,不能误改同编码企业资产。
- 风险运行时按“企业优先、平台回退”加载发布规则,不扫描其他企业版本。
- ORM 关系不是授权边界;对外响应必须经过带 scope 的 repository/service 查询。
### 资产解析顺序
```text
find_by_code(code, current_tenant):
1. tenant_id = current_tenant AND scope = tenant
2. tenant_id = platform AND scope = platform
3. not found
```
该顺序让企业能够在不修改平台模板的情况下覆盖默认规则,同时保持其他企业和平台资产不受影响。
### 风险场景测试
```text
认证用户 tenant
→ 校验 target_tenant_id == tenant
→ 校验目标资产为 tenant 自有或 platform 只读资产
→ SQL 第一层条件 ExpenseClaim.tenant_id == target_tenant_id
→ 应用时间、费用类型、城市等业务筛选
→ 创建 tenant-scoped TestRun
```
没有目标企业、目标企业不一致或资产属于其他企业时均拒绝,不使用 mock 的默认企业或全局样本补齐。
### ONLYOFFICE 状态机
```text
issue(view) → active ── callback status 2/6 ──拒绝写入
issue(edit) → active ── atomic claim ──→ processing
├─校验/下载/写入成功→ consumed
└─任一步失败────────→ failed
active -- exp 超时 --> 验证拒绝
processing/consumed/failed/revoked -- replay --> 409/拒绝
```
token 同时绑定 issuer、audience、scope、JTI、tenant、resource scope、asset、document key、version、fingerprint、writable、actor、iat/nbf/exp。回调 payload 只能提供状态和下载位置,不能覆盖这些授权事实。
### 降级与回滚策略
- 无可信租户HTTP 请求拒绝;无用户上下文的内部服务只看平台资产,绝不回退全局查询。
- 跨租户资产、版本或测试证据:按不存在处理,不尝试平台管理员越权兼容。
- 场景样本为空:返回空样本测试事实,不改查其他企业数据。
- ONLYOFFICE token、key、版本、指纹、DNS、MIME 或 OOXML 校验失败:拒绝回写并保持原文件不变。
- 生产文档服务必须使用配置白名单中的公网 TLS origin或经满足相同约束的安全代理访问。
- migration downgrade 在存在企业资产/证据或 ONLYOFFICE 会话时拒绝有损回滚;必须先通过受控数据迁移清理事实。
## 测试方案
- 可见性:两企业同编码资产、企业覆盖平台、平台只读、跨企业详情 404、无上下文仅平台。
- 数据完整性scope/tenant check、企业内 code 唯一、Version/Review 复合父子外键、TestRun 证据企业。
- 权限与身份RuleEditor/RuleReviewer、平台管理员、伪造 actor/reviewer 无效、稳定 employee/username 主体。
- 风险场景必须目标企业、目标不一致拒绝、SQL 租户首过滤、平台资产的企业 TestRun。
- 发布链路:发布门禁、监控、召回、运行时、调度和遥测均使用显式租户。
- ONLYOFFICEcontent/callback scope、tenant/asset/key/version/fingerprint 绑定、平台只读、原子 claim、失败终态和重放拒绝。
- SSRF/文件:白名单 origin、全量公网 DNS、固定已校验 IP、拒绝重定向、大小/MIME/ZIP/OOXML 限制。
- 迁移:旧平台数据回填、同编码多企业、非法 scope/跨租户外键拒绝、事实存在时 downgrade 拒绝、清理后可回滚。
- 所有后端验证只在 `local-x-financial-linux` 容器中执行,单条命令限制 60 秒。
## 指标与验收
- 所有 AgentAsset 业务读取都能由 `tenant_id + scope` 确定结果范围。
- 跨企业资产、版本、审核、测试、反馈和回调用例 100% 不可见或拒绝。
- 平台资产对非平台管理员的写入和回调 100% 无副作用。
- 真实费用样本查询 100% 具有服务端校验的企业首过滤条件。
- 审计主体 100% 来自稳定登录身份,客户端 actor/reviewer 不能改变事实。
- 同一 ONLYOFFICE callback JTI 最多一次进入 `processing`
- 相关 Python 文件通过 Ruff、py_compile 和 ORM mapper 配置;定向发布、安全与风险规则回归通过。
## 风险与开放问题
- 旧测试或内部调用若直接构造没有 `tenant_id``CurrentUserContext`,会按新信任边界 fail-closed应由对应调用方补齐真实租户而不是放宽服务契约。
- 两个旧费用风险测试使用尚未持久化、没有 claim tenant 的对象;共享租户作用域已要求先保存并确认归属,需由费用申请切片统一调整测试夹具。
- 两个旧风险发布测试手工注入 aggregate 后直接 promote与当前必须有真实质量 TestRun 的发布门禁不一致;需由发布门禁切片统一口径。
- ONLYOFFICE 实际回写依赖部署环境提供公网可解析、可信 TLS 的文档服务或安全代理;开发网络解析到保留/私网地址时会按设计拒绝。
- 企业间受控复制、平台资产签名发布和跨企业运维审计属于后续独立能力,不应通过放宽本轮隔离实现。
## 本轮实现记录
- 2026-07-17完成 AgentAsset、Version、Review、TestRun、Feedback 的结构化租户作用域、企业覆盖平台读取和跨企业 fail-closed。
- 2026-07-17完成可信目标企业风险场景、真实费用样本 SQL 首过滤、企业 TestRun 证据和稳定盲审身份。
- 2026-07-17完成 DB-backed ONLYOFFICE 一次性会话、平台只读、文档基线绑定与安全下载。
- 2026-07-17完成 `20260717_0026` 迁移及一次性 PostgreSQL `base → head``head → 0025 → head` 验证。
- 2026-07-17完成发布、监控、召回、运行时、调度、遥测、foundation 和风险规则生成链路的租户接线与容器回归。

View File

@@ -0,0 +1,67 @@
# Agent 资产多租户隔离与安全规则编辑 开发 TODO
更新时间2026-07-17
关联方案:[CONCEPT.md](./CONCEPT.md)
## 使用规则
- 任务边界、信任模型、数据归属和上线约束以 CONCEPT 对应章节为准。
- `[x]` 只表示已有代码或容器验证证据;生产环境尚未验证的项目保持 `[ ]`
- 所有后端测试必须在 `local-x-financial-linux` 容器内执行,单命令最长 60 秒。
## 1. 调研与边界
- [x] [CONCEPT: 背景与问题] 盘点 AgentAsset、版本、审核、测试、反馈、发布和规则表编辑中的无租户/弱租户查询。证据repository、service、endpoint 和 release 全链路调用扫描。
- [x] [CONCEPT: 目标与非目标] 冻结“企业资产可写 + 平台资产只读 + 企业覆盖平台”的双层模型。证据:`AgentAssetAccessScope` 与两企业同编码测试。
- [x] [CONCEPT: 权限与信任边界] 冻结可信租户只来自登录会话、客户端 tenant/actor/reviewer 不能覆盖事实。证据:认证依赖、稳定主体函数和伪造头测试。
## 2. 契约与设计
- [x] [CONCEPT: 数据] 为 Asset、Version、Review、TestRun、Feedback 定义 `tenant_id + scope`、约束和索引。证据:模型与 `20260717_0026`
- [x] [CONCEPT: 资产解析顺序] 定义企业同编码资产优先、平台资产回退的确定性加载顺序。证据scoped repository 和 runtime loader 测试。
- [x] [CONCEPT: ONLYOFFICE 状态机] 定义 `active → processing → consumed|failed`以及过期、撤销和重放拒绝。证据Session 模型、迁移与 callback 测试。
- [x] [CONCEPT: 风险场景测试] 定义显式 `target_tenant_id` 与 TestRun 证据企业不允许默认或全局样本。证据scenario schema/service 测试。
## 3. 后端实现
- [x] [CONCEPT: 后端职责] 所有 AgentAsset 列表、详情和 code 查询接入结构化可见谓词。证据:`agent_asset.py` repository 与 `agent_assets.py`
- [x] [CONCEPT: 权限与信任边界] 资产写入、版本和审核接入 RuleEditor/RuleReviewer/平台管理员依赖。证据AgentAsset 和风险规则 endpoints。
- [x] [CONCEPT: 权限与信任边界] 用 `employee:{id}` 或归一化 `username:{name}` 替代请求 actor/reviewer。证据`stable_user_principal()` 与审计断言。
- [x] [CONCEPT: 后端职责] 发布门禁、评审、监控、召回、遥测、调度和处置标签均接入租户作用域。证据8 个 release 定向测试文件。
- [x] [CONCEPT: 后端职责] foundation/bootstrap 只查平台 seed避免修改同编码企业资产。证据foundation helper 拆分与回归测试。
- [x] [CONCEPT: 后端职责] 风险运行时按企业优先、平台回退加载规则禁止跨企业版本。证据expense runtime/loader 改造。
- [x] [CONCEPT: 风险场景测试] 场景请求校验目标企业,并将 `ExpenseClaim.tenant_id` 作为 SQL 第一层谓词。证据:真实样本场景与 TestRun 断言。
- [x] [CONCEPT: ONLYOFFICE 状态机] 新增持久化 content/callback token、原子 claim 和终态处理。证据:`agent_asset_onlyoffice_security.py`
- [x] [CONCEPT: 权限与信任边界] content/callback 按会话重建 machine scope拒绝跨企业、跨资产、跨版本和平台只读回写。证据ONLYOFFICE 安全测试。
- [x] [CONCEPT: 降级与回滚策略] 复用安全下载器,拒绝错误 origin、非公网 DNS、重定向、超限及异常 OOXML。证据下载器单测与 callback 回归。
- [x] [CONCEPT: 数据] 新增 `20260717_0026`,完成旧平台数据回填、约束、索引和有事实 downgrade 保护。证据:迁移文件与 PostgreSQL 探针。
## 4. 代码结构
- [x] [CONCEPT: 后端职责] 将访问策略、ONLYOFFICE 安全和序列化从大型 AgentAsset service 中拆出。证据:新增小职责模块,核心文件均低于 800 行。
- [x] [CONCEPT: 后端职责] 将风险规则字段推断/草稿对齐从生成主服务抽到独立模块。证据:`risk_rule_generation.py` 619 行、`risk_rule_generation_fields.py` 272 行。
- [x] [CONCEPT: 后端职责] foundation 按资产 helper、seed、topup、财务规则和电子员工任务拆分。证据拆分模块与 28 项定向回归。
## 5. 前端契约
- [x] [CONCEPT: 前端契约] 后端 DTO 已返回 `tenantId/scope`客户端可识别平台只读资产。证据schema 与 AgentAsset API 回归。
- [x] [CONCEPT: 前端契约] ONLYOFFICE 配置按权限返回 view/edit平台资产仅平台管理员可编辑。证据config 单测与平台回写拒绝测试。
- [x] [CONCEPT: 目标与非目标] 本切片不做 Agent 资产管理页面视觉重构。证据CONCEPT 非目标。
## 6. 测试与验证
- [x] [CONCEPT: 测试方案] 完成租户可见性、企业覆盖平台、无上下文平台只读、跨租户 404、目标企业场景和稳定身份测试。证据`test_agent_asset_tenant_security.py`
- [x] [CONCEPT: 测试方案] 完成发布门禁、监控、召回、运行时、调度、遥测及 ONLYOFFICE callback 汇总回归。证据:容器内 58 项通过。
- [x] [CONCEPT: 测试方案] 完成 AgentAsset service 与 foundation 兼容回归。证据:容器内 28 项通过、4 项无关差旅计算器用例主动排除。
- [x] [CONCEPT: 测试方案] 完成风险生成、修订和 golden evaluator 回归。证据:容器内 49 项通过2 项旧发布门禁口径由对应切片处理。
- [x] [CONCEPT: 测试方案] 完成费用风险租户接线回归。证据13 项通过2 项旧测试使用未保存 claim已记录为共享测试夹具问题。
- [x] [CONCEPT: 测试方案] 完成一次性 PostgreSQL 迁移验证。证据:旧数据升级、同编码多企业、非法约束、跨租户 FK、有事实 downgrade 拒绝,以及 `base → head``head → 0025 → head` 均通过。
- [x] [CONCEPT: 指标与验收] 相关文件 Ruff 通过、py_compile 通过、ORM mapper 84 张表完成配置、`git diff --check` 通过。
## 7. 文档与上线
- [x] [CONCEPT: 本轮实现记录] 完成 CONCEPT、分阶段 TODO 和安全 bug 修复日志。证据:本目录及 `dev-logs/bugs/agent-asset-tenant-isolation-and-onlyoffice-security.md`
- [x] [CONCEPT: 风险与开放问题] 记录无 tenant 旧调用、未保存 claim 测试、旧发布 aggregate 口径和企业间受控复制边界。证据CONCEPT 风险章节。
- [ ] [CONCEPT: 降级与回滚策略] 上线前确认 ONLYOFFICE 下载 origin 在应用容器内解析为公网地址并使用可信 TLS。证据要求生产白名单配置、容器 DNS/TLS 与实际编辑回写验证。
- [ ] [CONCEPT: 降级与回滚策略] 上线前在备份副本执行 `0025 → head`,确认不存在无法归属的历史记录,并演练有事实情况下的受控回滚流程。

View File

@@ -0,0 +1,161 @@
# 商业资源权威边界闭环 概念文档
更新时间2026-07-17
## 功能一句话
只对已经通过可信边界且随业务事务成功持久化的连接器事件和附件源文件写入计量,并在任何拒绝、失败或回滚时先释放额度、恢复文件,不生成虚假用量。
## 背景与问题
商业底座已经具备套餐、权益、硬配额、执行前预占和追加式用量/成本事实,但真实资源入口仍存在两处断点:
- 金融连接器能验证 HMAC、阻断重放与 payload 冲突并持久化事件,却没有把“首次接受且提交成功”接到 `events` 权威计量。
- 报销附件会在上传开始时直接删除旧目录再写新文件。若配额在写入后才判断,旧文件已经遭到破坏;若数据库随后回滚,文件系统和商业事实还可能与业务记录分叉。
- 独立商业 Session 不能提交调用方业务 Session。反过来商业结算也不能早于业务提交否则数据库回滚后仍会留下客户用量。
- SQLite 单连接测试环境中,另开 Session 检查“未配置计量器”会回滚同一底层连接上的业务事务;未配置路径必须先在调用方 Session 只读短路。
因此,本能力把“预占—业务变更—事务终态—结算/释放”定义为统一资源协议,而不是在端点成功返回前后零散补记用量。
## 目标与非目标
### 目标
- [G1] 金融连接器只对新建、已认证、已提交的 `FinancialConnectorEvent` 计量一个 `events`
- [G2] 鉴权失败、稳定重放、payload 冲突、配额拒绝和业务事务回滚均不产生连接器用量。
- [G3] 附件上传按源文件 `bytes` 在覆盖旧文件前执行硬配额预占。
- [G4] 附件业务事务回滚时恢复旧文件树、删除未提交的新文件并释放预占;提交后才追加用量。
- [G5] 删除单附件、费用明细和整张报销单时,把文件删除绑定到数据库事务;回滚恢复、提交后最终清理。
- [G6] 商业事实只保存哈希化操作身份、固定工具维度、数量和安全来源,不保存外部事件原文、关联号、单号、文件名或文件正文。
- [G7] 同一已释放操作允许在相同账期重新预占,支持业务回滚后的安全重试。
### 非目标
- [NG1] 本切片不把附件 `bytes` 定义为实时磁盘容量;它表示成功持久化的源文件写入流量。
- [NG2] 删除附件不追加正向用量,也不自动冲销历史写入事实;容量型 GB-month 计费需要独立快照账本。
- [NG3] 不对连接器重放、鉴权失败和冲突运营事件收费。
- [NG4] 不在商业事实中复制金融 payload、OCR 文本、文件名、报销事由或客户外部引用。
- [NG5] 不在本切片新增商业配置页面或改变套餐价格。
## 用户与场景
- 企业员工上传或替换报销附件:平台先检查源文件字节配额,再覆盖文件;数据库提交后才形成用量。
- 企业员工删除附件、费用明细或草稿报销单:删除操作不被配额阻止,数据库失败时文件恢复。
- 金融连接器发送回执:只有首次通过签名验证、去重并持久化的事件消费一个连接器事件额度。
- 连接器因网络重试再次发送相同事件:返回既有业务响应,不重复预占或计量。
- 平台运营排查账单:能看到 `connector/financial.ingest/events``storage/attachment.upload/bytes`,但看不到业务原文和敏感标识。
## 功能能力
- 连接器权威观察器:认证和首次事件判定之后预占,业务事务 `after_commit` 后结算。
- 附件权威观察器:固定使用 `storage + attachment.upload + bytes`,以 `len(content)` 作为执行前与执行后的同一权威数量。
- 事务回调批次:同一 Session 可登记多个资源操作;提交按登记顺序结算,回滚按相反顺序补偿。
- 文件暂存事务:旧文件或目录先原子重命名为同目录隐藏备份;提交删除备份,回滚删除新目录并恢复原路径。
- 删除事务:单附件、费用明细附件和整张报销单附件树均先暂存,数据库提交后才最终删除。
- 已释放预占重开:相同指纹、相同订阅/权益/账期的 `released` 预占可重新进入 `reserved`,并重新执行硬配额判断。
- 未配置兼容:调用方 Session 先只读确认没有匹配计量器,直接兼容执行且不创建商业事实。
## 方案设计
### 前端
- 当前不新增页面。
- 附件上传端点接受可选 `X-Request-ID`,商业硬配额拒绝返回 HTTP 429现有 400/404 业务错误保持不变。
- 客户端应为同一次上传重试复用 `X-Request-ID`。未提供时服务端用认证会话、租户、Claim/Item 哈希和内容摘要形成保守幂等身份。
### 后端
- `FinancialConnectorCommercialObserver` 使用认证后配置租户、配置摘要和请求指纹构造哈希身份,固定工具维度为 `connector/financial.ingest`
- `FinancialConnectorIngestionService` 先完成认证、外部事件串行化和既有事实判断;只有新事件才申请预占。事件 flush 成功后把完成/释放绑定到调用方 Session。
- `CommercialTransactionCallbacks` 把多个独立商业操作绑定到一个业务事务。`after_commit` 执行完成回调,`after_rollback` 逆序执行补偿回调;回调异常只记录日志并保留可补偿预占,不伪装业务提交失败。
- `ExpenseClaimAttachmentCommercialObserver` 固定使用 `bytes`,配额拒绝发生在任何旧文件移动、`rmtree``unlink` 或新文件写入之前。
- `ExpenseClaimAttachmentFileTransaction` 负责旧路径暂存、提交清理和回滚恢复;上传、删除附件、删除费用明细和删除报销单复用同一协议。
- `CommercialRuntimeReservationService` 允许同一指纹的 `released` 预占在相同账期重新预占;重新检查当前合同、计量器快照和硬配额。
- OCR 与 RuntimeChat 的直接商业桥同样增加调用方 Session 的只读未配置短路,避免 SQLite 单连接环境的独立 Session 回滚调用方事务。
### 算法/规则
- 连接器的权威数量恒为一个已提交事件:`actual_events = 1`
- 附件的权威数量为请求中源文件真实字节数:`actual_bytes = len(content)`
- 连接器只允许 `quantity_basis=events`;附件只允许 `quantity_basis=bytes`。错误基准在预占前失败关闭。
- 同一业务事务包含多个附件操作时,回滚补偿使用 LIFO确保同一 Item 连续替换也能恢复到事务开始前状态。
### 数据
- 不新增数据库表和迁移。
- 复用 `commercial_runtime_reservations``usage_meter_events` 和可选 `commercial_cost_events`
- 连接器用量 metadata 只包含固定 meter 版本、哈希化 operation call、固定工具名、数量基准、权益、预占、provider 代码、结果和安全来源。
- 附件用量不包含文件名、storage key、Claim/Item 原值、MIME、OCR 文本或文件正文。
### 权限
- 连接器租户只来自 HMAC 认证后配置,不能由未验证 header 或 payload 单独决定。
- 附件租户只来自 `CurrentUserContext.tenant_id`Claim 和 Item 已先通过费用单租户访问策略校验。
- 付费或配额状态不能放宽现有报销状态、附件可变性、连接器签名、租户或财务对账规则。
### 降级策略
- 没有匹配计量器:保持原业务兼容,不写预占、用量或成本。
- 商业配置错误、重复计量器、错误数量基准或硬配额不足:在业务/文件副作用前拒绝。
- 业务事务回滚:释放预占;连接器不留事件用量,附件恢复原文件。
- 商业完成回调异常:业务提交不反转;预占保持可审计状态,由既有补偿流程处理。
- 文件恢复失败:记录错误并保留隐藏备份,不把失败删除误报为成功;需要运维根据事务日志和隐藏路径人工恢复。
## 算法与公式
### 硬配额判断
```text
allowed = used_quantity + held_quantity + requested_quantity <= hard_limit
```
- 连接器 `requested_quantity = 1 event`
- 附件 `requested_quantity = len(content) bytes`
- 判断在订阅与权益锁内完成;拒绝时真实连接器处理和附件破坏性操作尚未发生。
### 业务事务终态
```text
permit -> business mutation -> commit -> usage(actual_quantity)
-> rollback -> release reservation + restore files
```
### 附件写入计量口径
```text
billable_attachment_bytes = Σ len(source_content)
```
- 只汇总成功随业务事务提交的源文件写入。
- OCR 临时文件、预览图、metadata 和隐藏事务备份不进入本 meter。
- 删除不产生负数;实时容量需另建周期快照和保留量口径。
## 测试方案
- 连接器首次提交、稳定重放、payload 冲突、签名失败、业务回滚、回滚后重试、硬配额拒绝和敏感 metadata 反向断言。
- 附件商业bytes 提交、回滚释放、错误 basis、硬配额拒绝发生在旧文件移动前、相同事务连续替换逆序恢复。
- 附件文件:单附件删除、费用明细删除、整单删除的 commit/rollback 文件终态。
- 既有回归:连接器服务/端点/配置生命周期、附件归集任务、票据夹、报销端点、附件专项、OCR 与 RuntimeChat。
- 质量:相关 Python 文件 Ruff核心服务、Mixin 和端点继续低于 800 行。
- 所有后端测试只在 `local-x-financial-linux` 容器内执行,单命令超时不超过 60 秒。
## 指标与验收
- [A1] 每个首次提交的连接器事件最多一个 `events` 用量;重放、鉴权失败和冲突为零。
- [A2] 连接器或附件业务回滚后 `usage_meter_events` 为零,预占为 `released`,相同操作可重新预占。
- [A3] 附件配额拒绝时原文件内容和路径完全不变。
- [A4] 附件数据库回滚后原文件恢复;提交后新文件/删除结果与数据库一致。
- [A5] 用量 metadata 中不存在 external event ID、correlation ID、报销单号、文件名或业务正文。
- [A6] 连接器、附件归集、报销端点、OCR、RuntimeChat 和商业直接运行相关回归在容器内通过。
## 风险与开放问题
- 本地文件系统与数据库不是分布式事务。进程在文件暂存后、事务终态回调前被强杀时,隐藏备份可能需要启动恢复器扫描;当前正常异常和显式 commit/rollback 已闭环。
- `X-Request-ID` 当前为可选。缺失时采用内容绑定的保守幂等身份,优先避免客户端网络重试重复收费;若未来按每次写请求收费,应把请求 ID 升级为必填并为业务上传建立持久幂等记录。
- `bytes` 是源文件成功写入流量,不等于实时保留容量。若商业模式采用存储包或 GB-month需要新增周期容量快照、删除后容量释放和归档层级计价。
## 本轮实现记录
- 2026-07-17完成连接器首次接受事件的 `events` 预占—提交后结算并确保重放、鉴权失败、payload 冲突、配额拒绝和回滚不计量。
- 2026-07-17完成附件源文件 `bytes` 执行前硬配额、事务文件暂存、上传/删除/费用明细删除/整单删除的提交与回滚闭环。
- 2026-07-17完成事务回调批次逆序补偿、未配置计量器调用方 Session 短路和已释放预占同账期安全重开。

View File

@@ -0,0 +1,59 @@
# 商业资源权威边界闭环 开发 TODO
更新时间2026-07-17
关联方案:[CONCEPT.md](./CONCEPT.md)
## 使用规则
- 任务边界、计量口径和安全约束以 CONCEPT 对应章节为准。
- `[x]` 只表示已有代码或容器验证证据;没有证据不得勾选。
- 所有后端测试必须在 `local-x-financial-linux` 容器内运行,单命令最长 60 秒。
## 1. 调研与边界
- [x] [CONCEPT: 背景与问题] 盘点连接器认证、重放、冲突、业务 commit 和 operational event 边界。证据:`financial_connector_ingestion.py``financial_connectors.py` 与连接器服务/端点测试。
- [x] [CONCEPT: 背景与问题] 盘点附件上传覆盖、单附件删除、费用明细删除、整单删除和批量归集事务。证据:`expense_claim_attachment_operations.py``expense_claims.py``expense_receipt_association.py`
- [x] [CONCEPT: 目标与非目标] 冻结连接器 `events` 与附件源文件写入 `bytes` 两个唯一 meter不把删除伪装成正向用量。证据两个商业 observer 的固定工具名和 required basis。
## 2. 契约与设计
- [x] [CONCEPT: 后端] 定义业务事务提交后结算、回滚释放的回调协议。证据:`commercial_transaction_callbacks.py`
- [x] [CONCEPT: 算法/规则] 定义 `events=1``bytes=len(content)` 的权威数量,错误 basis 预占前拒绝。证据:`required_quantity_basis` 与资源边界测试。
- [x] [CONCEPT: 数据] 定义商业 metadata 脱敏白名单,不复制业务原文与敏感标识。证据:`test_connector_only_meters_new_authenticated_committed_event` 反向断言。
## 3. 后端实现
- [x] [CONCEPT: 后端] 连接器在认证和既有事件判断之后预占,事件提交后结算。证据:`financial_connector_commercial.py``financial_connector_ingestion.py`
- [x] [CONCEPT: 降级策略] 连接器重放、鉴权失败、冲突和事务回滚不写用量。证据:`test_commercial_resource_boundaries.py`
- [x] [CONCEPT: 后端] 附件在任何旧文件移动或新文件写入前按源文件 bytes 预占。证据:`stage_attachment_replacement()` 调用顺序与配额拒绝测试。
- [x] [CONCEPT: 后端] 上传回滚恢复旧目录,提交后清理备份并追加用量。证据:附件 commit/rollback 资源测试。
- [x] [CONCEPT: 后端] 单附件、费用明细和整张报销单删除均绑定数据库事务。证据:`stage_attachment_deletion()``stage_claim_attachment_deletion()` 与既有删除回归。
- [x] [CONCEPT: 后端] 同一 Session 多操作提交顺序结算、回滚逆序恢复。证据:事务批次实现与连续替换 LIFO 测试。
- [x] [CONCEPT: 降级策略] 未配置 meter 时在调用方 Session 只读短路,不让独立 SQLite Session 回滚业务。证据Direct bridge `lookup_session`、附件归集 31 项通过。
- [x] [CONCEPT: 后端] 允许相同 released 预占在相同账期重开并重新检查配额。证据Direct operation released retry 测试与连接器回滚后重试测试。
## 4. 算法/规则实现
- [x] [CONCEPT: 硬配额判断] 使用 `used + held + requested <= hard_limit` 的既有锁内配额算法。证据:`CommercialRuntimeReservationService.reserve()` 与资源配额拒绝测试。
- [x] [CONCEPT: 附件写入计量口径] 只结算成功持久化的源文件 bytes不包含 OCR/预览/metadata。证据observer authoritative quantity 与 metadata。
- [x] [CONCEPT: 业务事务终态] 回滚不记 usage/cost已释放操作可再次 permit。证据商业直接运行与资源边界回归。
## 5. 前端实现
- [x] [CONCEPT: 前端] 上传端点接收 `X-Request-ID` 并把商业拒绝映射为 HTTP 429。证据`reimbursements.py`
- [x] [CONCEPT: 目标与非目标] 本切片不新增商业或附件页面。证据CONCEPT 非目标;本轮无前端文件变更。
## 6. 测试与验证
- [x] [CONCEPT: 测试方案] 连接器首次提交、重放、冲突、鉴权失败、回滚、重试、配额和脱敏通过。证据:容器内 `test_commercial_resource_boundaries.py` 9 项通过商业资源、Direct、reservation、OCR、RuntimeChat 与连接器组合最终 `63 passed`
- [x] [CONCEPT: 测试方案] 商业 Direct、OCR、RuntimeChat 相关回归通过。证据:容器内组合 33 项通过;新增 released retry 后 Direct + 资源组合 22 项通过;最终商业/连接器组合 63 项通过。
- [x] [CONCEPT: 测试方案] 附件归集和票据夹回归通过。证据:容器内与报销端点最终组合 `54 passed`
- [x] [CONCEPT: 测试方案] 报销端点与附件专项通过。证据:容器内报销/归集组合 54 项、Expense Claim attachment `20 passed, 101 deselected`
- [x] [CONCEPT: 测试方案] 连接器既有服务、端点和配置生命周期回归通过。证据:容器内 15 项通过;最终纳入商业/连接器组合 63 项通过。
- [x] [CONCEPT: 指标与验收] 相关 Python 文件 Ruff、compileall 通过,核心文件均低于 800 行。证据:容器检查退出码 0最大相关文件 `expense_claim_attachment_operations.py` 为 787 行。
## 7. 文档收尾
- [x] [CONCEPT: 本轮实现记录] 创建 CONCEPT 与分阶段 TODO记录 meter、事务、降级和验证口径。证据本目录两份文档。
- [x] [CONCEPT: 风险与开放问题] 记录进程强杀恢复、可选请求 ID 和 GB-month 容量计费边界。证据CONCEPT 风险章节。

View File

@@ -0,0 +1,157 @@
# AI 费用闭环工程收口与生产就绪边界
日期2026-07-17
## 功能一句话
把 X-Financial 收口为一条租户安全、可学习、可解释、可计量的费用闭环:用户从申请、票据、报销、预审、审批到付款归档尽量少填少等,企业能看见风险、节省和真实成本,同时不把模拟数据包装成生产价值。
## 背景与问题
此前系统已经有申请、报销、审批、AI 助手和分析页面,但存在四类系统性断点:
- 业务链路能跑但申请、票据、审批、支付、ERP、归档和价值事实没有统一闭环。
- AI 能给建议但用户反馈、工作流结果、记忆、few-shot 和发布质量没有形成受控学习链。
- 单租户演示可用但员工、知识、规则资产、Hermes、报告、缓存、向量库和文档编辑仍有跨租户风险。
- 能展示费用,却不能严格区分确认现金节省、工时价值、风险暴露、预计机会、平台收入和内部成本。
本轮工程改造围绕上述断点逐步完成,不以页面数量或 mock 日志作为完成标准。
## 目标与非目标
### 目标
- 完成申请到付款、ERP、归档和冲回的可验证费用链路。
- 让 AI 从可信的字段修改、提交、审批、付款、风险处置和人工标签中学习,并保留解释、撤销和发布门禁。
- 对本轮纳入的 Claim、Employee、Agent Asset、Knowledge、Ontology、Hermes、Report 等共享核心数据建立可信会话、显式租户、复合约束、首层查询过滤和跨租户失败关闭;仍以 JSON 保存 tenant 的 legacy 状态继续列为后续迁移。
- 建立 Savings Ledger、CFO 价值看板、商业权益、资源计量、客户 ROI、平台成本和定价走廊。
- 将大型 Service 按访问策略、身份解析、持久化、规则、附件、计量和投影职责拆分,受代码体积门禁的核心类/组件保持低于 800 行。
### 非目标
- 不替客户决定首个支付/ERP provider、签名映射、会计期间、汇率来源、退款口径或大额双签阈值。
- 不用 mock 回执冒充真实现金、真实开票、真实回款或真实客户 ROI。
- 不在没有生产域名、可信 TLS、备份副本和真实 SMTP 的情况下声称完成生产上线。
- 不以一次工程验证替代 30/90 天真实企业试点和商业定价验证。
## 用户与场景
- 员工:通过 AI 预填、票据归集、结构化预审和断点续办,减少填表和退回。
- 直属领导、预算负责人和财务:在租户安全的任务队列中处理例外、风险、豁免、支付和财务确认。
- CFO/管理层:按币种和证据等级查看节省、机会、周期、预算、风险护栏和数据质量。
- 租户管理员:管理企业知识、规则资产、记忆、报告配置和商业权益,但不能越权替业务人员自证。
- 平台运营:管理套餐、订阅、计量、成本、发布门禁和连接器配置,不能跨租户读取业务正文。
## 功能能力
### 费用闭环
- Expense Case、Link、Business Event 将申请、票据、报销、预审、审批、付款、ERP、归档和冲回串成可回放链路。
- 服务端预览决策、预审握手、审批动作和风险处置使用版本、指纹、请求 ID、事务和乐观前置条件防止陈旧重放。
- 财务连接器区分 production-mode 外部事件契约、内部人工确认和 test/mock/staging 模拟事实;错误金额、币种、单据或状态不会推进付款,真实外部现金仍须 provider 联调证明。
### 越用越智能
- AI Decision、Feedback、Workflow Outcome、Memory Evidence 和 few-shot 按租户、主体、场景、规则版本与证据等级隔离。
- 个人及企业/部门低敏偏好可解释、可过期、可撤销;企业规则和当前输入始终高于个人记忆。
- 发布遥测从真实 observation、可信人工 label、盲审负样本和保守 recall 进入 Canary/Release Guard证据不足保持 collecting。
### 风险与安全
- 不透明 Bearer 会话是身份事实;请求中的 tenant、actor、reviewer、role 不能覆盖服务端上下文。
- Employee、Claim、Agent Asset、Knowledge、Ontology、Hermes、Report、Qdrant、文件路径和缓存键均按租户隔离。
- ONLYOFFICE 使用数据库一次性会话、资源绑定 token、DNS/IP 校验、可信 origin、大小/MIME/OOXML 校验和重放拒绝。
### 节省与商业闭环
- Savings Ledger 分离 baseline、opportunity、realization、evidence 和 append-only event未确认结果不进入确认现金 KPI。
- CFO 看板将现金、工时、风险暴露和预计机会分开,并显式展示 collecting、unavailable 和 coverage gap。
- 商业层分离套餐、订阅、权益、用量、内部成本、账期、客户 ROI、贡献毛利和定价建议。
- Orchestrator、OCR、Runtime Chat、连接器和附件源文件写入均接入权威 permit/reserve/commit/release 计量边界。
## 方案设计
```mermaid
flowchart LR
A["申请与票据"] --> B["服务端预览与预审"]
B --> C["审批任务与风险处置"]
C --> D["支付/ERP 连接器"]
D --> E["归档与冲回"]
B --> F["反馈、记忆与 few-shot"]
C --> F
D --> G["Savings Ledger"]
E --> G
G --> H["CFO 价值与 ROI"]
A --> I["商业预占与计量"]
B --> I
D --> I
F --> J["盲审遥测与 Release Guard"]
```
核心边界如下:
1. 认证层生成可信 `CurrentUserContext`,业务入口不得从请求体补造身份。
2. 业务服务以 `tenant_id` 作为第一层 SQL 条件ORM 复合外键和数据库约束作为第二层保护。
3. 状态与业务事件在同一事务提交;外部副作用和资源计量使用稳定请求 ID、追加事实和补偿状态。
4. 学习只消费可信服务端事实自由文本评论、mock 数据和未确认推断不得进入训练或价值 KPI。
5. 分析投影只读事实账本,并保留币种、时间窗口、证据等级和数据质量状态。
## 数据与契约
- Alembic 正式链从 Expense Case、认证、AI 学习一直升级到 `20260717_0028`migration-owned 表由启动前置检查统一管理。
- `20260716_0015``0024` 建立 Savings、商业计量、连接器、发布遥测、账期、运行事件、盲审和资源数量口径。
- `20260717_0025``0028` 建立租户身份、Agent Asset、Knowledge、Hermes/Ontology/Report 安全基础。
- 关键 append-only 表由数据库 trigger 阻止 UPDATE/DELETE幂等键和请求指纹区分安全重放与冲突载荷。
- production-mode 签名事件、内部确认、模拟回执、确认节省、预计机会、收入和成本使用不同类型,不互相降级替代;本地自签事件不作为真实现金证据。
## 算法与规则
- 硬配额:`used + held + requested <= hard_limit`,预占在业务提交后结算,回滚后释放并允许同账期安全重试。
- 确认现金节省:仅汇总 `finance_confirmed + canonical + cash` 的 realization冲回通过负向追加事实抵消。
- 客户 ROI按币种分别计算确认价值与客户费用不跨币种强行相加证据不足返回 unavailable。
- 贡献毛利平台收入减去可归属模型、OCR、存储、连接器和其他运行成本内部成本与客户价值分账。
- 定价走廊:成本下限、确认价值上限、成功费封顶和合同约束共同决定建议,系统不自动替客户签订价格。
- 发布门禁:只有 observation、独立可信 label、盲审负样本和保守置信下界达到阈值才允许晋级失败或 collecting 保持 stable。
## 测试方案
所有后端、集成、迁移和依赖验证以 Docker 容器 `local-x-financial-linux``/app` 为唯一事实来源,单命令限制 60 秒。
- 后端176 个测试文件按有界分片或专项运行,费用主服务 121 项单独回归;所有检查通过,条件跳过的 PostgreSQL 项随后在真实 PostgreSQL 探针补跑。
- PostgreSQLfresh schema、完整 upgrade/downgrade/re-upgrade、复合租户约束、append-only、迁移保护和并发专项最终 `87 passed / 0 skipped / 0 failed`,最终 head 为 `20260717_0028`
- 前端Node 全量 `815 passed / 0 failed`Vite production build 通过;仅保留 chunk size 提示。
- 移动端:`npm run lint``npx tsc --noEmit` 通过;真实移动 API 与设备浏览器链路仍属于上线验收。
- 静态质量:所有 197 个新增 Python 文件通过 Ruff目标模块 compileall、受门禁核心类/组件 800 行检查和 `git diff --check` 通过。全仓 Ruff 仍包含既有基线格式债,不在本轮批量改写用户已有代码。
## 指标与验收
### 工程验收
- A1申请到支付/ERP/归档/冲回的纵向事实链可通过 E2E 重放。
- A2现金、工时、风险和预计机会分账缺数据不伪造为 0。
- A3身份、租户、角色、业务范围和双人复核在服务端及数据库层失败关闭。
- A4费用基线、预算、异常归因、节省漏斗和 CFO 下钻使用租户安全事实。
- A5商业权益、配额、账期、用量、成本、ROI、毛利和定价建议可审计。
- A6AI 反馈、记忆、few-shot、盲审、Canary 和回滚均有证据等级和降级路径。
- A7迁移、并发、后端、前端、移动静态检查和差异检查形成可重复验证记录。
- A8文档明确区分工程完成、生产上线和真实商业验证不用 mock 冒充后两者。
### 真实试点指标
以下指标必须由首个企业在 30/90 天试点中建立基线后评估:报销创建时间、自动填充率、首次提交完整率、退回率、人工触点、完成周期、风险反馈、确认现金节省、工时价值、客户 ROI 和平台贡献毛利。
## 本轮实现记录
本轮约定的六个工程步骤已完成:商业资源计量与 EmployeeService 拆分、Expense Claim 访问策略与身份解析拆分、全链租户安全、后端/迁移/并发验证、前端/移动静态验证,以及文档与 bug 记录收口。
这六步范围内不再存在需要继续编码才能证明的阻断项。生产上线和商业验证仍需要目标环境与客户决策;上位长期路线图中统一 Outbox/legacy 清理、移动实机闭环、供应商事实和高级 AI 管理等扩展能力也没有被本轮文档悄悄标成完成,详见同目录 `TODO.md` 第 6-8 节。
## 风险与开放问题
- ONLYOFFICE 生产下载域名必须在应用容器解析为允许的公网地址并使用可信 TLS开发网络的保留地址会按设计拒绝。
- `0025 → 0028` 必须在生产备份副本演练历史归属和受控回滚,不能用 disposable 空库代替真实数据演练。
- 首个支付/ERP provider、字段映射、SLA、会计期间、汇率、批次拆分、退款和大额双签需要客户确认。
- SMTP、企业报告收件人和实际投递审计需要逐租户配置。
- 消息平台、移动设备真实流程、浏览器关键链路和私有部署安全验收需要目标环境联调。
- 30/90 天基线、客户财务签字、目标毛利、价值分享比例、合同、税率、开票和回款边界不能由代码自行完成。
- 上位长期路线图仍保留统一 correlation/Outbox、旧模型收敛、完整移动端、供应商事实、消息/SSO 模板、数据导出与高级 AI 管理等产品扩展;它们不阻断本轮六步收口,但属于“完整产品愿景”后续工作。

View File

@@ -0,0 +1,81 @@
# AI 费用闭环工程收口与生产就绪 TODO
更新时间2026-07-17
关联方案:[CONCEPT.md](./CONCEPT.md)
## 使用规则
- 每项必须回链 `CONCEPT.md`;没有代码、迁移、接口、容器或真实环境证据不得勾选。
- `[x]` 代表本轮工程范围已完成,不代表生产环境或真实商业试点自动完成。
- mock/test/staging 只能验证契约和降级,不能证明真实现金、开票、回款、客户 ROI 或生产可用性。
## 1. 功能闭环
- [x] [CONCEPT: 费用闭环] 完成申请、票据、报销、预审、审批、付款、ERP、归档和冲回的可回放链路。
证据Expense Case/Business Event、财务连接器、Savings Ledger`test_expense_financial_value_chain_e2e.py` 与相关服务测试通过。
- [x] [CONCEPT: 越用越智能] 完成可信反馈、工作流结果、个人/企业记忆、few-shot、盲审遥测、Canary 和 Release Guard。
证据AI learning/memory、release telemetry/review/recall 模块;相关后端与 PostgreSQL 并发测试通过。
- [x] [CONCEPT: 节省与商业闭环] 完成 Savings Ledger、CFO 价值看板、商业权益/计量/成本/ROI/定价建议。
证据0015/0016/0019/0021/0024 迁移Savings/CFO/Commercial 服务、端点和前端组件。
## 2. 租户安全与代码结构
- [x] [CONCEPT: 风险与安全] 收口 Bearer 会话、Claim、Employee、Agent Asset、Knowledge、Ontology、Hermes、Report、Qdrant、文件和缓存租户边界。
证据0025-0028 迁移与 tenant security 测试;生产 `CurrentUserContext` 无缺失 tenant 构造。
- [x] [CONCEPT: 风险与安全] 完成 ONLYOFFICE 一次性会话、资源绑定、SSRF/DNS/IP、格式和重放保护。
证据Agent Asset/Knowledge ONLYOFFICE 安全服务与回归测试。
- [x] [CONCEPT: 目标与非目标] 完成大型核心模块职责拆分,保持受门禁核心类/组件低于 800 行。
证据Employee 776 行、ExpenseClaimAccessPolicy 701 行;访问策略、身份解析、目录维护、附件计量等均为独立模块。
## 3. 商业资源权威边界
- [x] [CONCEPT: 节省与商业闭环] Orchestrator、OCR、Runtime Chat、连接器和附件写入接入 permit/reserve/commit/release。
证据commercial direct/runtime bridge、connector observer、attachment commercial资源组合 63 项、附件/端点 54 项通过。
- [x] [CONCEPT: 算法与规则] 连接器仅按已认证且成功提交的事件计 `events=1`,附件仅按成功持久化源文件计 bytes。
证据:`test_commercial_resource_boundaries.py` 覆盖重放、冲突、鉴权失败、回滚、配额、重试和脱敏。
- [x] [CONCEPT: 算法与规则] 回滚释放、同账期安全重试、硬配额和未配置兼容均保持事务正确。
证据Direct、reservation、OCR、Runtime Chat、连接器和附件组合回归通过。
## 4. 容器验证
- [x] [CONCEPT: 测试方案] 后端 176 个测试文件完成有界分片或专项检查,费用主服务 121 项单独通过。
证据:所有分片退出码 0条件 PostgreSQL 跳过已在真实探针补跑。
- [x] [CONCEPT: 测试方案] fresh PostgreSQL 完成迁移、降级、再升级、并发和数据库约束专项。
证据:`87 passed / 0 skipped / 0 failed`,最终 head `20260717_0028`,一次性数据库已清理。
- [x] [CONCEPT: 测试方案] Web 全量测试和生产构建通过。
证据:`815 passed / 0 failed`Vite production build 通过。
- [x] [CONCEPT: 测试方案] Mobile lint 与 TypeScript 静态检查通过。
证据:`npm run lint``npx tsc --noEmit` 均退出码 0。
- [x] [CONCEPT: 测试方案] 新增 Python、编译、文件大小和差异质量门禁通过。
证据197 个新增 Python 文件 Ruff 通过;目标 compileall、受门禁核心类/组件 800 行检查和 `git diff --check` 通过;全仓历史 Ruff 债单独披露。
## 5. 文档与可追溯性
- [x] [CONCEPT: 本轮实现记录] 回填 Savings、商业、连接器、发布遥测和上位 AI 闭环 TODO 的真实完成状态。
证据2026-07-13、2026-07-16 对应功能文档及本目录。
- [x] [CONCEPT: 本轮实现记录] 为本轮生产 bug 创建独立修复日志,并先完成 upstream/local-ahead 检查。
证据:`document/development/2026-07-17/dev-logs/bugs/``origin/main` 无新提交,本地 ahead 17 已记录。
- [x] [CONCEPT: 指标与验收] 明确工程完成、生产上线、真实试点三种完成口径。
证据CONCEPT“目标与非目标”“指标与验收”“风险与开放问题”。
## 6. 生产上线(需要目标环境)
- [ ] [CONCEPT: 风险与开放问题] 配置生产 ONLYOFFICE 允许 origin并在应用容器验证公网 DNS、可信 TLS 和真实编辑回写。
- [ ] [CONCEPT: 风险与开放问题] 在生产备份副本演练 `0025 → 0028`、历史默认企业归属、回滚保护和恢复。
- [ ] [CONCEPT: 风险与开放问题] 为每个启用企业配置 SMTP、报告收件人并验证实际投递审计。
- [ ] [CONCEPT: 风险与开放问题] 完成真实浏览器、移动设备、消息平台和私有部署环境的关键流程验收。
## 7. 客户与商业验证(需要业务决策)
- [ ] [CONCEPT: 风险与开放问题] 确认首个支付/ERP provider、签名、字段映射、SLA、重试、批次、汇率、会计期间、退款和大额双签口径。
- [ ] [CONCEPT: 真实试点指标] 采集首个企业 30/90 天基线并验证效率、风险、现金节省、工时价值和客户 ROI。
- [ ] [CONCEPT: 风险与开放问题] 由客户财务签字确认节省归因、去重、汇率、工时价值和报告口径。
- [ ] [CONCEPT: 风险与开放问题] 冻结目标毛利、包含量、超额策略、价值分享、合同、税率、开票、回款、坏账和收入确认。
## 8. 长期产品路线图(不属于本轮六步阻断)
- [ ] [CONCEPT: 风险与开放问题] 统一所有阶段 correlation/事务 Outbox并完成 `agent_conversations` 结构化租户、旧 `ReimbursementRequest``risk_flags_json` 和少见状态迁移。
- [ ] [CONCEPT: 风险与开放问题] 完成移动端真实 API、拍照/OCR/票据/草稿实机闭环,以及全浏览器键盘、焦点和响应式验收。
- [ ] [CONCEPT: 风险与开放问题] 接入租户化供应商/合同/单位价格事实、真实消息/SSO/电子档案模板、删除传播和数据导出。
- [ ] [CONCEPT: 风险与开放问题] 扩展“我的 AI 记忆”、保留/敏感级别/动作上限配置,以及自动化依据、撤销、抽检和版本可视化。

View File

@@ -0,0 +1,106 @@
# Hermes、本体解析与财务报告多租户安全 概念文档
更新时间2026-07-17
## 功能一句话
让本体解析、员工行为画像、Hermes 扫描、数字员工看板和定时财务报告从请求到存储全程绑定可信企业,并在任何租户上下文缺失或冲突时停止运行。
## 背景与问题
本体解析曾在建立 `AgentRun` 前调用模型,并从全局员工、组织、客户、供应商、项目和单据字典构造提示词;员工画像详情可按任意员工 ID 查询Hermes 扫描、看板和财务报告存在全表读取、全局收件人以及跨企业复用存储路径的风险。内部 Orchestrator 调用还可在没有认证用户时继续运行,无法证明 `AgentRun` 的企业归属。
## 目标与非目标
### 目标
- HTTP 入口只信任 `CurrentUserContext.tenant_id`,内部任务只接受显式、已注册且启用的 `trusted_tenant_id`
- 本体解析在任何模型调用前创建租户化 `AgentRun`,商业运行上下文和失败证据可追溯。
- 员工、组织、费用、应收、应付、画像、风险、提醒和看板查询在首个 SQL 中限定租户。
- 员工画像仅允许本人、直属领导、财务/高管或管理员读取;越权和跨租户统一返回 404。
- 财务报告按租户配置收件人、生成内容、存储文件和幂等运行账本,不使用全局邮件回退。
- 所有 Hermes 定时任务逐个枚举 `status=active` 的企业,运行记录同时在 route/ontology 保存租户快照。
### 非目标
- 不提供客户端选择或覆盖租户的兼容参数。
- 不实现跨企业合并分析、集团穿透报表或平台运营后台。
- 不在本切片中重构预算分配等仍属旧模型的全部业务域。
## 信任边界
```text
HTTP 请求 ── CurrentUserContext.tenant_id ─┐
├─→ tenant-scoped AgentRun
内部任务 ── active trusted_tenant_id ──────┘ ├─ route_json.tenant_id
└─ ontology_json.tenant_id
```
- 客户端 `context_json.tenant_id` 会被可信租户覆盖,不能参与授权。
- 同时传入登录用户和内部租户时,两者必须一致。
- 内部任务租户必须存在于租户注册表且状态为 active缺失、停用或不存在均在创建 Run 前拒绝。
- 后台消费者从数据库 Run 恢复租户,并校验 route/ontology 两份快照一致。
## 功能设计
### 本体解析
- 接口覆盖请求中的 user id并把认证企业传给 `SemanticOntologyService`
- `parse()` 先建立 `running/pending_model_analysis` Run再加载租户字典、商业运行上下文并调用模型。
- 模型、规则降级和失败路径都更新同一 Run失败不会留下无企业、无状态的调用。
- 员工、部门、费用申请、应收、应付和项目参考目录均以租户作为第一层 SQL 条件。
### 员工画像
- 快照保存 `tenant_id`,并用 `(tenant_id, subject_id)` 复合外键绑定员工。
- Profile Service 的快照、费用单和 Agent Run 查询均先限定租户。
- 详情接口先在当前企业解析目标员工,再执行本人/直属领导/财务/高管/管理员访问矩阵。
- 附带 `claim_id` 时必须同时属于目标员工和当前企业。
### Hermes 与数字员工
- 风险扫描、画像扫描、风险线索、提醒扫描和看板均接受显式租户。
- 调度器只枚举 active 企业,并为每个企业独立创建任务与结果。
- 看板只统计 route/ontology 租户快照均匹配的 Run缺少或冲突快照的历史记录不会被猜测归属。
- 提醒任务的费用单、员工和关联报销查询在首个 SQL 限制当前企业。
### 财务分析报告
- `TenantFinanceReportConfig` 保存企业启停状态和经校验的收件人。
- `TenantFinanceReportRun``(tenant_id, idempotency_key)` 防止同企业同周期重复发送。
- 报告上下文只读取当前企业的费用、风险、画像和 Agent Run。
- 邮件发送只使用当前企业配置;未配置、停用或无有效收件人时 fail-closed。
- 文件存储目录使用企业标识哈希,避免路径注入和跨企业文件覆盖。
## 数据与迁移
`20260717_0028_hermes_ontology_tenant_security.py` 完成:
- 员工行为画像、Hermes 任务配置/日志和风险报告新增租户列、索引、唯一约束和复合外键。
- 新增租户财务报告配置和周期运行账本。
- 旧记录回填为 `default`,再移除 server default后续写入必须显式确定企业。
- 仅支持 PostgreSQLupgrade/downgrade 在任何 DDL 前检查方言,其他数据库直接拒绝。
- downgrade 在存在非默认企业事实或报告账本时拒绝有损回滚。
## 降级与回滚
- 租户缺失、停用、不存在或上下文冲突:不创建 Run、不查询业务数据。
- 跨企业员工、画像、费用单:统一按不存在处理。
- 企业未配置报告收件人:保留失败/跳过证据,不回退环境变量中的全局地址。
- 历史 Run 没有双租户快照:看板不纳入企业统计。
- 生产升级前必须在数据库备份副本演练 `0027 → 0028 → 0027 → 0028`
## 测试与验收
- 两企业本体目录、画像 IDOR、Hermes 扫描、风险线索、看板和报告内容/收件人/路径/幂等隔离。
- Orchestrator 缺租户、停用租户、不存在租户和认证/内部租户冲突均在 Run 前拒绝。
- 本体模型调用前存在 running Run失败后仍保存同企业失败证据。
- PostgreSQL 完成 fresh `base → 0028``0028 → 0027 → 0028` 及旧表回填/外键验证。
- 所有后端测试和 Ruff 均在 `local-x-financial-linux` 容器内执行,单命令不超过 60 秒。
## 本轮实现记录
- 2026-07-17完成本体解析前置 Run、可信商业上下文和租户参考目录。
- 2026-07-17完成员工画像模型、服务和 API 的租户隔离与访问矩阵。
- 2026-07-17完成 Hermes 扫描、提醒、看板、调度和财务报告的逐租户运行。
- 2026-07-17完成 `20260717_0028` PostgreSQL 迁移、反向回滚和安全回归。

View File

@@ -0,0 +1,40 @@
# Hermes、本体解析与财务报告多租户安全 开发 TODO
更新时间2026-07-17
关联方案:[CONCEPT.md](./CONCEPT.md)
## 1. 信任边界
- [x] [CONCEPT: 信任边界] HTTP 入口只使用认证用户企业,覆盖请求中的 user/tenant 上下文。
- [x] [CONCEPT: 信任边界] Orchestrator 内部调用必须显式传入已注册、启用的 `trusted_tenant_id`
- [x] [CONCEPT: 信任边界] Agent Run 的 route/ontology 同时保存租户快照,冲突时 fail-closed。
## 2. 本体与画像
- [x] [CONCEPT: 本体解析] 在模型调用前创建 running Run并在成功、规则降级和失败路径闭环状态。
- [x] [CONCEPT: 本体解析] 员工、组织、费用、应收、应付和项目目录全部首 SQL 租户过滤。
- [x] [CONCEPT: 员工画像] 画像模型新增租户字段、复合员工外键和租户索引。
- [x] [CONCEPT: 员工画像] 实现本人、直属领导、财务/高管/管理员访问矩阵及 claim 归属校验。
## 3. Hermes 与报告
- [x] [CONCEPT: Hermes 与数字员工] 风险扫描、画像扫描、风险线索、提醒和看板接入显式租户。
- [x] [CONCEPT: Hermes 与数字员工] 调度器逐 active 企业运行,不创建默认或全局扫描。
- [x] [CONCEPT: 财务分析报告] 新增企业报告配置、收件人校验和周期幂等账本。
- [x] [CONCEPT: 财务分析报告] 报告上下文、邮件收件人和文件路径按企业隔离。
## 4. 数据与验证
- [x] [CONCEPT: 数据与迁移] 完成 `20260717_0028` 数据回填、约束、索引、报告表和 downgrade 保护。
- [x] [CONCEPT: 数据与迁移] upgrade/downgrade 在 DDL 前拒绝非 PostgreSQL 方言。
- [x] [CONCEPT: 测试与验收] 完成本体、画像、Hermes、报告和 Orchestrator 两企业安全测试。
- [x] [CONCEPT: 测试与验收] 完成旧本体 72 项、Orchestrator 16 项及认证/关联草稿 18 项回归。
- [x] [CONCEPT: 测试与验收] 完成一次性 PostgreSQL fresh、回滚重升和旧结构迁移探针。
- [x] [CONCEPT: 测试与验收] 相关 Python 文件通过容器 RuffGit diff 无空白错误。
## 5. 上线
- [x] [CONCEPT: 本轮实现记录] 完成概念文档、TODO 和安全修复日志。
- [ ] [CONCEPT: 降级与回滚] 上线前在生产备份副本执行 `0027 → 0028 → 0027 → 0028`,确认历史默认企业归属。
- [ ] [CONCEPT: 财务分析报告] 为每个启用企业确认 SMTP、报告收件人和实际投递审计不使用全局兜底地址。

View File

@@ -0,0 +1,162 @@
# 知识库多租户隔离与安全编辑 概念文档
更新时间2026-07-17
## 功能一句话
让每个企业只读写自己的知识文件、元数据和 LightRAG/Qdrant 命名空间,同时以平台制度只读层和一次性 ONLYOFFICE 会话安全地贯通知识查询、预览与编辑。
## 背景与问题
原知识库把所有企业的文件、`.index.json``.lightrag`、运行时缓存和 Qdrant workspace 放在同一全局空间。API、后台索引和定时任务还能在没有可信 `tenant_id` 时继续工作,导致同名文件覆盖、跨租户列表/详情/原文读取、索引串读和错误的默认租户归属风险。
ONLYOFFICE 原回调仅依赖可伪造或可重放的短 payload并直接下载回调 URL。匿名请求可以借此访问内部地址、跟随重定向、下载超大或错误格式内容最终覆盖知识文件。预览会话与编辑会话也没有不可变的租户、资源、文档 key、版本和一次性状态绑定。
## 目标与非目标
### 目标
- 文件、索引 JSON、LightRAG 本地状态、运行时实例和 Qdrant workspace 全部按可信租户隔离。
- 旧全局制度资料无损复制到显式 `platform` 空间,并始终以只读方式提供给租户。
- 列表、详情、原文、上传、删除、同步和查询均从认证用户或数据库 Agent Run 获取租户,不接受客户端上下文覆盖。
- ONLYOFFICE token 同时绑定 tenant、resource scope、document、key、version、editable、audience、expiry 和 JTI。
- 编辑回调以数据库状态机实现一次性消费;预览会话永不回写。
- 回调下载仅访问配置的文档服务,拒绝重定向、私网/回环/链路本地解析、DNS 重绑定、超限和非 OOXML 内容。
- 定时索引只枚举租户注册表中的 `active` 租户,不再使用默认租户。
### 非目标
- 不允许租户经普通 API 新建或修改平台制度;平台资料由受控部署流程维护。
- 不把租户知识数据合并成一个共享向量集合后再依赖过滤器补救。
- 不为私网 ONLYOFFICE 地址提供安全降级开关;不满足公网解析要求时回写必须 fail-closed。
- 不在本切片中实现知识内容质量评估、模型微调或新的知识运营前端。
## 用户与场景
- 租户管理员:上传、覆盖、删除和触发当前企业的知识同步。
- 普通员工:联合查询本企业知识与平台只读制度,预览有权限的原文。
- 知识运营人员:在受控编辑模式中修改租户 Office 文档,保存后形成新版本。
- Hermes 调度器:逐个处理活跃租户的增量知识,不创建或猜测默认租户。
- 平台管理员:通过部署资产提供跨租户可见但不可写的平台制度。
## 功能能力
- 租户目录:`storage/knowledge/tenants/{tenant_id}/`
- 平台目录:`storage/knowledge/platform/`,所有业务入口只读。
- 每个作用域独立 `.index.json``.lightrag`、workspace 和运行时缓存键。
- 租户查询采用“租户空间 + 平台只读空间”隔离检索后融合,不在存储层混库。
-`storage/knowledge/{固定目录}` 与旧索引首次启动时复制到平台空间,源文件不删除。
- ONLYOFFICE content token 默认 5 分钟callback session 默认 4 小时、最长 12 小时,并在首次写回前原子进入 `processing`
- 回写成功进入 `consumed`,失败进入 `failed`;已消费、失败、撤销或过期会话不能再次写入。
- 后台索引线程从数据库 Agent Run 的 route/ontology 双份租户上下文重新校验,线程参数只能作为一致性断言。
## 方案设计
### 前端契约
- 知识文档新增 `scope``readOnly` 字段。
- 平台文档可以查看、下载和预览,但编辑入口必须隐藏或禁用。
- ONLYOFFICE 配置默认 `mode=view`;只有租户管理员显式请求 `editable=true` 时才返回编辑配置。
- 回调和 content URL 中的 token 是文档服务专用凭证,不暴露为普通用户授权能力。
### 后端职责
- `knowledge_tenant_scope`校验租户标识生成文件路径、workspace 和缓存键,执行旧资料到平台空间的无损迁移。
- `knowledge`:编排租户/平台文档、权限、文件操作、查询融合和 ONLYOFFICE 配置。
- `knowledge_index_state`:管理当前作用域的 JSON 元数据与 ingest 状态。
- `knowledge_rag`:只使用当前作用域的 working dir、workspace、缓存实例与 Qdrant 命名空间。
- `knowledge_run_scope`:从持久化 Agent Run 的 route/ontology 双份上下文还原并校验后台任务租户。
- `knowledge_onlyoffice_security`:签发/验证会话、原子消费 JTI、执行安全网络下载与 OOXML 校验。
- `knowledge_onlyoffice_callback`:按 token 中已验证的资源作用域定位文件,校验基线后回写新版本。
- `knowledge_scheduler`:从 `tenants.status=active` 枚举租户并创建显式内部身份。
### 数据
新增 migration-owned 表 `knowledge_onlyoffice_sessions`
- 身份:`jti``tenant_id``resource_scope``document_id`
- 不可变绑定:`document_key``document_version``audience``editable``created_by``expires_at`
- 生命周期:`active → processing → consumed | failed`,另支持 `revoked`
- 平台会话仍绑定发起租户,但数据库约束要求 `editable=false`
- `tenant_id` 外键指向租户注册表;租户删除时清理其会话。
- 存在会话证据时 migration downgrade 拒绝有损删除。
### 权限与信任边界
- HTTP 文档 API 只使用 `CurrentUserContext.tenant_id`
- 同步任务只使用认证用户租户;后台线程再与数据库 Agent Run 租户交叉校验。
- 平台 scope 必须由服务端显式构造,客户端不能通过参数选择。
- 租户文档 ID 在其他租户下按不存在处理;平台文档仅作为只读 fallback。
- ONLYOFFICE token 必须同时通过签名、issuer、audience、scope、时间、数据库行和全部资源字段比较。
- callback token 只有写入状态 `2/6` 才尝试 claimkey 不匹配时不会消耗会话。
- 下载 URL 的 origin 必须等于配置白名单DNS 解析的全部地址必须为公网地址,实际连接固定到已校验 IPHTTPS 仍校验原主机证书和 SNI。
- 不跟随 3xx限制响应大小、MIME、ZIP 条目数、解压体积、路径穿越、加密条目和 OOXML 目录结构。
### 查询算法
租户查询先在两个物理隔离空间各自检索,再对候选做确定性融合:
```text
tenant_workspace = base + "__tenant_" + SHA256(tenant_id)[0:20]
platform_workspace = base + "__platform"
merged_hits = top_k(
deduplicate(tenant_hits platform_hits, by=code),
order_by=score DESC
)
```
运行时缓存同样使用租户哈希键,不把原始 tenant ID 放进 Qdrant workspace 名称。
### ONLYOFFICE 状态机
```text
issue(view) → active ── callback status 2/6 ──拒绝写入
issue(edit) → active ── atomic claim ──→ processing
├─验证/下载/写入成功→ consumed
└─任一步失败────────→ failed
active -- exp 超时 --> 验证拒绝
consumed/failed/revoked -- replay --> 409/拒绝
```
### 降级策略
- 租户缺失、格式非法或与 Agent Run 冲突:拒绝任务,不回退默认租户。
- 平台迁移源不存在:建立空平台作用域,不影响租户空间。
- 平台或租户 RAG 不可用:保留已有本地检索降级;不会改查其他租户 workspace。
- ONLYOFFICE 地址、JWT、DNS、MIME 或 OOXML 校验失败:返回错误并保持原文件不变。
- 当前容器若通过网络代理把文档域名解析为非公网保留地址,真实回写会按安全策略拒绝;部署需提供满足公网解析与 TLS 的文档服务或安全反向代理。
## 测试方案
- 服务:两租户同名文件、列表/详情/原文隔离、平台只读 fallback、无 scope fail-closed。
- RAGworkspace、缓存键、本地 chunks 和运行签名隔离。
- 后台:只枚举 active tenantworker 从 Agent Run 重取租户并拒绝 route/ontology 冲突。
- ONLYOFFICEtenant/resource/key/version/aud/exp/JTI 绑定,平台只读,会话过期,错 key 不 claim成功只写一次重放拒绝。
- SSRF错误 origin、私网 DNS、重定向、IP pinning、超限、错误 MIME、损坏 OOXML。
- 迁移revision/down_revision、表/外键/check、非 PostgreSQL 拒绝、ownership/preflight 和有证据 downgrade 拒绝。
- 所有后端验证只在 `local-x-financial-linux` 容器内执行,单命令超时不超过 60 秒。
## 指标与验收
- 100% 租户知识文件、索引、LightRAG 与 Qdrant workspace 可由可信 tenant 唯一确定。
- 跨租户文档读取、删除、同步和查询用例 100% 拒绝或不可见。
- 平台文档 `readOnly=true`,任何编辑回调均不产生文件副作用。
- 同一 callback JTI 最多一次进入 `processing`,重复回调 100% 拒绝。
- 非白名单 origin、非公网解析、重定向、超限或非 OOXML 回写 100% fail-closed。
- 定时任务只为注册表 `active` 租户建任务;无活跃租户时不生成默认数据。
- 新增/修改 Python 文件通过 Ruff 与 compileall定向知识安全、既有知识回归和迁移 ownership 测试全部通过。
## 风险与开放问题
- 当前 JSON 索引采用原子文件级写入之外的旧读改写模型;同一租户多进程高并发上传仍应在后续演进为数据库元数据或跨进程锁。
- 平台制度的发布、签名和回滚需要独立的受控平台资产流程,本切片只保证业务 API 只读。
- 部署必须确认 ONLYOFFICE 下载域名从应用容器解析为真实公网地址;不得为开发便利开放私网通配。
- 长时编辑使用 4 小时 callback session超过时限需重新打开文档生成新会话。
## 本轮实现记录
- 2026-07-17完成租户/平台文件与 RAG 命名空间拆分、旧资料无损平台迁移、API 与后台任务可信租户接线。
- 2026-07-17完成 DB-backed ONLYOFFICE 一次性会话、平台只读预览、文档 key/version 基线校验和 SSRF/大小/MIME/OOXML 防护。
- 2026-07-17完成 `20260717_0027` 迁移、模型 ownership/preflight 注册、定时器 active tenant 枚举及容器定向回归;共享 PostgreSQL 探针完成 `base → 0028``0028 → 0025 → 0028`Knowledge/AgentAsset 两类会话表均正常落库。

View File

@@ -0,0 +1,63 @@
# 知识库多租户隔离与安全编辑 开发 TODO
更新时间2026-07-17
关联方案:[CONCEPT.md](./CONCEPT.md)
## 使用规则
- 任务边界、信任模型和上线约束以 CONCEPT 对应章节为准。
- `[x]` 只表示已有代码或容器验证证据;运维环境尚未验证的项目保持 `[ ]`
- 所有后端测试必须在 `local-x-financial-linux` 容器内运行,单命令最长 60 秒。
## 1. 调研与边界
- [x] [CONCEPT: 背景与问题] 盘点旧文件、index、LightRAG、运行时缓存和 Qdrant 的全局共享路径。证据:`knowledge.py``knowledge_rag.py` 调用点扫描与两租户隔离测试。
- [x] [CONCEPT: 目标与非目标] 冻结“租户可写 + 平台只读”的双层资源模型,不提供客户端 scope 选择或私网下载降级。证据:`KnowledgeStorageScope` 与 platform 只读反向测试。
- [x] [CONCEPT: 用户与场景] 明确 API 用户、平台资料、Hermes 调度器和 ONLYOFFICE 文档服务四类主体。证据认证端点、scheduler 和 session service 的显式调用契约。
## 2. 契约与设计
- [x] [CONCEPT: 功能能力] 定义 `tenants/{tenant}/``platform/`、workspace 和 runtime cache key。证据`knowledge_tenant_scope.py`
- [x] [CONCEPT: 方案设计] 为文档 DTO 增加 `scope/readOnly`,平台文档只能只读 fallback。证据`schemas/knowledge.py` 与平台文档测试。
- [x] [CONCEPT: ONLYOFFICE 状态机] 定义 `active → processing → consumed|failed` 和过期/重放拒绝。证据:`KnowledgeOnlyOfficeSession` 模型、`20260717_0027` 迁移与 replay 测试。
## 3. 后端实现
- [x] [CONCEPT: 后端职责] 建立 tenant/platform 文件、index 与 LightRAG 存储作用域。证据:`knowledge_tenant_scope.py``knowledge_index_state.py``knowledge_rag.py`
- [x] [CONCEPT: 后端职责] 将列表、详情、原文、上传、删除、同步和查询接入可信租户。证据:`knowledge.py``endpoints/knowledge.py``knowledge_sync.py`
- [x] [CONCEPT: 权限与信任边界] 让 Orchestrator 从数据库 Agent Run 取 tenant并让索引 worker 比对 route/ontology tenant。证据`orchestrator_execution.py``knowledge_run_scope.py``knowledge_index_tasks.py`、Agent Run 冲突测试。
- [x] [CONCEPT: 后端职责] 定时器只枚举 `Tenant.status=active`,没有活跃租户时跳过。证据:`knowledge_scheduler.py` 与 active/suspended 调度测试。
- [x] [CONCEPT: 后端职责] 迁移旧全局制度到 platform 空间且不删除源文件。证据:`migrate_legacy_library_to_platform()` 与 legacy migration 测试。
- [x] [CONCEPT: 权限与信任边界] 新增带数据库状态的 ONLYOFFICE token、平台只读预览和一次性 callback。证据`knowledge_onlyoffice_security.py``knowledge_onlyoffice_callback.py`
- [x] [CONCEPT: 权限与信任边界] 加固下载 origin、DNS/IP pinning、重定向、大小、MIME、ZIP 和 OOXML 校验。证据:`download_onlyoffice_document()` 与 SSRF/格式测试。
- [x] [CONCEPT: 数据] 新增 `20260717_0027`注册模型、schema ownership 和 migration preflight。证据迁移文件、`db/base.py``models/__init__.py``migration_preflight.py`
- [x] [CONCEPT: 后端职责] 删除知识文件工具中已废弃的无状态弱 token 实现。证据:`knowledge_file_utils.py` 差异与全仓引用扫描。
## 4. 算法/规则实现
- [x] [CONCEPT: 查询算法] 以租户哈希生成不可冲突的 workspace/cache key再隔离检索 tenant 与 platform 候选。证据workspace/cache/local chunks 两租户测试。
- [x] [CONCEPT: 查询算法] 对两个隔离结果按 code 去重、score 排序并截取 top-k。证据`_merge_scoped_search_results()`
- [x] [CONCEPT: ONLYOFFICE 状态机] content token 默认 5 分钟callback session 默认 4 小时且最长 12 小时。证据JWT exp 差值断言与会话测试。
## 5. 前端实现
- [x] [CONCEPT: 前端契约] 后端 DTO 已提供 `scope/readOnly`现有列表可安全识别平台只读资源。证据OpenAPI 回归与 `KnowledgeDocumentRead`
- [x] [CONCEPT: 前端契约] ONLYOFFICE 默认返回 view 模式,只有管理员显式 `editable=true` 才获得 edit 权限。证据config 单元测试与平台编辑拒绝测试。
- [x] [CONCEPT: 目标与非目标] 本切片不新增知识运营页面或视觉改版。证据CONCEPT 非目标;本轮无知识前端文件变更。
## 6. 测试与验证
- [x] [CONCEPT: 测试方案] 完成租户文件、平台只读、RAG namespace、scheduler 与 worker trust 测试。证据:容器内 `test_knowledge_tenant_security.py` 6 项通过。
- [x] [CONCEPT: 测试方案] 完成 token 绑定、平台只读、错 key、过期、单次回写、重放和 SSRF/OOXML 测试。证据:容器内 `test_knowledge_onlyoffice_tenant_security.py` 8 项通过。
- [x] [CONCEPT: 测试方案] 完成既有知识服务、RAG、同步、配置、解析、runtime 与 OpenAPI 回归。证据:容器内 8 个测试文件 31 项通过。
- [x] [CONCEPT: 测试方案] 完成 migration、preflight 与 ownership 静态回归。证据:容器内 163 项通过、1 项因无 PostgreSQL 测试 DSN 跳过。
- [x] [CONCEPT: 测试方案] 完成相关 Agent Run 与鉴权回归。证据:容器内 20 项通过。
- [x] [CONCEPT: 指标与验收] 完成目标 Python 文件 Ruff 和 compileall。证据容器命令均退出码 0。
- [x] [CONCEPT: 测试方案] 在一次性 PostgreSQL 测试数据库执行迁移链。证据:共享迁移探针完成 `base → 0028``0028 → 0025 → 0028``knowledge_onlyoffice_sessions``agent_asset_onlyoffice_sessions` 均正常落库;有事实 downgrade 仍由各迁移显式拒绝。
## 7. 文档收尾
- [x] [CONCEPT: 本轮实现记录] 更新 CONCEPT、分阶段 TODO 和三份 bug 修复日志。证据:本目录两份文档及 `dev-logs/bugs/knowledge-*.md`
- [x] [CONCEPT: 风险与开放问题] 记录 JSON index 多进程竞争、平台发布流程和长时编辑边界。证据CONCEPT 风险章节。
- [ ] [CONCEPT: 降级策略] 上线前确认 ONLYOFFICE 下载域名从应用容器解析为公网地址并使用可信 TLS。证据要求生产 `ONLYOFFICE_DOWNLOAD_ALLOWED_ORIGINS` 配置与容器 DNS/TLS 验证;当前开发网络解析到 `198.18.0.0/15` 时会按设计拒绝真实回写。

View File

@@ -0,0 +1,800 @@
"""add tenant-safe savings value ledger and append-only audit events
Revision ID: 20260716_0015
Revises: 20260716_0014
Create Date: 2026-07-16 20:10:00
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260716_0015"
down_revision: str | None = "20260716_0014"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _require_postgresql() -> None:
dialect_name = op.get_bind().dialect.name
if dialect_name != "postgresql":
raise RuntimeError(
"20260716_0015 only supports PostgreSQL; "
f"refusing to mutate {dialect_name} without transactional constraint DDL"
)
def _require_empty_ledger_for_downgrade() -> None:
bind = op.get_bind()
counts = {
table_name: int(
bind.scalar(sa.text(f"SELECT COUNT(*) FROM {table_name}")) or 0
)
for table_name in (
"profile_baseline_snapshots",
"savings_opportunities",
"savings_realizations",
"savings_evidence_links",
"savings_events",
)
}
if any(counts.values()):
summary = ", ".join(f"{name}={count}" for name, count in counts.items())
raise RuntimeError(
"cannot downgrade savings value ledger: immutable value facts exist "
f"({summary})"
)
def _json_object_default() -> sa.TextClause:
return sa.text("'{}'::json")
def _json_array_default() -> sa.TextClause:
return sa.text("'[]'::json")
def upgrade() -> None:
_require_postgresql()
op.create_table(
"profile_baseline_snapshots",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("baseline_key", sa.String(length=160), nullable=False),
sa.Column("baseline_type", sa.String(length=32), nullable=False),
sa.Column("dimension_type", sa.String(length=50), nullable=False),
sa.Column("dimension_id", sa.String(length=160), nullable=False),
sa.Column("metric_key", sa.String(length=100), nullable=False),
sa.Column("unit", sa.String(length=30), nullable=False),
sa.Column("original_currency", sa.String(length=3), nullable=True),
sa.Column("baseline_value", sa.Numeric(20, 4), nullable=False),
sa.Column("window_start", sa.DateTime(timezone=True), nullable=True),
sa.Column("window_end", sa.DateTime(timezone=True), nullable=True),
sa.Column("sample_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("method", sa.String(length=80), nullable=False),
sa.Column("query_fingerprint", sa.String(length=80), nullable=False),
sa.Column("data_quality_status", sa.String(length=20), nullable=False),
sa.Column("data_quality_score", sa.Numeric(5, 4), nullable=False),
sa.Column(
"quality_issues_json",
sa.JSON(),
nullable=False,
server_default=_json_array_default(),
),
sa.Column("algorithm_version", sa.String(length=80), nullable=False),
sa.Column("policy_version", sa.String(length=120), nullable=True),
sa.Column("policy_effective_from", sa.Date(), nullable=True),
sa.Column("policy_effective_to", sa.Date(), nullable=True),
sa.Column("target_resource_type", sa.String(length=50), nullable=True),
sa.Column("target_resource_id", sa.String(length=160), nullable=True),
sa.Column("frozen_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("frozen_by", sa.String(length=120), nullable=False),
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.CheckConstraint(
"baseline_type IN ('historical_cohort', 'policy_counterfactual', 'manual')",
name="ck_profile_baseline_snapshots_type",
),
sa.CheckConstraint(
"data_quality_status IN ('complete', 'partial', 'insufficient', 'invalid')",
name="ck_profile_baseline_snapshots_quality_status",
),
sa.CheckConstraint(
"baseline_value >= 0 AND sample_count >= 0",
name="ck_profile_baseline_snapshots_values",
),
sa.CheckConstraint(
"data_quality_score >= 0 AND data_quality_score <= 1",
name="ck_profile_baseline_snapshots_quality_score",
),
sa.CheckConstraint(
"window_end IS NULL OR window_start IS NOT NULL",
name="ck_profile_baseline_snapshots_window_pair",
),
sa.CheckConstraint(
"window_start IS NULL OR window_end IS NULL OR window_end >= window_start",
name="ck_profile_baseline_snapshots_window_order",
),
sa.CheckConstraint(
"baseline_type != 'historical_cohort' OR "
"(window_start IS NOT NULL AND window_end IS NOT NULL AND sample_count > 0)",
name="ck_profile_baseline_snapshots_historical_shape",
),
sa.CheckConstraint(
"baseline_type != 'policy_counterfactual' OR "
"(policy_version IS NOT NULL AND length(trim(policy_version)) > 0 "
"AND policy_effective_from IS NOT NULL AND target_resource_type IS NOT NULL "
"AND target_resource_id IS NOT NULL)",
name="ck_profile_baseline_snapshots_policy_shape",
),
sa.CheckConstraint(
"policy_effective_to IS NULL OR policy_effective_from IS NOT NULL",
name="ck_profile_baseline_snapshots_policy_pair",
),
sa.CheckConstraint(
"policy_effective_from IS NULL OR policy_effective_to IS NULL "
"OR policy_effective_to >= policy_effective_from",
name="ck_profile_baseline_snapshots_policy_order",
),
sa.CheckConstraint(
"valid_until IS NULL OR valid_until >= frozen_at",
name="ck_profile_baseline_snapshots_validity",
),
sa.CheckConstraint(
"length(trim(baseline_key)) > 0 AND length(trim(query_fingerprint)) > 0",
name="ck_profile_baseline_snapshots_keys",
),
sa.CheckConstraint("version >= 1", name="ck_profile_baseline_snapshots_version"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id", "id", name="uq_profile_baseline_snapshots_tenant_id"
),
sa.UniqueConstraint(
"tenant_id",
"baseline_key",
name="uq_profile_baseline_snapshots_tenant_key",
),
)
op.create_index(
"ix_profile_baseline_snapshots_lookup",
"profile_baseline_snapshots",
[
"tenant_id",
"baseline_type",
"dimension_type",
"dimension_id",
"metric_key",
"frozen_at",
],
)
op.create_index(
"ix_profile_baseline_snapshots_quality",
"profile_baseline_snapshots",
["tenant_id", "data_quality_status", "frozen_at"],
)
op.create_table(
"savings_opportunities",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("opportunity_key", sa.String(length=180), nullable=False),
sa.Column("benefit_key", sa.String(length=180), nullable=False),
sa.Column("expense_case_id", sa.String(length=36), nullable=False),
# expense_claims/items 仍由 legacy bootstrap 管理,仅保存已校验软引用。
sa.Column("claim_id", sa.String(length=36), nullable=False),
sa.Column("claim_no_snapshot", sa.String(length=80), nullable=False),
sa.Column("claim_item_id", sa.String(length=36), nullable=True),
sa.Column("discovery_business_event_id", sa.String(length=36), nullable=True),
sa.Column("source_type", sa.String(length=50), nullable=False),
sa.Column("source_id", sa.String(length=160), nullable=False),
sa.Column("category", sa.String(length=60), nullable=False),
sa.Column("value_kind", sa.String(length=20), nullable=False),
sa.Column("title", sa.String(length=200), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("exposure_amount", sa.Numeric(20, 4), nullable=False),
sa.Column("baseline_snapshot_id", sa.String(length=36), nullable=False),
sa.Column("baseline_amount", sa.Numeric(20, 4), nullable=False),
sa.Column("target_amount", sa.Numeric(20, 4), nullable=False),
sa.Column("estimated_gross", sa.Numeric(20, 4), nullable=False),
sa.Column("estimated_cost", sa.Numeric(20, 4), nullable=False),
sa.Column("estimated_net", sa.Numeric(20, 4), nullable=False),
sa.Column("estimated_low", sa.Numeric(20, 4), nullable=False),
sa.Column("estimated_high", sa.Numeric(20, 4), nullable=False),
sa.Column("confidence", sa.Numeric(5, 4), nullable=False),
sa.Column("currency", sa.String(length=3), nullable=False),
sa.Column("reporting_currency", sa.String(length=3), nullable=False),
sa.Column("attribution_method", sa.String(length=60), nullable=False),
sa.Column("ai_decision_id", sa.String(length=36), nullable=True),
sa.Column("suggested_action", sa.Text(), nullable=False),
sa.Column("owner_id", sa.String(length=120), nullable=False),
sa.Column("owner_name", sa.String(length=120), nullable=False),
sa.Column("owner_role", sa.String(length=60), nullable=False),
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("status", sa.String(length=24), nullable=False, server_default="identified"),
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
sa.Column(
"dimension_json", sa.JSON(), nullable=False, server_default=_json_object_default()
),
sa.Column(
"baseline_snapshot_json",
sa.JSON(),
nullable=False,
server_default=_json_object_default(),
),
sa.Column(
"evidence_json", sa.JSON(), nullable=False, server_default=_json_array_default()
),
sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("realized_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.CheckConstraint(
"value_kind IN ('cash', 'labor')",
name="ck_savings_opportunities_value_kind",
),
sa.CheckConstraint(
"status IN ('identified', 'accepted', 'in_progress', 'realized', "
"'verified', 'reversed', 'rejected', 'expired')",
name="ck_savings_opportunities_status",
),
sa.CheckConstraint(
"exposure_amount >= 0 AND baseline_amount >= 0 AND target_amount >= 0 "
"AND estimated_gross >= 0 AND estimated_cost >= 0 AND estimated_net >= 0 "
"AND estimated_low >= 0 AND estimated_high >= 0",
name="ck_savings_opportunities_amounts",
),
sa.CheckConstraint(
"estimated_net = estimated_gross - estimated_cost",
name="ck_savings_opportunities_net_math",
),
sa.CheckConstraint(
"estimated_low <= estimated_net AND estimated_net <= estimated_high",
name="ck_savings_opportunities_interval",
),
sa.CheckConstraint(
"confidence >= 0 AND confidence <= 1",
name="ck_savings_opportunities_confidence",
),
sa.CheckConstraint("version >= 1", name="ck_savings_opportunities_version"),
sa.CheckConstraint(
"length(trim(benefit_key)) > 0 AND length(trim(opportunity_key)) > 0",
name="ck_savings_opportunities_keys",
),
sa.CheckConstraint(
"length(trim(currency)) = 3 AND length(trim(reporting_currency)) = 3",
name="ck_savings_opportunities_currencies",
),
sa.CheckConstraint(
"status NOT IN ('accepted', 'in_progress', 'realized', 'verified', 'reversed') "
"OR accepted_at IS NOT NULL",
name="ck_savings_opportunities_acceptance",
),
sa.CheckConstraint(
"status NOT IN ('in_progress', 'realized', 'verified', 'reversed') "
"OR started_at IS NOT NULL",
name="ck_savings_opportunities_started",
),
sa.CheckConstraint(
"status NOT IN ('realized', 'verified', 'reversed') OR realized_at IS NOT NULL",
name="ck_savings_opportunities_realized",
),
sa.CheckConstraint(
"status NOT IN ('verified', 'reversed') OR verified_at IS NOT NULL",
name="ck_savings_opportunities_verified",
),
sa.CheckConstraint(
"status NOT IN ('verified', 'reversed', 'rejected', 'expired') "
"OR closed_at IS NOT NULL",
name="ck_savings_opportunities_closed",
),
sa.ForeignKeyConstraint(
["tenant_id", "expense_case_id"],
["expense_cases.tenant_id", "expense_cases.id"],
name="fk_savings_opportunities_tenant_case",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "expense_case_id", "discovery_business_event_id"],
[
"business_events.tenant_id",
"business_events.expense_case_id",
"business_events.id",
],
name="fk_savings_opportunities_tenant_event",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "baseline_snapshot_id"],
["profile_baseline_snapshots.tenant_id", "profile_baseline_snapshots.id"],
name="fk_savings_opportunities_tenant_baseline",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "expense_case_id", "ai_decision_id"],
["ai_decisions.tenant_id", "ai_decisions.expense_case_id", "ai_decisions.id"],
name="fk_savings_opportunities_tenant_ai_decision",
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "id", name="uq_savings_opportunities_tenant_id"),
sa.UniqueConstraint(
"tenant_id", "opportunity_key", name="uq_savings_opportunities_tenant_key"
),
)
op.create_index(
"ix_savings_opportunities_tenant_status_due",
"savings_opportunities",
["tenant_id", "status", "due_at"],
)
op.create_index(
"ix_savings_opportunities_tenant_case",
"savings_opportunities",
["tenant_id", "expense_case_id", "created_at"],
)
op.create_index(
"ix_savings_opportunities_tenant_benefit",
"savings_opportunities",
["tenant_id", "benefit_key"],
)
op.create_index(
"ix_savings_opportunities_tenant_owner",
"savings_opportunities",
["tenant_id", "owner_id", "status"],
)
op.create_table(
"savings_realizations",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("realization_key", sa.String(length=180), nullable=False),
sa.Column("opportunity_id", sa.String(length=36), nullable=False),
sa.Column("expense_case_id", sa.String(length=36), nullable=False),
sa.Column("claim_id", sa.String(length=36), nullable=False),
sa.Column("claim_item_id", sa.String(length=36), nullable=True),
sa.Column("business_event_id", sa.String(length=36), nullable=True),
sa.Column("realization_type", sa.String(length=20), nullable=False),
sa.Column("reversal_of_realization_id", sa.String(length=36), nullable=True),
sa.Column("realized_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("recorded_by_id", sa.String(length=120), nullable=False),
sa.Column("recorded_by_name", sa.String(length=120), nullable=False),
sa.Column("actual_gross", sa.Numeric(20, 4), nullable=False),
sa.Column("incremental_cost", sa.Numeric(20, 4), nullable=False),
sa.Column("actual_net", sa.Numeric(20, 4), nullable=False),
sa.Column("original_currency", sa.String(length=3), nullable=False),
sa.Column("reporting_amount", sa.Numeric(20, 4), nullable=False),
sa.Column("reporting_currency", sa.String(length=3), nullable=False),
sa.Column("fx_rate", sa.Numeric(20, 8), nullable=False),
sa.Column("fx_source", sa.String(length=80), nullable=False),
sa.Column("fx_date", sa.Date(), nullable=False),
sa.Column("fx_version", sa.String(length=80), nullable=False),
sa.Column("attribution_method", sa.String(length=60), nullable=False),
sa.Column("attribution_ratio", sa.Numeric(7, 6), nullable=False),
sa.Column("benefit_key", sa.String(length=180), nullable=False),
sa.Column(
"dedupe_status",
sa.String(length=24),
nullable=False,
server_default="pending_review",
),
sa.Column("canonical_realization_id", sa.String(length=36), nullable=True),
sa.Column(
"status",
sa.String(length=24),
nullable=False,
server_default="pending_confirmation",
),
sa.Column("finance_confirmer_id", sa.String(length=120), nullable=True),
sa.Column("finance_confirmer_name", sa.String(length=120), nullable=True),
sa.Column("confirmed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("confirmation_note", sa.Text(), nullable=True),
sa.Column("rejected_by_id", sa.String(length=120), nullable=True),
sa.Column("rejected_by_name", sa.String(length=120), nullable=True),
sa.Column("rejected_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("rejection_reason", sa.Text(), nullable=True),
sa.Column("reversed_by_id", sa.String(length=120), nullable=True),
sa.Column("reversed_by_name", sa.String(length=120), nullable=True),
sa.Column("reversed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("reversal_reason", sa.Text(), nullable=True),
sa.Column(
"baseline_snapshot_json",
sa.JSON(),
nullable=False,
server_default=_json_object_default(),
),
sa.Column(
"final_snapshot_json",
sa.JSON(),
nullable=False,
server_default=_json_object_default(),
),
sa.Column(
"evidence_json", sa.JSON(), nullable=False, server_default=_json_array_default()
),
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.CheckConstraint(
"realization_type IN ('actual', 'reversal')",
name="ck_savings_realizations_type",
),
sa.CheckConstraint(
"dedupe_status IN ('pending_review', 'canonical', 'duplicate', 'excluded')",
name="ck_savings_realizations_dedupe_status",
),
sa.CheckConstraint(
"status IN ('pending_confirmation', 'finance_confirmed', 'rejected', 'reversed')",
name="ck_savings_realizations_status",
),
sa.CheckConstraint(
"attribution_ratio > 0 AND attribution_ratio <= 1",
name="ck_savings_realizations_attribution",
),
sa.CheckConstraint(
"incremental_cost >= 0 AND fx_rate > 0",
name="ck_savings_realizations_cost_fx",
),
sa.CheckConstraint(
"actual_net = actual_gross - incremental_cost",
name="ck_savings_realizations_net_math",
),
sa.CheckConstraint(
"(realization_type = 'actual' AND reversal_of_realization_id IS NULL "
"AND actual_gross >= 0 AND actual_net >= 0 AND reporting_amount >= 0) OR "
"(realization_type = 'reversal' AND reversal_of_realization_id IS NOT NULL "
"AND actual_gross <= 0 AND actual_net <= 0 AND reporting_amount <= 0)",
name="ck_savings_realizations_amount_direction",
),
sa.CheckConstraint(
"(dedupe_status = 'duplicate' AND canonical_realization_id IS NOT NULL "
"AND canonical_realization_id <> id) OR "
"(dedupe_status != 'duplicate' AND canonical_realization_id IS NULL)",
name="ck_savings_realizations_duplicate_target",
),
sa.CheckConstraint(
"status != 'finance_confirmed' OR "
"(finance_confirmer_id IS NOT NULL AND finance_confirmer_name IS NOT NULL "
"AND confirmed_at IS NOT NULL AND confirmation_note IS NOT NULL "
"AND (realization_type = 'reversal' OR finance_confirmer_id <> recorded_by_id) "
"AND dedupe_status = 'canonical')",
name="ck_savings_realizations_confirmation",
),
sa.CheckConstraint(
"status != 'rejected' OR (rejected_by_id IS NOT NULL "
"AND rejected_by_name IS NOT NULL AND rejected_at IS NOT NULL "
"AND rejection_reason IS NOT NULL)",
name="ck_savings_realizations_rejection",
),
sa.CheckConstraint(
"status != 'reversed' OR (reversed_by_id IS NOT NULL "
"AND reversed_by_name IS NOT NULL AND reversed_at IS NOT NULL "
"AND reversal_reason IS NOT NULL)",
name="ck_savings_realizations_reversal",
),
sa.CheckConstraint("version >= 1", name="ck_savings_realizations_version"),
sa.CheckConstraint(
"length(trim(realization_key)) > 0 AND length(trim(benefit_key)) > 0",
name="ck_savings_realizations_keys",
),
sa.CheckConstraint(
"length(trim(original_currency)) = 3 "
"AND length(trim(reporting_currency)) = 3",
name="ck_savings_realizations_currencies",
),
sa.ForeignKeyConstraint(
["tenant_id", "opportunity_id"],
["savings_opportunities.tenant_id", "savings_opportunities.id"],
name="fk_savings_realizations_tenant_opportunity",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "expense_case_id"],
["expense_cases.tenant_id", "expense_cases.id"],
name="fk_savings_realizations_tenant_case",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "expense_case_id", "business_event_id"],
[
"business_events.tenant_id",
"business_events.expense_case_id",
"business_events.id",
],
name="fk_savings_realizations_tenant_event",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
[
"tenant_id", "opportunity_id", "benefit_key", "reversal_of_realization_id"
],
[
"savings_realizations.tenant_id", "savings_realizations.opportunity_id",
"savings_realizations.benefit_key", "savings_realizations.id",
],
name="fk_savings_realizations_tenant_reversal",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "benefit_key", "canonical_realization_id"],
[
"savings_realizations.tenant_id", "savings_realizations.benefit_key",
"savings_realizations.id",
],
name="fk_savings_realizations_tenant_canonical",
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "id", name="uq_savings_realizations_tenant_id"),
sa.UniqueConstraint(
"tenant_id", "realization_key", name="uq_savings_realizations_tenant_key"
),
sa.UniqueConstraint(
"tenant_id", "opportunity_id", "benefit_key", "id",
name="uq_savings_realizations_tenant_opportunity_benefit_id",
),
sa.UniqueConstraint(
"tenant_id", "benefit_key", "id",
name="uq_savings_realizations_tenant_benefit_id",
),
)
op.create_index(
"uq_savings_realizations_actual_canonical_benefit",
"savings_realizations",
["tenant_id", "benefit_key"],
unique=True,
postgresql_where=sa.text(
"realization_type = 'actual' AND dedupe_status = 'canonical'"
),
)
op.create_index(
"ix_savings_realizations_tenant_status_time",
"savings_realizations",
["tenant_id", "status", "realized_at"],
)
op.create_index(
"ix_savings_realizations_tenant_opportunity",
"savings_realizations",
["tenant_id", "opportunity_id", "realized_at"],
)
op.create_index(
"ix_savings_realizations_tenant_benefit",
"savings_realizations",
["tenant_id", "benefit_key", "dedupe_status"],
)
op.create_table(
"savings_evidence_links",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("evidence_key", sa.String(length=180), nullable=False),
sa.Column("entity_type", sa.String(length=20), nullable=False),
sa.Column("entity_id", sa.String(length=36), nullable=False),
sa.Column("baseline_snapshot_id", sa.String(length=36), nullable=True),
sa.Column("opportunity_id", sa.String(length=36), nullable=True),
sa.Column("realization_id", sa.String(length=36), nullable=True),
sa.Column("evidence_role", sa.String(length=50), nullable=False),
sa.Column("resource_type", sa.String(length=50), nullable=False),
sa.Column("resource_id", sa.String(length=160), nullable=False),
sa.Column("source_system", sa.String(length=80), nullable=False),
sa.Column("external_event_id", sa.String(length=160), nullable=True),
sa.Column("content_hash", sa.String(length=80), nullable=False),
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("collected_at", sa.DateTime(timezone=True), nullable=False),
sa.Column(
"verification_status",
sa.String(length=20),
nullable=False,
server_default="unverified",
),
sa.Column("verified_by", sa.String(length=120), nullable=True),
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"metadata_json", sa.JSON(), nullable=False, server_default=_json_object_default()
),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.CheckConstraint(
"entity_type IN ('baseline', 'opportunity', 'realization')",
name="ck_savings_evidence_links_entity_type",
),
sa.CheckConstraint(
"(entity_type = 'baseline' AND baseline_snapshot_id = entity_id "
"AND opportunity_id IS NULL AND realization_id IS NULL) OR "
"(entity_type = 'opportunity' AND opportunity_id = entity_id "
"AND baseline_snapshot_id IS NULL AND realization_id IS NULL) OR "
"(entity_type = 'realization' AND realization_id = entity_id "
"AND baseline_snapshot_id IS NULL AND opportunity_id IS NULL)",
name="ck_savings_evidence_links_entity_shape",
),
sa.CheckConstraint(
"verification_status IN ('unverified', 'verified', 'rejected', 'unavailable')",
name="ck_savings_evidence_links_verification",
),
sa.CheckConstraint(
"verification_status != 'verified' OR "
"(verified_by IS NOT NULL AND verified_at IS NOT NULL)",
name="ck_savings_evidence_links_verifier",
),
sa.CheckConstraint(
"length(trim(evidence_key)) > 0 AND length(trim(content_hash)) > 0",
name="ck_savings_evidence_links_keys",
),
sa.ForeignKeyConstraint(
["tenant_id", "baseline_snapshot_id"],
["profile_baseline_snapshots.tenant_id", "profile_baseline_snapshots.id"],
name="fk_savings_evidence_links_tenant_baseline",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "opportunity_id"],
["savings_opportunities.tenant_id", "savings_opportunities.id"],
name="fk_savings_evidence_links_tenant_opportunity",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "realization_id"],
["savings_realizations.tenant_id", "savings_realizations.id"],
name="fk_savings_evidence_links_tenant_realization",
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "id", name="uq_savings_evidence_links_tenant_id"),
sa.UniqueConstraint(
"tenant_id", "evidence_key", name="uq_savings_evidence_links_tenant_key"
),
)
op.create_index(
"ix_savings_evidence_links_entity",
"savings_evidence_links",
["tenant_id", "entity_type", "entity_id", "collected_at"],
)
op.create_index(
"ix_savings_evidence_links_resource",
"savings_evidence_links",
["tenant_id", "resource_type", "resource_id"],
)
op.create_table(
"savings_events",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("aggregate_type", sa.String(length=20), nullable=False),
sa.Column("aggregate_id", sa.String(length=36), nullable=False),
sa.Column("baseline_snapshot_id", sa.String(length=36), nullable=True),
sa.Column("opportunity_id", sa.String(length=36), nullable=True),
sa.Column("realization_id", sa.String(length=36), nullable=True),
sa.Column("action", sa.String(length=60), nullable=False),
sa.Column("actor_id", sa.String(length=120), nullable=False),
sa.Column("actor_name", sa.String(length=120), nullable=False),
sa.Column("actor_type", sa.String(length=20), nullable=False),
sa.Column("request_id", sa.String(length=120), nullable=False),
sa.Column("expected_version", sa.Integer(), nullable=False),
sa.Column("result_version", sa.Integer(), nullable=False),
sa.Column("payload_fingerprint", sa.String(length=80), nullable=False),
sa.Column(
"payload_json", sa.JSON(), nullable=False, server_default=_json_object_default()
),
sa.Column("before_json", sa.JSON(), nullable=False, server_default=_json_object_default()),
sa.Column("after_json", sa.JSON(), nullable=False, server_default=_json_object_default()),
sa.Column(
"response_json", sa.JSON(), nullable=False, server_default=_json_object_default()
),
sa.Column("correlation_id", sa.String(length=64), nullable=True),
sa.Column("causation_id", sa.String(length=64), nullable=True),
sa.Column(
"occurred_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.CheckConstraint(
"aggregate_type IN ('baseline', 'opportunity', 'realization')",
name="ck_savings_events_aggregate_type",
),
sa.CheckConstraint(
"(aggregate_type = 'baseline' AND baseline_snapshot_id = aggregate_id "
"AND opportunity_id IS NULL AND realization_id IS NULL) OR "
"(aggregate_type = 'opportunity' AND opportunity_id = aggregate_id "
"AND baseline_snapshot_id IS NULL AND realization_id IS NULL) OR "
"(aggregate_type = 'realization' AND realization_id = aggregate_id "
"AND baseline_snapshot_id IS NULL AND opportunity_id IS NULL)",
name="ck_savings_events_aggregate_shape",
),
sa.CheckConstraint("length(trim(action)) > 0", name="ck_savings_events_action"),
sa.CheckConstraint(
"actor_type IN ('user', 'system', 'agent', 'service')",
name="ck_savings_events_actor_type",
),
sa.CheckConstraint(
"expected_version >= 0 AND result_version >= 1 "
"AND result_version >= expected_version",
name="ck_savings_events_version",
),
sa.CheckConstraint(
"length(trim(request_id)) > 0 AND length(trim(payload_fingerprint)) > 0",
name="ck_savings_events_request",
),
sa.ForeignKeyConstraint(
["tenant_id", "baseline_snapshot_id"],
["profile_baseline_snapshots.tenant_id", "profile_baseline_snapshots.id"],
name="fk_savings_events_tenant_baseline",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "opportunity_id"],
["savings_opportunities.tenant_id", "savings_opportunities.id"],
name="fk_savings_events_tenant_opportunity",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "realization_id"],
["savings_realizations.tenant_id", "savings_realizations.id"],
name="fk_savings_events_tenant_realization",
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "id", name="uq_savings_events_tenant_id"),
sa.UniqueConstraint(
"tenant_id", "actor_id", "request_id", name="uq_savings_events_actor_request"
),
sa.UniqueConstraint(
"tenant_id",
"aggregate_type",
"aggregate_id",
"result_version",
name="uq_savings_events_aggregate_version",
),
)
op.create_index(
"ix_savings_events_tenant_aggregate_time",
"savings_events",
["tenant_id", "aggregate_type", "aggregate_id", "occurred_at"],
)
op.create_index(
"ix_savings_events_tenant_correlation",
"savings_events",
["tenant_id", "correlation_id", "occurred_at"],
)
op.execute(
"""
CREATE OR REPLACE FUNCTION prevent_savings_events_mutation()
RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'savings_events are append-only';
END;
$$ LANGUAGE plpgsql
"""
)
op.execute(
"""
CREATE TRIGGER trg_savings_events_append_only
BEFORE UPDATE OR DELETE ON savings_events
FOR EACH ROW EXECUTE FUNCTION prevent_savings_events_mutation()
"""
)
def downgrade() -> None:
_require_postgresql()
_require_empty_ledger_for_downgrade()
op.execute("DROP TRIGGER IF EXISTS trg_savings_events_append_only ON savings_events")
op.execute("DROP FUNCTION IF EXISTS prevent_savings_events_mutation()")
op.drop_table("savings_events")
op.drop_table("savings_evidence_links")
op.drop_table("savings_realizations")
op.drop_table("savings_opportunities")
op.drop_table("profile_baseline_snapshots")

View File

@@ -0,0 +1,590 @@
"""add tenant commercial contracts, entitlements, usage and cost ledgers
Revision ID: 20260716_0016
Revises: 20260716_0015
Create Date: 2026-07-16 23:10:00
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260716_0016"
down_revision: str | None = "20260716_0015"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _require_postgresql() -> None:
dialect_name = op.get_bind().dialect.name
if dialect_name != "postgresql":
raise RuntimeError(
"20260716_0016 only supports PostgreSQL; "
f"refusing to mutate {dialect_name} without transactional constraint DDL"
)
def _require_empty_commercial_domain_for_downgrade() -> None:
bind = op.get_bind()
counts = {
table_name: int(bind.scalar(sa.text(f"SELECT COUNT(*) FROM {table_name}")) or 0)
for table_name in (
"tenant_commercial_plans",
"tenant_subscriptions",
"commercial_entitlements",
"usage_meter_events",
"commercial_cost_events",
)
}
if any(counts.values()):
summary = ", ".join(f"{name}={count}" for name, count in counts.items())
raise RuntimeError(
"cannot downgrade commercial metering: contracts or immutable facts exist "
f"({summary})"
)
def _json_object_default() -> sa.TextClause:
return sa.text("'{}'::json")
def upgrade() -> None:
_require_postgresql()
op.create_table(
"tenant_commercial_plans",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("plan_code", sa.String(length=80), nullable=False),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("pricing_model", sa.String(length=24), nullable=False),
sa.Column("billing_interval", sa.String(length=20), nullable=False),
sa.Column("currency", sa.String(length=3), nullable=False),
sa.Column("base_fee", sa.Numeric(20, 4), nullable=False),
sa.Column("included_seats", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"overage_enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")
),
sa.Column("status", sa.String(length=16), nullable=False, server_default="draft"),
sa.Column("effective_from", sa.DateTime(timezone=True), nullable=False),
sa.Column("effective_to", sa.DateTime(timezone=True), nullable=True),
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
sa.Column(
"contract_terms_json",
sa.JSON(),
nullable=False,
server_default=_json_object_default(),
),
sa.Column("created_by", sa.String(length=120), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.CheckConstraint(
"pricing_model IN ('subscription', 'usage', 'hybrid', 'pilot', 'custom')",
name="ck_tenant_commercial_plans_pricing_model",
),
sa.CheckConstraint(
"billing_interval IN ('monthly', 'quarterly', 'annual', 'contract')",
name="ck_tenant_commercial_plans_billing_interval",
),
sa.CheckConstraint(
"status IN ('draft', 'active', 'retired')",
name="ck_tenant_commercial_plans_status",
),
sa.CheckConstraint(
"base_fee >= 0 AND included_seats >= 0 AND version >= 1",
name="ck_tenant_commercial_plans_values",
),
sa.CheckConstraint(
"length(trim(plan_code)) > 0 AND length(trim(name)) > 0",
name="ck_tenant_commercial_plans_keys",
),
sa.CheckConstraint(
"length(trim(currency)) = 3", name="ck_tenant_commercial_plans_currency"
),
sa.CheckConstraint(
"effective_to IS NULL OR effective_to > effective_from",
name="ck_tenant_commercial_plans_effective_window",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id", "id", name="uq_tenant_commercial_plans_tenant_id"
),
sa.UniqueConstraint(
"tenant_id",
"plan_code",
"version",
name="uq_tenant_commercial_plans_tenant_code_version",
),
)
op.create_index(
"uq_tenant_commercial_plans_active_code",
"tenant_commercial_plans",
["tenant_id", "plan_code"],
unique=True,
postgresql_where=sa.text("status = 'active'"),
)
op.create_index(
"ix_tenant_commercial_plans_tenant_status",
"tenant_commercial_plans",
["tenant_id", "status", "effective_from"],
)
op.create_table(
"tenant_subscriptions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("subscription_key", sa.String(length=120), nullable=False),
sa.Column("plan_id", sa.String(length=36), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("starts_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ends_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("current_period_start", sa.DateTime(timezone=True), nullable=False),
sa.Column("current_period_end", sa.DateTime(timezone=True), nullable=False),
sa.Column("seats", sa.Integer(), nullable=False),
sa.Column("base_fee_snapshot", sa.Numeric(20, 4), nullable=False),
sa.Column("currency", sa.String(length=3), nullable=False),
sa.Column("billing_interval", sa.String(length=20), nullable=False),
sa.Column("auto_renew", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("external_provider", sa.String(length=60), nullable=True),
sa.Column("external_subscription_id", sa.String(length=160), nullable=True),
sa.Column("canceled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
sa.Column(
"metadata_json", sa.JSON(), nullable=False, server_default=_json_object_default()
),
sa.Column("created_by", sa.String(length=120), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.CheckConstraint(
"status IN ('trialing', 'active', 'past_due', 'suspended', 'canceled', 'expired')",
name="ck_tenant_subscriptions_status",
),
sa.CheckConstraint(
"billing_interval IN ('monthly', 'quarterly', 'annual', 'contract')",
name="ck_tenant_subscriptions_billing_interval",
),
sa.CheckConstraint(
"seats > 0 AND base_fee_snapshot >= 0 AND version >= 1",
name="ck_tenant_subscriptions_values",
),
sa.CheckConstraint(
"length(trim(subscription_key)) > 0 AND length(trim(currency)) = 3",
name="ck_tenant_subscriptions_keys",
),
sa.CheckConstraint(
"current_period_end > current_period_start",
name="ck_tenant_subscriptions_period",
),
sa.CheckConstraint(
"ends_at IS NULL OR ends_at > starts_at",
name="ck_tenant_subscriptions_contract_window",
),
sa.CheckConstraint(
"(external_provider IS NULL AND external_subscription_id IS NULL) OR "
"(external_provider IS NOT NULL AND external_subscription_id IS NOT NULL)",
name="ck_tenant_subscriptions_external_pair",
),
sa.CheckConstraint(
"status != 'canceled' OR canceled_at IS NOT NULL",
name="ck_tenant_subscriptions_cancellation",
),
sa.ForeignKeyConstraint(
["tenant_id", "plan_id"],
["tenant_commercial_plans.tenant_id", "tenant_commercial_plans.id"],
name="fk_tenant_subscriptions_tenant_plan",
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "id", name="uq_tenant_subscriptions_tenant_id"),
sa.UniqueConstraint(
"tenant_id", "subscription_key", name="uq_tenant_subscriptions_tenant_key"
),
sa.UniqueConstraint(
"tenant_id",
"external_provider",
"external_subscription_id",
name="uq_tenant_subscriptions_external_ref",
),
)
op.create_index(
"uq_tenant_subscriptions_current",
"tenant_subscriptions",
["tenant_id"],
unique=True,
postgresql_where=sa.text(
"status IN ('trialing', 'active', 'past_due', 'suspended')"
),
)
op.create_index(
"ix_tenant_subscriptions_tenant_status_period",
"tenant_subscriptions",
["tenant_id", "status", "current_period_end"],
)
op.create_table(
"commercial_entitlements",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("subscription_id", sa.String(length=36), nullable=False),
sa.Column("entitlement_key", sa.String(length=120), nullable=False),
sa.Column("metric_key", sa.String(length=120), nullable=False),
sa.Column("entitlement_type", sa.String(length=20), nullable=False),
sa.Column("unit", sa.String(length=40), nullable=False),
sa.Column("included_quantity", sa.Numeric(20, 6), nullable=True),
sa.Column("hard_limit_quantity", sa.Numeric(20, 6), nullable=True),
sa.Column("reset_interval", sa.String(length=20), nullable=False),
sa.Column("overage_policy", sa.String(length=16), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("effective_from", sa.DateTime(timezone=True), nullable=False),
sa.Column("effective_to", sa.DateTime(timezone=True), nullable=True),
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
sa.Column("config_json", sa.JSON(), nullable=False, server_default=_json_object_default()),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.CheckConstraint(
"entitlement_type IN ('feature', 'metered', 'unlimited')",
name="ck_commercial_entitlements_type",
),
sa.CheckConstraint(
"reset_interval IN ('none', 'monthly', 'quarterly', 'annual', 'contract')",
name="ck_commercial_entitlements_reset_interval",
),
sa.CheckConstraint(
"overage_policy IN ('block', 'allow', 'alert')",
name="ck_commercial_entitlements_overage_policy",
),
sa.CheckConstraint(
"status IN ('active', 'suspended', 'expired')",
name="ck_commercial_entitlements_status",
),
sa.CheckConstraint(
"version >= 1 AND (included_quantity IS NULL OR included_quantity >= 0) "
"AND (hard_limit_quantity IS NULL OR hard_limit_quantity >= 0)",
name="ck_commercial_entitlements_values",
),
sa.CheckConstraint(
"(entitlement_type = 'unlimited' AND included_quantity IS NULL "
"AND hard_limit_quantity IS NULL) OR "
"(entitlement_type = 'feature' AND included_quantity IN (0, 1) "
"AND (hard_limit_quantity IS NULL OR hard_limit_quantity IN (0, 1))) OR "
"(entitlement_type = 'metered' AND included_quantity IS NOT NULL "
"AND (hard_limit_quantity IS NULL OR hard_limit_quantity >= included_quantity))",
name="ck_commercial_entitlements_quota_shape",
),
sa.CheckConstraint(
"length(trim(entitlement_key)) > 0 AND length(trim(metric_key)) > 0 "
"AND length(trim(unit)) > 0",
name="ck_commercial_entitlements_keys",
),
sa.CheckConstraint(
"effective_to IS NULL OR effective_to > effective_from",
name="ck_commercial_entitlements_effective_window",
),
sa.ForeignKeyConstraint(
["tenant_id", "subscription_id"],
["tenant_subscriptions.tenant_id", "tenant_subscriptions.id"],
name="fk_commercial_entitlements_tenant_subscription",
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id", "id", name="uq_commercial_entitlements_tenant_id"
),
sa.UniqueConstraint(
"tenant_id",
"subscription_id",
"id",
name="uq_commercial_entitlements_tenant_subscription_id",
),
sa.UniqueConstraint(
"tenant_id",
"subscription_id",
"entitlement_key",
name="uq_commercial_entitlements_subscription_key",
),
)
op.create_index(
"ix_commercial_entitlements_subscription_status",
"commercial_entitlements",
["tenant_id", "subscription_id", "status"],
)
op.create_table(
"usage_meter_events",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("subscription_id", sa.String(length=36), nullable=False),
sa.Column("entitlement_id", sa.String(length=36), nullable=False),
sa.Column("event_type", sa.String(length=16), nullable=False),
sa.Column("metric_key", sa.String(length=120), nullable=False),
sa.Column("quantity", sa.Numeric(20, 6), nullable=False),
sa.Column("unit", sa.String(length=40), nullable=False),
sa.Column("period_key", sa.String(length=32), nullable=False),
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("source_system", sa.String(length=80), nullable=False),
sa.Column("idempotency_key", sa.String(length=160), nullable=False),
sa.Column("request_fingerprint", sa.String(length=80), nullable=False),
sa.Column("reversal_of_event_id", sa.String(length=36), nullable=True),
sa.Column("subject_type", sa.String(length=60), nullable=True),
sa.Column("subject_id", sa.String(length=160), nullable=True),
sa.Column("actor_type", sa.String(length=20), nullable=False),
sa.Column("actor_id", sa.String(length=120), nullable=False),
sa.Column("correlation_id", sa.String(length=120), nullable=True),
sa.Column("trace_id", sa.String(length=120), nullable=True),
sa.Column(
"metadata_json", sa.JSON(), nullable=False, server_default=_json_object_default()
),
sa.Column(
"recorded_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.CheckConstraint(
"event_type IN ('usage', 'credit', 'adjustment', 'reversal')",
name="ck_usage_meter_events_type",
),
sa.CheckConstraint(
"(event_type = 'usage' AND quantity > 0) OR "
"(event_type = 'credit' AND quantity < 0) OR "
"(event_type IN ('adjustment', 'reversal') AND quantity <> 0)",
name="ck_usage_meter_events_quantity",
),
sa.CheckConstraint(
"(event_type = 'reversal' AND reversal_of_event_id IS NOT NULL) OR "
"(event_type != 'reversal' AND reversal_of_event_id IS NULL)",
name="ck_usage_meter_events_reversal",
),
sa.CheckConstraint(
"(subject_type IS NULL AND subject_id IS NULL) OR "
"(subject_type IS NOT NULL AND subject_id IS NOT NULL)",
name="ck_usage_meter_events_subject_pair",
),
sa.CheckConstraint(
"actor_type IN ('system', 'user', 'integration', 'admin')",
name="ck_usage_meter_events_actor_type",
),
sa.CheckConstraint(
"length(trim(metric_key)) > 0 AND length(trim(unit)) > 0 "
"AND length(trim(period_key)) > 0 AND length(trim(source_system)) > 0 "
"AND length(trim(idempotency_key)) > 0 "
"AND length(trim(request_fingerprint)) > 0",
name="ck_usage_meter_events_keys",
),
sa.ForeignKeyConstraint(
["tenant_id", "subscription_id"],
["tenant_subscriptions.tenant_id", "tenant_subscriptions.id"],
name="fk_usage_meter_events_tenant_subscription",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "subscription_id", "entitlement_id"],
[
"commercial_entitlements.tenant_id",
"commercial_entitlements.subscription_id",
"commercial_entitlements.id",
],
name="fk_usage_meter_events_tenant_entitlement",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "subscription_id", "entitlement_id", "reversal_of_event_id"],
[
"usage_meter_events.tenant_id",
"usage_meter_events.subscription_id",
"usage_meter_events.entitlement_id",
"usage_meter_events.id",
],
name="fk_usage_meter_events_tenant_reversal",
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "id", name="uq_usage_meter_events_tenant_id"),
sa.UniqueConstraint(
"tenant_id",
"subscription_id",
"id",
name="uq_usage_meter_events_tenant_subscription_id",
),
sa.UniqueConstraint(
"tenant_id",
"subscription_id",
"entitlement_id",
"id",
name="uq_usage_meter_events_entitlement_id",
),
sa.UniqueConstraint(
"tenant_id",
"source_system",
"idempotency_key",
name="uq_usage_meter_events_source_request",
),
)
op.create_index(
"ix_usage_meter_events_quota_window",
"usage_meter_events",
["tenant_id", "subscription_id", "metric_key", "period_key", "occurred_at"],
)
op.create_index(
"ix_usage_meter_events_correlation",
"usage_meter_events",
["tenant_id", "correlation_id", "occurred_at"],
)
op.create_table(
"commercial_cost_events",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("subscription_id", sa.String(length=36), nullable=True),
sa.Column("usage_event_id", sa.String(length=36), nullable=True),
sa.Column("event_type", sa.String(length=16), nullable=False),
sa.Column("cost_category", sa.String(length=32), nullable=False),
sa.Column("quantity", sa.Numeric(20, 6), nullable=False),
sa.Column("unit", sa.String(length=40), nullable=False),
sa.Column("unit_cost", sa.Numeric(20, 8), nullable=False),
sa.Column("cost_amount", sa.Numeric(20, 4), nullable=False),
sa.Column("original_currency", sa.String(length=3), nullable=False),
sa.Column("reporting_amount", sa.Numeric(20, 4), nullable=False),
sa.Column("reporting_currency", sa.String(length=3), nullable=False),
sa.Column("fx_rate", sa.Numeric(20, 8), nullable=False),
sa.Column("provider", sa.String(length=120), nullable=True),
sa.Column("sku", sa.String(length=120), nullable=True),
sa.Column("model_name", sa.String(length=120), nullable=True),
sa.Column("allocation_key", sa.String(length=160), nullable=False),
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("source_system", sa.String(length=80), nullable=False),
sa.Column("idempotency_key", sa.String(length=160), nullable=False),
sa.Column("request_fingerprint", sa.String(length=80), nullable=False),
sa.Column("reversal_of_cost_event_id", sa.String(length=36), nullable=True),
sa.Column("correlation_id", sa.String(length=120), nullable=True),
sa.Column("trace_id", sa.String(length=120), nullable=True),
sa.Column(
"metadata_json", sa.JSON(), nullable=False, server_default=_json_object_default()
),
sa.Column(
"recorded_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.CheckConstraint(
"event_type IN ('incurred', 'credit', 'adjustment', 'reversal')",
name="ck_commercial_cost_events_type",
),
sa.CheckConstraint(
"cost_category IN ('ai_inference', 'ocr', 'storage', 'connector', "
"'support', 'implementation', 'infrastructure', 'payment', 'other')",
name="ck_commercial_cost_events_category",
),
sa.CheckConstraint(
"quantity > 0 AND unit_cost >= 0 AND fx_rate > 0",
name="ck_commercial_cost_events_values",
),
sa.CheckConstraint(
"(event_type = 'incurred' AND cost_amount >= 0 AND reporting_amount >= 0) OR "
"(event_type = 'credit' AND cost_amount <= 0 AND reporting_amount <= 0) OR "
"(event_type IN ('adjustment', 'reversal') AND cost_amount <> 0 "
"AND reporting_amount <> 0)",
name="ck_commercial_cost_events_amount_direction",
),
sa.CheckConstraint(
"(event_type = 'reversal' AND reversal_of_cost_event_id IS NOT NULL) OR "
"(event_type != 'reversal' AND reversal_of_cost_event_id IS NULL)",
name="ck_commercial_cost_events_reversal",
),
sa.CheckConstraint(
"usage_event_id IS NULL OR subscription_id IS NOT NULL",
name="ck_commercial_cost_events_usage_pair",
),
sa.CheckConstraint(
"length(trim(unit)) > 0 AND length(trim(source_system)) > 0 "
"AND length(trim(idempotency_key)) > 0 "
"AND length(trim(request_fingerprint)) > 0 "
"AND length(trim(original_currency)) = 3 "
"AND length(trim(reporting_currency)) = 3",
name="ck_commercial_cost_events_keys",
),
sa.ForeignKeyConstraint(
["tenant_id", "subscription_id"],
["tenant_subscriptions.tenant_id", "tenant_subscriptions.id"],
name="fk_commercial_cost_events_tenant_subscription",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "subscription_id", "usage_event_id"],
[
"usage_meter_events.tenant_id",
"usage_meter_events.subscription_id",
"usage_meter_events.id",
],
name="fk_commercial_cost_events_tenant_usage",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "reversal_of_cost_event_id"],
["commercial_cost_events.tenant_id", "commercial_cost_events.id"],
name="fk_commercial_cost_events_tenant_reversal",
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id", "id", name="uq_commercial_cost_events_tenant_id"
),
sa.UniqueConstraint(
"tenant_id",
"source_system",
"idempotency_key",
name="uq_commercial_cost_events_source_request",
),
)
op.create_index(
"ix_commercial_cost_events_tenant_period",
"commercial_cost_events",
["tenant_id", "occurred_at", "cost_category"],
)
op.create_index(
"ix_commercial_cost_events_subscription_period",
"commercial_cost_events",
["tenant_id", "subscription_id", "occurred_at"],
)
op.create_index(
"ix_commercial_cost_events_allocation",
"commercial_cost_events",
["tenant_id", "allocation_key", "occurred_at"],
)
op.execute(
"""
CREATE OR REPLACE FUNCTION prevent_commercial_events_mutation()
RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION '% is append-only', TG_TABLE_NAME;
END;
$$ LANGUAGE plpgsql
"""
)
for table_name in ("usage_meter_events", "commercial_cost_events"):
op.execute(
f"""
CREATE TRIGGER trg_{table_name}_append_only
BEFORE UPDATE OR DELETE ON {table_name}
FOR EACH ROW EXECUTE FUNCTION prevent_commercial_events_mutation()
"""
)
def downgrade() -> None:
_require_postgresql()
_require_empty_commercial_domain_for_downgrade()
for table_name in ("commercial_cost_events", "usage_meter_events"):
op.execute(f"DROP TRIGGER IF EXISTS trg_{table_name}_append_only ON {table_name}")
op.execute("DROP FUNCTION IF EXISTS prevent_commercial_events_mutation()")
op.drop_table("commercial_cost_events")
op.drop_table("usage_meter_events")
op.drop_table("commercial_entitlements")
op.drop_table("tenant_subscriptions")
op.drop_table("tenant_commercial_plans")

View File

@@ -0,0 +1,375 @@
"""add tenant-safe financial connector and reconciliation ledger
Revision ID: 20260716_0017
Revises: 20260716_0016
Create Date: 2026-07-16 20:00:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260716_0017"
down_revision: str | None = "20260716_0016"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _require_postgresql() -> None:
dialect_name = op.get_bind().dialect.name
if dialect_name != "postgresql":
raise RuntimeError(
"20260716_0017 only supports PostgreSQL; "
f"refusing to mutate {dialect_name} without transactional constraint DDL"
)
def _require_empty_connector_domain_for_downgrade() -> None:
bind = op.get_bind()
counts = {
table_name: int(bind.scalar(sa.text(f"SELECT COUNT(*) FROM {table_name}")) or 0)
for table_name in (
"financial_connector_configs",
"financial_connector_events",
"payment_reconciliation_cases",
"payment_reconciliation_events",
)
}
if any(counts.values()):
summary = ", ".join(f"{name}={count}" for name, count in counts.items())
raise RuntimeError(
"cannot downgrade financial connector: configurations or immutable facts exist "
f"({summary})"
)
def upgrade() -> None:
_require_postgresql()
op.create_table(
"financial_connector_configs",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("provider", sa.String(length=80), nullable=False),
sa.Column("environment", sa.String(length=16), nullable=False),
sa.Column("key_version", sa.String(length=40), nullable=False),
sa.Column("secret_ref", sa.String(length=180), nullable=False),
sa.Column("allowed_event_types_json", sa.JSON(), nullable=False),
sa.Column("clock_skew_seconds", sa.Integer(), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("last_success_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error_code", sa.String(length=80), nullable=True),
sa.Column("created_by", sa.String(length=120), nullable=False),
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(
"environment IN ('test', 'mock', 'staging', 'production')",
name="ck_financial_connector_configs_environment",
),
sa.CheckConstraint(
"status IN ('active', 'disabled', 'rotating')",
name="ck_financial_connector_configs_status",
),
sa.CheckConstraint(
"clock_skew_seconds BETWEEN 30 AND 900",
name="ck_financial_connector_configs_clock_skew",
),
sa.CheckConstraint(
"length(trim(provider)) > 0 AND length(trim(key_version)) > 0 "
"AND length(trim(secret_ref)) > 0",
name="ck_financial_connector_configs_keys",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "id", name="uq_financial_connector_configs_tenant_id"),
sa.UniqueConstraint(
"tenant_id",
"provider",
"key_version",
name="uq_financial_connector_configs_tenant_provider_key",
),
)
op.create_index(
"ix_financial_connector_configs_tenant_status",
"financial_connector_configs",
["tenant_id", "status", "provider"],
)
op.create_table(
"financial_connector_events",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("config_id", sa.String(length=36), nullable=False),
sa.Column("provider", sa.String(length=80), nullable=False),
sa.Column("environment", sa.String(length=16), nullable=False),
sa.Column("direction", sa.String(length=12), nullable=False),
sa.Column("external_event_id", sa.String(length=160), nullable=False),
sa.Column("event_type", sa.String(length=40), nullable=False),
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
sa.Column(
"received_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column("key_version", sa.String(length=40), nullable=False),
sa.Column("verification_level", sa.String(length=32), nullable=False),
sa.Column("request_fingerprint", sa.String(length=80), nullable=False),
sa.Column("content_hash", sa.String(length=80), nullable=False),
sa.Column("processing_status", sa.String(length=20), nullable=False),
sa.Column("error_code", sa.String(length=80), nullable=True),
sa.Column("claim_id", sa.String(length=36), nullable=True),
sa.Column("expense_case_id", sa.String(length=36), nullable=True),
sa.Column("origin_event_id", sa.String(length=36), nullable=True),
sa.Column("correlation_id", sa.String(length=64), nullable=False),
sa.Column("external_reference_tail", sa.String(length=8), nullable=True),
sa.Column("normalized_payload_json", sa.JSON(), nullable=False),
sa.Column("response_json", sa.JSON(), nullable=False),
sa.CheckConstraint("direction = 'inbound'", name="ck_financial_connector_events_direction"),
sa.CheckConstraint(
"event_type IN ('payment_settled', 'payment_failed', 'erp_posted', "
"'erp_posting_failed', 'payment_refunded', 'payment_reversed')",
name="ck_financial_connector_events_type",
),
sa.CheckConstraint(
"environment IN ('test', 'mock', 'staging', 'production')",
name="ck_financial_connector_events_environment",
),
sa.CheckConstraint(
"verification_level IN ('simulated', 'staging_verified', 'production_verified')",
name="ck_financial_connector_events_verification",
),
sa.CheckConstraint(
"processing_status IN ('processed', 'exception', 'pending')",
name="ck_financial_connector_events_processing_status",
),
sa.CheckConstraint(
"length(trim(external_event_id)) > 0 "
"AND length(trim(request_fingerprint)) >= 16 "
"AND length(trim(content_hash)) >= 16",
name="ck_financial_connector_events_fingerprints",
),
sa.CheckConstraint(
"(event_type IN ('payment_refunded', 'payment_reversed', "
"'erp_posted', 'erp_posting_failed') "
"AND (origin_event_id IS NOT NULL OR processing_status = 'exception')) "
"OR (event_type IN ('payment_settled', 'payment_failed') "
"AND origin_event_id IS NULL)",
name="ck_financial_connector_events_origin",
),
sa.ForeignKeyConstraint(
["tenant_id", "config_id"],
["financial_connector_configs.tenant_id", "financial_connector_configs.id"],
name="fk_financial_connector_events_tenant_config",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "expense_case_id"],
["expense_cases.tenant_id", "expense_cases.id"],
name="fk_financial_connector_events_tenant_expense_case",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "origin_event_id"],
["financial_connector_events.tenant_id", "financial_connector_events.id"],
name="fk_financial_connector_events_tenant_origin",
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "id", name="uq_financial_connector_events_tenant_id"),
sa.UniqueConstraint(
"tenant_id",
"provider",
"external_event_id",
name="uq_financial_connector_events_external_id",
),
)
op.create_index(
"ix_financial_connector_events_tenant_received",
"financial_connector_events",
["tenant_id", "received_at"],
)
op.create_index(
"ix_financial_connector_events_tenant_claim",
"financial_connector_events",
["tenant_id", "claim_id", "occurred_at"],
)
op.create_table(
"payment_reconciliation_cases",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("provider", sa.String(length=80), nullable=False),
sa.Column("claim_id", sa.String(length=36), nullable=False),
sa.Column("expense_case_id", sa.String(length=36), nullable=True),
sa.Column("expected_amount", sa.Numeric(20, 4), nullable=False),
sa.Column("actual_amount", sa.Numeric(20, 4), nullable=False),
sa.Column("amount_difference", sa.Numeric(20, 4), nullable=False),
sa.Column("expected_currency", sa.String(length=3), nullable=False),
sa.Column("actual_currency", sa.String(length=3), nullable=False),
sa.Column("expected_reference", sa.String(length=160), nullable=False),
sa.Column("external_reference_tail", sa.String(length=8), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("exception_code", sa.String(length=80), nullable=True),
sa.Column("erp_status", sa.String(length=20), nullable=False),
sa.Column("erp_document_tail", sa.String(length=8), nullable=True),
sa.Column("erp_document_hash", sa.String(length=80), nullable=True),
sa.Column("assigned_to", sa.String(length=120), nullable=True),
sa.Column("last_connector_event_id", sa.String(length=36), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
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 ('pending', 'matched', 'exception', 'confirmed', "
"'rejected', 'reopened', 'closed')",
name="ck_payment_reconciliation_cases_status",
),
sa.CheckConstraint(
"erp_status IN ('pending_posting', 'posted', 'posting_failed')",
name="ck_payment_reconciliation_cases_erp_status",
),
sa.CheckConstraint(
"expected_amount >= 0 AND actual_amount >= 0",
name="ck_payment_reconciliation_cases_amounts",
),
sa.CheckConstraint(
"length(trim(expected_currency)) = 3 AND length(trim(actual_currency)) = 3",
name="ck_payment_reconciliation_cases_currencies",
),
sa.ForeignKeyConstraint(
["tenant_id", "expense_case_id"],
["expense_cases.tenant_id", "expense_cases.id"],
name="fk_payment_reconciliation_cases_tenant_expense_case",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "last_connector_event_id"],
["financial_connector_events.tenant_id", "financial_connector_events.id"],
name="fk_payment_reconciliation_cases_tenant_last_event",
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "id", name="uq_payment_reconciliation_cases_tenant_id"),
sa.UniqueConstraint(
"tenant_id",
"provider",
"claim_id",
name="uq_payment_reconciliation_cases_tenant_provider_claim",
),
)
op.create_index(
"ix_payment_reconciliation_cases_tenant_status",
"payment_reconciliation_cases",
["tenant_id", "status", "updated_at"],
)
op.create_table(
"payment_reconciliation_events",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("reconciliation_case_id", sa.String(length=36), nullable=False),
sa.Column("connector_event_id", sa.String(length=36), nullable=False),
sa.Column("action", sa.String(length=32), nullable=False),
sa.Column("actor_type", sa.String(length=20), nullable=False),
sa.Column("actor_id", sa.String(length=120), nullable=False),
sa.Column("request_fingerprint", sa.String(length=80), nullable=False),
sa.Column("before_json", sa.JSON(), nullable=False),
sa.Column("after_json", sa.JSON(), nullable=False),
sa.Column("response_json", sa.JSON(), nullable=False),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("correlation_id", sa.String(length=64), nullable=False),
sa.Column(
"occurred_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.CheckConstraint(
"action IN ('auto_matched', 'exception_created', 'erp_posted', "
"'erp_posting_failed', 'reopened', 'confirmed', 'rejected', 'closed')",
name="ck_payment_reconciliation_events_action",
),
sa.CheckConstraint(
"length(trim(request_fingerprint)) >= 16",
name="ck_payment_reconciliation_events_fingerprint",
),
sa.ForeignKeyConstraint(
["tenant_id", "connector_event_id"],
["financial_connector_events.tenant_id", "financial_connector_events.id"],
name="fk_payment_reconciliation_events_tenant_connector_event",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "reconciliation_case_id"],
["payment_reconciliation_cases.tenant_id", "payment_reconciliation_cases.id"],
name="fk_payment_reconciliation_events_tenant_case",
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "id", name="uq_payment_reconciliation_events_tenant_id"),
sa.UniqueConstraint(
"tenant_id",
"connector_event_id",
"action",
name="uq_payment_reconciliation_events_connector_action",
),
)
op.create_index(
"ix_payment_reconciliation_events_tenant_case_time",
"payment_reconciliation_events",
["tenant_id", "reconciliation_case_id", "occurred_at"],
)
op.execute(
"""
CREATE FUNCTION reject_financial_connector_append_only_mutation()
RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'financial connector facts are append-only';
END;
$$ LANGUAGE plpgsql;
"""
)
for table_name in ("financial_connector_events", "payment_reconciliation_events"):
op.execute(
f"""
CREATE TRIGGER trg_{table_name}_append_only
BEFORE UPDATE OR DELETE ON {table_name}
FOR EACH ROW EXECUTE FUNCTION reject_financial_connector_append_only_mutation();
"""
)
def downgrade() -> None:
_require_postgresql()
_require_empty_connector_domain_for_downgrade()
for table_name in ("payment_reconciliation_events", "financial_connector_events"):
op.execute(f"DROP TRIGGER IF EXISTS trg_{table_name}_append_only ON {table_name}")
op.execute("DROP FUNCTION IF EXISTS reject_financial_connector_append_only_mutation()")
op.drop_index(
"ix_payment_reconciliation_events_tenant_case_time",
table_name="payment_reconciliation_events",
)
op.drop_table("payment_reconciliation_events")
op.drop_index(
"ix_payment_reconciliation_cases_tenant_status",
table_name="payment_reconciliation_cases",
)
op.drop_table("payment_reconciliation_cases")
op.drop_index(
"ix_financial_connector_events_tenant_claim",
table_name="financial_connector_events",
)
op.drop_index(
"ix_financial_connector_events_tenant_received",
table_name="financial_connector_events",
)
op.drop_table("financial_connector_events")
op.drop_index(
"ix_financial_connector_configs_tenant_status",
table_name="financial_connector_configs",
)
op.drop_table("financial_connector_configs")

View File

@@ -0,0 +1,237 @@
"""add append-only real release telemetry and review labels
Revision ID: 20260716_0018
Revises: 20260716_0017
Create Date: 2026-07-16 22:00:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260716_0018"
down_revision: str | None = "20260716_0017"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_TELEMETRY_TABLES = (
"agent_asset_release_observations",
"agent_asset_release_labels",
)
def _require_postgresql() -> None:
dialect_name = op.get_bind().dialect.name
if dialect_name != "postgresql":
raise RuntimeError(
"20260716_0018 only supports PostgreSQL; "
f"refusing to mutate {dialect_name} without transactional constraint DDL"
)
def _require_empty_telemetry_domain_for_downgrade() -> None:
bind = op.get_bind()
counts = {
table_name: int(bind.scalar(sa.text(f"SELECT COUNT(*) FROM {table_name}")) or 0)
for table_name in _TELEMETRY_TABLES
}
if any(counts.values()):
summary = ", ".join(f"{name}={count}" for name, count in counts.items())
raise RuntimeError(
"cannot downgrade release telemetry: immutable observations or labels exist "
f"({summary})"
)
def upgrade() -> None:
_require_postgresql()
op.create_table(
"agent_asset_release_observations",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("asset_id", sa.String(length=36), nullable=False),
sa.Column("release_id", sa.String(length=64), nullable=False),
sa.Column("stage", sa.String(length=16), nullable=False),
sa.Column("version", sa.String(length=30), nullable=False),
sa.Column("rule_code", sa.String(length=100), nullable=False),
sa.Column("business_stage", sa.String(length=40), nullable=False),
sa.Column(
"source_kind",
sa.String(length=32),
server_default="expense_claim_risk",
nullable=False,
),
sa.Column("source_fingerprint", sa.String(length=64), nullable=False),
sa.Column("candidate_hit", sa.Boolean(), nullable=False),
sa.Column("baseline_hit", sa.Boolean(), nullable=True),
sa.Column("runtime_status", sa.String(length=16), nullable=False),
sa.Column(
"failure_code",
sa.String(length=40),
server_default="none",
nullable=False,
),
sa.Column("idempotency_key", sa.String(length=80), nullable=False),
sa.Column("payload_fingerprint", sa.String(length=64), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.CheckConstraint(
"stage IN ('shadow', 'canary', 'active')",
name="ck_agent_asset_release_observations_stage",
),
sa.CheckConstraint(
"runtime_status IN ('completed', 'failed')",
name="ck_agent_asset_release_observations_runtime_status",
),
sa.CheckConstraint(
"source_kind IN ('expense_claim_risk')",
name="ck_agent_asset_release_observations_source_kind",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id",
"id",
name="uq_agent_asset_release_observations_tenant_id",
),
sa.UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_agent_asset_release_observations_tenant_idempotency",
),
sa.UniqueConstraint(
"tenant_id",
"id",
"asset_id",
"release_id",
"stage",
"version",
name="uq_agent_asset_release_observations_release_identity",
),
)
op.create_index(
"ix_agent_asset_release_observations_release",
"agent_asset_release_observations",
["tenant_id", "asset_id", "release_id", "stage", "version", "created_at"],
)
op.create_index(
"ix_agent_asset_release_observations_source",
"agent_asset_release_observations",
["tenant_id", "source_fingerprint"],
)
op.create_table(
"agent_asset_release_labels",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("observation_id", sa.String(length=36), nullable=False),
sa.Column("asset_id", sa.String(length=36), nullable=False),
sa.Column("release_id", sa.String(length=64), nullable=False),
sa.Column("stage", sa.String(length=16), nullable=False),
sa.Column("version", sa.String(length=30), nullable=False),
sa.Column("label", sa.String(length=24), nullable=False),
sa.Column("verification_source", sa.String(length=32), nullable=False),
sa.Column("source_event_fingerprint", sa.String(length=64), nullable=False),
sa.Column("actor_fingerprint", sa.String(length=64), nullable=False),
sa.Column("idempotency_key", sa.String(length=80), nullable=False),
sa.Column("payload_fingerprint", sa.String(length=64), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.CheckConstraint(
"label IN ('confirmed', 'false_positive')",
name="ck_agent_asset_release_labels_label",
),
sa.CheckConstraint(
"verification_source IN ('typed_risk_disposition', 'release_review')",
name="ck_agent_asset_release_labels_source",
),
sa.ForeignKeyConstraint(
["tenant_id", "observation_id", "asset_id", "release_id", "stage", "version"],
[
"agent_asset_release_observations.tenant_id",
"agent_asset_release_observations.id",
"agent_asset_release_observations.asset_id",
"agent_asset_release_observations.release_id",
"agent_asset_release_observations.stage",
"agent_asset_release_observations.version",
],
name="fk_agent_asset_release_labels_release_observation",
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id",
"id",
name="uq_agent_asset_release_labels_tenant_id",
),
sa.UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_agent_asset_release_labels_tenant_idempotency",
),
)
op.create_index(
"ix_agent_asset_release_labels_observation_time",
"agent_asset_release_labels",
["tenant_id", "observation_id", "created_at"],
)
op.create_index(
"ix_agent_asset_release_labels_release",
"agent_asset_release_labels",
["tenant_id", "asset_id", "release_id", "stage", "version"],
)
op.execute(
"""
CREATE FUNCTION reject_agent_asset_release_telemetry_mutation()
RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'agent asset release telemetry is append-only';
END;
$$ LANGUAGE plpgsql;
"""
)
for table_name in _TELEMETRY_TABLES:
op.execute(
f"""
CREATE TRIGGER trg_{table_name}_append_only
BEFORE UPDATE OR DELETE ON {table_name}
FOR EACH ROW EXECUTE FUNCTION reject_agent_asset_release_telemetry_mutation();
"""
)
def downgrade() -> None:
_require_postgresql()
_require_empty_telemetry_domain_for_downgrade()
for table_name in reversed(_TELEMETRY_TABLES):
op.execute(f"DROP TRIGGER IF EXISTS trg_{table_name}_append_only ON {table_name}")
op.execute("DROP FUNCTION IF EXISTS reject_agent_asset_release_telemetry_mutation()")
op.drop_index(
"ix_agent_asset_release_labels_release",
table_name="agent_asset_release_labels",
)
op.drop_index(
"ix_agent_asset_release_labels_observation_time",
table_name="agent_asset_release_labels",
)
op.drop_table("agent_asset_release_labels")
op.drop_index(
"ix_agent_asset_release_observations_source",
table_name="agent_asset_release_observations",
)
op.drop_index(
"ix_agent_asset_release_observations_release",
table_name="agent_asset_release_observations",
)
op.drop_table("agent_asset_release_observations")

View File

@@ -0,0 +1,178 @@
"""add commercial runtime quota reservations
Revision ID: 20260716_0019
Revises: 20260716_0018
Create Date: 2026-07-16 22:10:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260716_0019"
down_revision: str | None = "20260716_0018"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _require_postgresql() -> None:
dialect_name = op.get_bind().dialect.name
if dialect_name != "postgresql":
raise RuntimeError(
"20260716_0019 only supports PostgreSQL; "
f"refusing to mutate {dialect_name} without transactional constraint DDL"
)
def _require_empty_reservations_for_downgrade() -> None:
count = int(
op.get_bind().scalar(
sa.text("SELECT COUNT(*) FROM commercial_runtime_reservations")
)
or 0
)
if count:
raise RuntimeError(
"cannot downgrade commercial runtime reservations: "
f"operational quota holds exist ({count})"
)
def upgrade() -> None:
_require_postgresql()
op.create_table(
"commercial_runtime_reservations",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("subscription_id", sa.String(length=36), nullable=False),
sa.Column("entitlement_id", sa.String(length=36), nullable=False),
sa.Column("run_id", sa.String(length=50), nullable=False),
sa.Column("tool_call_id", sa.String(length=36), nullable=False),
sa.Column("tool_type", sa.String(length=30), nullable=False),
sa.Column("tool_name", sa.String(length=100), nullable=False),
sa.Column("quantity_basis", sa.String(length=20), nullable=False),
sa.Column("reserved_quantity", sa.Numeric(20, 6), nullable=False),
sa.Column("actual_quantity", sa.Numeric(20, 6), nullable=True),
sa.Column("period_key", sa.String(length=32), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False),
sa.Column("request_fingerprint", sa.String(length=64), nullable=False),
sa.Column("meter_config_json", sa.JSON(), nullable=False),
sa.Column("resolution_code", sa.String(length=64), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("settled_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 ('reserved', 'committed', 'released', 'expired', "
"'reconciliation_required', 'committed_reconciliation_required')",
name="ck_commercial_runtime_reservations_status",
),
sa.CheckConstraint(
"quantity_basis IN ('call', 'input_tokens', 'output_tokens', "
"'total_tokens', 'duration_ms')",
name="ck_commercial_runtime_reservations_basis",
),
sa.CheckConstraint(
"reserved_quantity > 0 AND "
"(actual_quantity IS NULL OR actual_quantity > 0)",
name="ck_commercial_runtime_reservations_quantity",
),
sa.CheckConstraint(
"expires_at > created_at",
name="ck_commercial_runtime_reservations_expiry",
),
sa.CheckConstraint(
"(status = 'reserved' AND actual_quantity IS NULL AND settled_at IS NULL "
"AND resolution_code IS NULL) OR "
"(status = 'committed' AND actual_quantity IS NOT NULL "
"AND actual_quantity <= reserved_quantity AND settled_at IS NOT NULL "
"AND resolution_code IS NULL) OR "
"(status IN ('released', 'expired') AND actual_quantity IS NULL "
"AND settled_at IS NOT NULL AND resolution_code IS NOT NULL) OR "
"(status = 'reconciliation_required' AND settled_at IS NULL "
"AND resolution_code IS NOT NULL) OR "
"(status = 'committed_reconciliation_required' "
"AND actual_quantity IS NOT NULL "
"AND actual_quantity <= reserved_quantity "
"AND settled_at IS NOT NULL AND resolution_code IS NOT NULL)",
name="ck_commercial_runtime_reservations_state",
),
sa.CheckConstraint(
"length(trim(run_id)) > 0 AND length(trim(tool_call_id)) > 0 "
"AND length(trim(tool_type)) > 0 AND length(trim(tool_name)) > 0 "
"AND length(trim(period_key)) > 0 "
"AND length(trim(request_fingerprint)) > 0",
name="ck_commercial_runtime_reservations_keys",
),
sa.ForeignKeyConstraint(
["tenant_id", "subscription_id"],
["tenant_subscriptions.tenant_id", "tenant_subscriptions.id"],
name="fk_commercial_runtime_reservations_tenant_subscription",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "subscription_id", "entitlement_id"],
[
"commercial_entitlements.tenant_id",
"commercial_entitlements.subscription_id",
"commercial_entitlements.id",
],
name="fk_commercial_runtime_reservations_tenant_entitlement",
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id",
"id",
name="uq_commercial_runtime_reservations_tenant_id",
),
sa.UniqueConstraint(
"tool_call_id",
name="uq_commercial_runtime_reservations_tool_call",
),
)
op.create_index(
"ix_commercial_runtime_reservations_quota",
"commercial_runtime_reservations",
["tenant_id", "subscription_id", "entitlement_id", "period_key", "status"],
)
op.create_index(
"ix_commercial_runtime_reservations_expiry",
"commercial_runtime_reservations",
["status", "expires_at"],
)
op.create_index(
"ix_commercial_runtime_reservations_run",
"commercial_runtime_reservations",
["tenant_id", "run_id", "created_at"],
)
def downgrade() -> None:
_require_postgresql()
_require_empty_reservations_for_downgrade()
op.drop_index(
"ix_commercial_runtime_reservations_run",
table_name="commercial_runtime_reservations",
)
op.drop_index(
"ix_commercial_runtime_reservations_expiry",
table_name="commercial_runtime_reservations",
)
op.drop_index(
"ix_commercial_runtime_reservations_quota",
table_name="commercial_runtime_reservations",
)
op.drop_table("commercial_runtime_reservations")

View File

@@ -0,0 +1,181 @@
"""add versioned financial connector config lifecycle audit
Revision ID: 20260716_0020
Revises: 20260716_0019
Create Date: 2026-07-16 22:40:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260716_0020"
down_revision: str | None = "20260716_0019"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _require_postgresql() -> None:
dialect_name = op.get_bind().dialect.name
if dialect_name != "postgresql":
raise RuntimeError(
"20260716_0020 only supports PostgreSQL; "
f"refusing to mutate {dialect_name} without transactional constraint DDL"
)
def _require_empty_lifecycle_for_downgrade() -> None:
bind = op.get_bind()
counts = {
table_name: int(bind.scalar(sa.text(f"SELECT COUNT(*) FROM {table_name}")) or 0)
for table_name in (
"financial_connector_configs",
"financial_connector_config_events",
)
}
if any(counts.values()):
summary = ", ".join(f"{name}={count}" for name, count in counts.items())
raise RuntimeError(
"cannot downgrade financial connector lifecycle: versioned configuration "
f"state or immutable audit facts exist ({summary})"
)
def upgrade() -> None:
_require_postgresql()
# 0017 曾冗余保存完整 claim_reference。这里以受控迁移完成一次性脱敏
# 内容哈希和签名指纹仍足以证明原始回执,运行期触发器随后立即恢复。
op.execute(
"ALTER TABLE financial_connector_events "
"DISABLE TRIGGER trg_financial_connector_events_append_only"
)
op.execute(
"UPDATE financial_connector_events "
"SET normalized_payload_json = "
"(normalized_payload_json::jsonb - 'claim_reference')::json "
"WHERE normalized_payload_json::jsonb ? 'claim_reference'"
)
op.execute(
"UPDATE financial_connector_events SET response_json = "
"jsonb_set(response_json::jsonb, '{projection_scope}', "
"to_jsonb((CASE WHEN verification_level = 'production_verified' "
"THEN 'canonical' "
"WHEN COALESCE(response_json::jsonb ->> 'reconciliation_case_id', '') <> '' "
"THEN 'legacy_nonproduction_effect_unknown' "
"ELSE 'simulation_only' END)::text), true)::json "
"WHERE NOT (response_json::jsonb ? 'projection_scope')"
)
op.execute(
"ALTER TABLE financial_connector_events "
"ENABLE TRIGGER trg_financial_connector_events_append_only"
)
op.add_column(
"financial_connector_configs",
sa.Column("version", sa.Integer(), server_default="1", nullable=False),
)
op.create_check_constraint(
"ck_financial_connector_configs_version",
"financial_connector_configs",
"version >= 1",
)
op.alter_column(
"financial_connector_configs",
"version",
existing_type=sa.Integer(),
server_default=None,
)
op.create_table(
"financial_connector_config_events",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("config_id", sa.String(length=36), nullable=False),
sa.Column("action", sa.String(length=40), nullable=False),
sa.Column("actor_id", sa.String(length=120), nullable=False),
sa.Column("request_id", sa.String(length=120), nullable=False),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column("expected_version", sa.Integer(), nullable=True),
sa.Column("before_json", sa.JSON(), nullable=False),
sa.Column("after_json", sa.JSON(), nullable=False),
sa.Column(
"occurred_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.CheckConstraint(
"action IN ('created', 'activated', 'disabled', "
"'rotation_started', 'rotation_replacement_created')",
name="ck_financial_connector_config_events_action",
),
sa.CheckConstraint(
"expected_version IS NULL OR expected_version >= 1",
name="ck_financial_connector_config_events_expected_version",
),
sa.CheckConstraint(
"length(trim(actor_id)) > 0 AND length(trim(request_id)) > 0 "
"AND length(trim(reason)) > 0",
name="ck_financial_connector_config_events_required_text",
),
sa.ForeignKeyConstraint(
["tenant_id", "config_id"],
["financial_connector_configs.tenant_id", "financial_connector_configs.id"],
name="fk_financial_connector_config_events_tenant_config",
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id",
"id",
name="uq_financial_connector_config_events_tenant_id",
),
sa.UniqueConstraint(
"tenant_id",
"request_id",
"action",
name="uq_financial_connector_config_events_tenant_request_action",
),
)
op.create_index(
"ix_financial_connector_config_events_tenant_config_time",
"financial_connector_config_events",
["tenant_id", "config_id", "occurred_at"],
)
op.create_index(
"ix_financial_connector_config_events_tenant_request",
"financial_connector_config_events",
["tenant_id", "request_id"],
)
op.execute(
"""
CREATE TRIGGER trg_financial_connector_config_events_append_only
BEFORE UPDATE OR DELETE ON financial_connector_config_events
FOR EACH ROW EXECUTE FUNCTION reject_financial_connector_append_only_mutation();
"""
)
def downgrade() -> None:
_require_postgresql()
_require_empty_lifecycle_for_downgrade()
op.execute(
"DROP TRIGGER IF EXISTS trg_financial_connector_config_events_append_only "
"ON financial_connector_config_events"
)
op.drop_index(
"ix_financial_connector_config_events_tenant_request",
table_name="financial_connector_config_events",
)
op.drop_index(
"ix_financial_connector_config_events_tenant_config_time",
table_name="financial_connector_config_events",
)
op.drop_table("financial_connector_config_events")
op.drop_constraint(
"ck_financial_connector_configs_version",
"financial_connector_configs",
type_="check",
)
op.drop_column("financial_connector_configs", "version")

View File

@@ -0,0 +1,724 @@
"""add immutable commercial billing periods and administration audit
Revision ID: 20260716_0021
Revises: 20260716_0020
Create Date: 2026-07-16 22:45:00
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260716_0021"
down_revision: str | None = "20260716_0020"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _require_postgresql() -> None:
dialect_name = op.get_bind().dialect.name
if dialect_name != "postgresql":
raise RuntimeError(
"20260716_0021 only supports PostgreSQL; "
f"refusing to mutate {dialect_name} without transactional constraint DDL"
)
def _require_empty_billing_lifecycle_for_downgrade() -> None:
bind = op.get_bind()
counts = {
table_name: int(bind.scalar(sa.text(f"SELECT COUNT(*) FROM {table_name}")) or 0)
for table_name in ("commercial_admin_events", "commercial_billing_periods")
}
if any(counts.values()):
summary = ", ".join(f"{name}={count}" for name, count in counts.items())
raise RuntimeError(
"cannot downgrade commercial billing lifecycle: immutable periods or audit "
f"facts exist ({summary})"
)
def _json_object_default() -> sa.TextClause:
return sa.text("'{}'::json")
def upgrade() -> None:
_require_postgresql()
_create_billing_periods()
_create_admin_events()
_backfill_current_periods_and_audit()
_bind_usage_to_periods()
_bind_costs_to_periods()
_bind_reservations_to_periods()
_create_period_overlap_guard()
_create_append_only_triggers()
def _create_billing_periods() -> None:
op.create_table(
"commercial_billing_periods",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("subscription_id", sa.String(length=36), nullable=False),
sa.Column("plan_id", sa.String(length=36), nullable=False),
sa.Column("period_sequence", sa.Integer(), nullable=False),
sa.Column("period_key", sa.String(length=64), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False, server_default="issued"),
sa.Column("period_start", sa.DateTime(timezone=True), nullable=False),
sa.Column("period_end", sa.DateTime(timezone=True), nullable=False),
sa.Column("subscription_status_snapshot", sa.String(length=20), nullable=False),
sa.Column("plan_code_snapshot", sa.String(length=80), nullable=False),
sa.Column("plan_version_snapshot", sa.Integer(), nullable=False),
sa.Column("pricing_model_snapshot", sa.String(length=24), nullable=False),
sa.Column("billing_interval", sa.String(length=20), nullable=False),
sa.Column("currency", sa.String(length=3), nullable=False),
sa.Column("base_fee_snapshot", sa.Numeric(20, 4), nullable=False),
sa.Column("seats_snapshot", sa.Integer(), nullable=False),
sa.Column("source", sa.String(length=32), nullable=False),
sa.Column("idempotency_key", sa.String(length=160), nullable=False),
sa.Column("created_by", sa.String(length=120), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.CheckConstraint(
"status = 'issued'",
name="ck_commercial_billing_periods_status",
),
sa.CheckConstraint(
"subscription_status_snapshot IN ('trialing', 'active', 'past_due', "
"'suspended', 'canceled', 'expired')",
name="ck_commercial_billing_periods_subscription_status",
),
sa.CheckConstraint(
"pricing_model_snapshot IN ('subscription', 'usage', 'hybrid', 'pilot', 'custom')",
name="ck_commercial_billing_periods_pricing_model",
),
sa.CheckConstraint(
"billing_interval IN ('monthly', 'quarterly', 'annual', 'contract')",
name="ck_commercial_billing_periods_interval",
),
sa.CheckConstraint(
"source IN ('subscription_created', 'auto_renew', 'migration_backfill')",
name="ck_commercial_billing_periods_source",
),
sa.CheckConstraint(
"period_sequence >= 1 AND period_end > period_start "
"AND plan_version_snapshot >= 1 AND base_fee_snapshot >= 0 "
"AND seats_snapshot > 0",
name="ck_commercial_billing_periods_values",
),
sa.CheckConstraint(
"length(trim(period_key)) > 0 AND length(trim(plan_code_snapshot)) > 0 "
"AND length(trim(currency)) = 3 AND length(trim(idempotency_key)) > 0 "
"AND length(trim(created_by)) > 0",
name="ck_commercial_billing_periods_keys",
),
sa.ForeignKeyConstraint(
["tenant_id", "subscription_id"],
["tenant_subscriptions.tenant_id", "tenant_subscriptions.id"],
name="fk_commercial_billing_periods_tenant_subscription",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "plan_id"],
["tenant_commercial_plans.tenant_id", "tenant_commercial_plans.id"],
name="fk_commercial_billing_periods_tenant_plan",
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "id", name="uq_commercial_billing_periods_tenant_id"),
sa.UniqueConstraint(
"tenant_id",
"subscription_id",
"id",
name="uq_commercial_billing_periods_tenant_subscription_id",
),
sa.UniqueConstraint(
"tenant_id",
"subscription_id",
"period_sequence",
name="uq_commercial_billing_periods_subscription_sequence",
),
sa.UniqueConstraint(
"tenant_id",
"subscription_id",
"period_key",
name="uq_commercial_billing_periods_subscription_key",
),
sa.UniqueConstraint(
"tenant_id",
"subscription_id",
"period_start",
name="uq_commercial_billing_periods_subscription_start",
),
sa.UniqueConstraint(
"tenant_id",
"subscription_id",
"idempotency_key",
name="uq_commercial_billing_periods_subscription_request",
),
)
op.create_index(
"ix_commercial_billing_periods_tenant_window",
"commercial_billing_periods",
["tenant_id", "period_start", "period_end"],
)
op.create_index(
"ix_commercial_billing_periods_subscription_window",
"commercial_billing_periods",
["tenant_id", "subscription_id", "period_start", "period_end"],
)
def _create_admin_events() -> None:
op.create_table(
"commercial_admin_events",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("actor_type", sa.String(length=16), nullable=False),
sa.Column("actor_id", sa.String(length=120), nullable=False),
sa.Column("request_id", sa.String(length=120), nullable=False),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column("action", sa.String(length=48), nullable=False),
sa.Column("resource_type", sa.String(length=24), nullable=False),
sa.Column("resource_id", sa.String(length=36), nullable=False),
sa.Column("resource_version", sa.Integer(), nullable=False),
sa.Column("before_json", sa.JSON(), nullable=False, server_default=_json_object_default()),
sa.Column("after_json", sa.JSON(), nullable=False, server_default=_json_object_default()),
sa.Column(
"occurred_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.CheckConstraint(
"actor_type IN ('user', 'system', 'migration')",
name="ck_commercial_admin_events_actor_type",
),
sa.CheckConstraint(
"action IN ('plan_created', 'plan_activated', 'plan_retired', "
"'subscription_created', 'subscription_activated', "
"'subscription_transitioned', 'entitlement_created', "
"'entitlement_updated', 'entitlement_activated', "
"'billing_period_created', 'subscription_rolled_over', "
"'legacy_state_imported')",
name="ck_commercial_admin_events_action",
),
sa.CheckConstraint(
"resource_type IN ('plan', 'subscription', 'entitlement', 'billing_period')",
name="ck_commercial_admin_events_resource_type",
),
sa.CheckConstraint(
"resource_version >= 1",
name="ck_commercial_admin_events_resource_version",
),
sa.CheckConstraint(
"length(trim(actor_id)) > 0 AND length(trim(request_id)) > 0 "
"AND length(trim(reason)) > 0 AND length(trim(resource_id)) > 0",
name="ck_commercial_admin_events_required_text",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "id", name="uq_commercial_admin_events_tenant_id"),
sa.UniqueConstraint(
"tenant_id",
"request_id",
"action",
"resource_type",
"resource_id",
name="uq_commercial_admin_events_request_resource",
),
)
op.create_index(
"ix_commercial_admin_events_tenant_time",
"commercial_admin_events",
["tenant_id", "occurred_at", "id"],
)
op.create_index(
"ix_commercial_admin_events_tenant_resource",
"commercial_admin_events",
["tenant_id", "resource_type", "resource_id", "occurred_at"],
)
op.create_index(
"ix_commercial_admin_events_tenant_request",
"commercial_admin_events",
["tenant_id", "request_id"],
)
def _backfill_current_periods_and_audit() -> None:
op.execute(
"""
INSERT INTO commercial_billing_periods (
id, tenant_id, subscription_id, plan_id, period_sequence, period_key,
status, period_start, period_end, subscription_status_snapshot,
plan_code_snapshot, plan_version_snapshot, pricing_model_snapshot,
billing_interval, currency, base_fee_snapshot, seats_snapshot,
source, idempotency_key, created_by
)
SELECT
substr(digest, 1, 8) || '-' || substr(digest, 9, 4) || '-' ||
substr(digest, 13, 4) || '-' || substr(digest, 17, 4) || '-' ||
substr(digest, 21, 12),
subscription.tenant_id,
subscription.id,
subscription.plan_id,
1,
'bp-' || substr(digest, 1, 24),
'issued',
subscription.current_period_start,
subscription.current_period_end,
subscription.status,
plan.plan_code,
plan.version,
plan.pricing_model,
subscription.billing_interval,
subscription.currency,
subscription.base_fee_snapshot,
subscription.seats,
'migration_backfill',
'migration-0021:' || subscription.id,
'migration:20260716_0021'
FROM tenant_subscriptions AS subscription
JOIN tenant_commercial_plans AS plan
ON plan.tenant_id = subscription.tenant_id
AND plan.id = subscription.plan_id
CROSS JOIN LATERAL (
SELECT md5(
subscription.tenant_id || ':' || subscription.id || ':' ||
subscription.current_period_start::text || ':' ||
subscription.current_period_end::text
) AS digest
) AS identity
"""
)
for resource_type, table_name, version_column in (
("plan", "tenant_commercial_plans", "version"),
("subscription", "tenant_subscriptions", "version"),
("entitlement", "commercial_entitlements", "version"),
("billing_period", "commercial_billing_periods", "period_sequence"),
):
op.execute(
f"""
INSERT INTO commercial_admin_events (
id, tenant_id, actor_type, actor_id, request_id, reason, action,
resource_type, resource_id, resource_version, before_json, after_json
)
SELECT
substr(digest, 1, 8) || '-' || substr(digest, 9, 4) || '-' ||
substr(digest, 13, 4) || '-' || substr(digest, 17, 4) || '-' ||
substr(digest, 21, 12),
resource.tenant_id,
'migration',
'migration:20260716_0021',
'migration:0021:{resource_type}:' || resource.id,
'0021 建立商业管理审计基线,不推断迁移前操作人或原始请求。',
'legacy_state_imported',
'{resource_type}',
resource.id,
resource.{version_column},
'{{}}'::json,
json_build_object('id', resource.id, 'version', resource.{version_column})
FROM {table_name} AS resource
CROSS JOIN LATERAL (
SELECT md5(
resource.tenant_id || ':audit:{resource_type}:' || resource.id
) AS digest
) AS identity
"""
)
def _bind_usage_to_periods() -> None:
op.add_column(
"usage_meter_events",
sa.Column("billing_period_id", sa.String(length=36), nullable=True),
)
op.add_column(
"usage_meter_events",
sa.Column("quota_period_key", sa.String(length=64), nullable=True),
)
op.alter_column(
"usage_meter_events",
"period_key",
existing_type=sa.String(length=32),
type_=sa.String(length=64),
existing_nullable=False,
)
op.execute(
"""
UPDATE usage_meter_events AS usage
SET billing_period_id = period.id,
quota_period_key = usage.period_key,
period_key = period.period_key
FROM commercial_billing_periods AS period
WHERE period.tenant_id = usage.tenant_id
AND period.subscription_id = usage.subscription_id
AND usage.occurred_at >= period.period_start
AND usage.occurred_at < period.period_end
"""
)
_fail_if_unbound(
"usage_meter_events",
"billing_period_id IS NULL OR quota_period_key IS NULL",
"existing usage facts do not map to one immutable current billing period",
)
op.alter_column(
"usage_meter_events", "billing_period_id", existing_type=sa.String(36), nullable=False
)
op.alter_column(
"usage_meter_events", "quota_period_key", existing_type=sa.String(64), nullable=False
)
op.drop_constraint("ck_usage_meter_events_keys", "usage_meter_events", type_="check")
op.create_check_constraint(
"ck_usage_meter_events_keys",
"usage_meter_events",
"length(trim(metric_key)) > 0 AND length(trim(unit)) > 0 "
"AND length(trim(period_key)) > 0 AND length(trim(source_system)) > 0 "
"AND length(trim(quota_period_key)) > 0 "
"AND length(trim(idempotency_key)) > 0 "
"AND length(trim(request_fingerprint)) > 0",
)
op.create_foreign_key(
"fk_usage_meter_events_tenant_billing_period",
"usage_meter_events",
"commercial_billing_periods",
["tenant_id", "subscription_id", "billing_period_id"],
["tenant_id", "subscription_id", "id"],
ondelete="RESTRICT",
)
op.drop_index("ix_usage_meter_events_quota_window", table_name="usage_meter_events")
op.create_index(
"ix_usage_meter_events_quota_window",
"usage_meter_events",
["tenant_id", "subscription_id", "metric_key", "quota_period_key", "occurred_at"],
)
op.create_index(
"ix_usage_meter_events_billing_period",
"usage_meter_events",
["tenant_id", "billing_period_id", "occurred_at"],
)
def _bind_costs_to_periods() -> None:
op.add_column(
"commercial_cost_events",
sa.Column("billing_period_id", sa.String(length=36), nullable=True),
)
op.execute(
"""
UPDATE commercial_cost_events AS cost
SET billing_period_id = usage.billing_period_id
FROM usage_meter_events AS usage
WHERE usage.tenant_id = cost.tenant_id
AND usage.subscription_id = cost.subscription_id
AND usage.id = cost.usage_event_id
"""
)
op.execute(
"""
UPDATE commercial_cost_events AS cost
SET billing_period_id = period.id
FROM commercial_billing_periods AS period
WHERE cost.subscription_id IS NOT NULL
AND cost.billing_period_id IS NULL
AND period.tenant_id = cost.tenant_id
AND period.subscription_id = cost.subscription_id
AND cost.occurred_at >= period.period_start
AND cost.occurred_at < period.period_end
"""
)
_fail_if_unbound(
"commercial_cost_events",
"subscription_id IS NOT NULL AND billing_period_id IS NULL",
"existing subscription cost facts do not map to one immutable billing period",
)
op.create_check_constraint(
"ck_commercial_cost_events_billing_period_pair",
"commercial_cost_events",
"(subscription_id IS NULL AND billing_period_id IS NULL) OR "
"(subscription_id IS NOT NULL AND billing_period_id IS NOT NULL)",
)
op.create_foreign_key(
"fk_commercial_cost_events_tenant_billing_period",
"commercial_cost_events",
"commercial_billing_periods",
["tenant_id", "subscription_id", "billing_period_id"],
["tenant_id", "subscription_id", "id"],
ondelete="RESTRICT",
)
op.drop_index(
"ix_commercial_cost_events_subscription_period",
table_name="commercial_cost_events",
)
op.create_index(
"ix_commercial_cost_events_subscription_period",
"commercial_cost_events",
["tenant_id", "subscription_id", "billing_period_id", "occurred_at"],
)
def _bind_reservations_to_periods() -> None:
op.add_column(
"commercial_runtime_reservations",
sa.Column("billing_period_id", sa.String(length=36), nullable=True),
)
op.add_column(
"commercial_runtime_reservations",
sa.Column("quota_period_key", sa.String(length=64), nullable=True),
)
op.alter_column(
"commercial_runtime_reservations",
"period_key",
existing_type=sa.String(length=32),
type_=sa.String(length=64),
existing_nullable=False,
)
op.execute(
"""
UPDATE commercial_runtime_reservations AS reservation
SET billing_period_id = period.id,
quota_period_key = reservation.period_key,
period_key = period.period_key
FROM commercial_billing_periods AS period
WHERE period.tenant_id = reservation.tenant_id
AND period.subscription_id = reservation.subscription_id
AND reservation.created_at >= period.period_start
AND reservation.created_at < period.period_end
"""
)
_fail_if_unbound(
"commercial_runtime_reservations",
"billing_period_id IS NULL OR quota_period_key IS NULL",
"existing runtime reservations do not map to one immutable billing period",
)
op.alter_column(
"commercial_runtime_reservations",
"billing_period_id",
existing_type=sa.String(36),
nullable=False,
)
op.alter_column(
"commercial_runtime_reservations",
"quota_period_key",
existing_type=sa.String(64),
nullable=False,
)
op.drop_constraint(
"ck_commercial_runtime_reservations_keys",
"commercial_runtime_reservations",
type_="check",
)
op.create_check_constraint(
"ck_commercial_runtime_reservations_keys",
"commercial_runtime_reservations",
"length(trim(run_id)) > 0 AND length(trim(tool_call_id)) > 0 "
"AND length(trim(tool_type)) > 0 AND length(trim(tool_name)) > 0 "
"AND length(trim(period_key)) > 0 AND length(trim(quota_period_key)) > 0 "
"AND length(trim(request_fingerprint)) > 0",
)
op.create_foreign_key(
"fk_commercial_runtime_reservations_tenant_billing_period",
"commercial_runtime_reservations",
"commercial_billing_periods",
["tenant_id", "subscription_id", "billing_period_id"],
["tenant_id", "subscription_id", "id"],
ondelete="RESTRICT",
)
op.drop_index(
"ix_commercial_runtime_reservations_quota",
table_name="commercial_runtime_reservations",
)
op.create_index(
"ix_commercial_runtime_reservations_quota",
"commercial_runtime_reservations",
["tenant_id", "subscription_id", "entitlement_id", "quota_period_key", "status"],
)
op.create_index(
"ix_commercial_runtime_reservations_billing_period",
"commercial_runtime_reservations",
["tenant_id", "billing_period_id", "status"],
)
def _fail_if_unbound(table_name: str, predicate: str, message: str) -> None:
op.execute(
f"""
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM {table_name} WHERE {predicate}) THEN
RAISE EXCEPTION '{message}';
END IF;
END;
$$
"""
)
def _create_append_only_triggers() -> None:
for table_name in ("commercial_billing_periods", "commercial_admin_events"):
op.execute(
f"""
CREATE TRIGGER trg_{table_name}_append_only
BEFORE UPDATE OR DELETE ON {table_name}
FOR EACH ROW EXECUTE FUNCTION prevent_commercial_events_mutation()
"""
)
def _create_period_overlap_guard() -> None:
op.execute(
"""
CREATE FUNCTION prevent_commercial_billing_period_overlap()
RETURNS trigger AS $$
BEGIN
PERFORM pg_advisory_xact_lock(
hashtextextended(NEW.tenant_id || ':' || NEW.subscription_id, 0)
);
IF EXISTS (
SELECT 1
FROM commercial_billing_periods AS existing
WHERE existing.tenant_id = NEW.tenant_id
AND existing.subscription_id = NEW.subscription_id
AND tstzrange(
existing.period_start,
existing.period_end,
'[)'
) && tstzrange(NEW.period_start, NEW.period_end, '[)')
) THEN
RAISE EXCEPTION 'commercial billing period overlaps an issued period';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql
"""
)
op.execute(
"""
CREATE TRIGGER trg_commercial_billing_periods_no_overlap
BEFORE INSERT ON commercial_billing_periods
FOR EACH ROW EXECUTE FUNCTION prevent_commercial_billing_period_overlap()
"""
)
def downgrade() -> None:
_require_postgresql()
_require_empty_billing_lifecycle_for_downgrade()
_unbind_reservations()
_unbind_costs()
_unbind_usage()
op.execute(
"DROP TRIGGER IF EXISTS trg_commercial_billing_periods_no_overlap "
"ON commercial_billing_periods"
)
for table_name in ("commercial_admin_events", "commercial_billing_periods"):
op.execute(f"DROP TRIGGER IF EXISTS trg_{table_name}_append_only ON {table_name}")
op.drop_table("commercial_admin_events")
op.drop_table("commercial_billing_periods")
op.execute("DROP FUNCTION IF EXISTS prevent_commercial_billing_period_overlap()")
def _unbind_reservations() -> None:
op.drop_index(
"ix_commercial_runtime_reservations_billing_period",
table_name="commercial_runtime_reservations",
)
op.drop_index(
"ix_commercial_runtime_reservations_quota",
table_name="commercial_runtime_reservations",
)
op.create_index(
"ix_commercial_runtime_reservations_quota",
"commercial_runtime_reservations",
["tenant_id", "subscription_id", "entitlement_id", "period_key", "status"],
)
op.drop_constraint(
"fk_commercial_runtime_reservations_tenant_billing_period",
"commercial_runtime_reservations",
type_="foreignkey",
)
op.drop_constraint(
"ck_commercial_runtime_reservations_keys",
"commercial_runtime_reservations",
type_="check",
)
op.create_check_constraint(
"ck_commercial_runtime_reservations_keys",
"commercial_runtime_reservations",
"length(trim(run_id)) > 0 AND length(trim(tool_call_id)) > 0 "
"AND length(trim(tool_type)) > 0 AND length(trim(tool_name)) > 0 "
"AND length(trim(period_key)) > 0 "
"AND length(trim(request_fingerprint)) > 0",
)
op.drop_column("commercial_runtime_reservations", "quota_period_key")
op.drop_column("commercial_runtime_reservations", "billing_period_id")
op.alter_column(
"commercial_runtime_reservations",
"period_key",
existing_type=sa.String(length=64),
type_=sa.String(length=32),
existing_nullable=False,
)
def _unbind_costs() -> None:
op.drop_index(
"ix_commercial_cost_events_subscription_period",
table_name="commercial_cost_events",
)
op.create_index(
"ix_commercial_cost_events_subscription_period",
"commercial_cost_events",
["tenant_id", "subscription_id", "occurred_at"],
)
op.drop_constraint(
"fk_commercial_cost_events_tenant_billing_period",
"commercial_cost_events",
type_="foreignkey",
)
op.drop_constraint(
"ck_commercial_cost_events_billing_period_pair",
"commercial_cost_events",
type_="check",
)
op.drop_column("commercial_cost_events", "billing_period_id")
def _unbind_usage() -> None:
op.drop_index("ix_usage_meter_events_billing_period", table_name="usage_meter_events")
op.drop_index("ix_usage_meter_events_quota_window", table_name="usage_meter_events")
op.create_index(
"ix_usage_meter_events_quota_window",
"usage_meter_events",
["tenant_id", "subscription_id", "metric_key", "period_key", "occurred_at"],
)
op.drop_constraint(
"fk_usage_meter_events_tenant_billing_period",
"usage_meter_events",
type_="foreignkey",
)
op.drop_constraint("ck_usage_meter_events_keys", "usage_meter_events", type_="check")
op.create_check_constraint(
"ck_usage_meter_events_keys",
"usage_meter_events",
"length(trim(metric_key)) > 0 AND length(trim(unit)) > 0 "
"AND length(trim(period_key)) > 0 AND length(trim(source_system)) > 0 "
"AND length(trim(idempotency_key)) > 0 "
"AND length(trim(request_fingerprint)) > 0",
)
op.drop_column("usage_meter_events", "quota_period_key")
op.drop_column("usage_meter_events", "billing_period_id")
op.alter_column(
"usage_meter_events",
"period_key",
existing_type=sa.String(length=64),
type_=sa.String(length=32),
existing_nullable=False,
)

View File

@@ -0,0 +1,148 @@
"""add durable financial connector operational events
Revision ID: 20260716_0022
Revises: 20260716_0021
Create Date: 2026-07-17 00:30:00
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260716_0022"
down_revision: str | None = "20260716_0021"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _require_postgresql() -> None:
dialect_name = op.get_bind().dialect.name
if dialect_name != "postgresql":
raise RuntimeError(
"20260716_0022 only supports PostgreSQL; "
f"refusing to mutate {dialect_name} without transactional constraint DDL"
)
def _require_empty_operational_events_for_downgrade() -> None:
count = int(
op.get_bind().scalar(
sa.text("SELECT COUNT(*) FROM financial_connector_operational_events")
)
or 0
)
if count:
raise RuntimeError(
"cannot downgrade financial connector operational events: "
f"immutable operational facts exist (financial_connector_operational_events={count})"
)
def upgrade() -> None:
_require_postgresql()
op.create_table(
"financial_connector_operational_events",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("config_id", sa.String(length=36), nullable=False),
sa.Column("provider", sa.String(length=80), nullable=False),
sa.Column("environment", sa.String(length=16), nullable=False),
sa.Column("event_type", sa.String(length=32), nullable=False),
sa.Column("reason_code", sa.String(length=80), nullable=False),
sa.Column("request_fingerprint", sa.String(length=76), nullable=False),
sa.Column("external_event_fingerprint", sa.String(length=76), nullable=False),
sa.Column("idempotency_key", sa.String(length=71), nullable=False),
sa.Column(
"occurred_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.CheckConstraint(
"event_type IN ('replay', 'auth_failure', 'payload_conflict')",
name="ck_financial_connector_operational_events_type",
),
sa.CheckConstraint(
"environment IN ('test', 'mock', 'staging', 'production')",
name="ck_financial_connector_operational_events_environment",
),
sa.CheckConstraint(
"length(trim(provider)) > 0 AND length(trim(reason_code)) > 0",
name="ck_financial_connector_operational_events_required_text",
),
sa.CheckConstraint(
"length(request_fingerprint) = 76 "
"AND request_fingerprint LIKE 'hmac-sha256:%' "
"AND length(external_event_fingerprint) = 76 "
"AND external_event_fingerprint LIKE 'hmac-sha256:%' "
"AND length(idempotency_key) = 71 "
"AND idempotency_key LIKE 'sha256:%'",
name="ck_financial_connector_operational_events_fingerprints",
),
sa.ForeignKeyConstraint(
["tenant_id", "config_id"],
["financial_connector_configs.tenant_id", "financial_connector_configs.id"],
name="fk_financial_connector_operational_events_tenant_config",
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id",
"id",
name="uq_financial_connector_operational_events_tenant_id",
),
sa.UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_financial_connector_operational_events_tenant_request",
),
)
op.create_index(
"ix_financial_connector_operational_events_tenant_config_time",
"financial_connector_operational_events",
["tenant_id", "config_id", "occurred_at"],
)
op.create_index(
"ix_financial_connector_operational_events_tenant_type_time",
"financial_connector_operational_events",
["tenant_id", "event_type", "occurred_at"],
)
op.create_index(
"ix_financial_connector_operational_events_tenant_provider_time",
"financial_connector_operational_events",
["tenant_id", "provider", "occurred_at"],
)
op.execute(
"""
CREATE TRIGGER trg_financial_connector_operational_events_append_only
BEFORE UPDATE OR DELETE ON financial_connector_operational_events
FOR EACH ROW EXECUTE FUNCTION reject_financial_connector_append_only_mutation()
"""
)
def downgrade() -> None:
_require_postgresql()
_require_empty_operational_events_for_downgrade()
op.execute(
"DROP TRIGGER IF EXISTS "
"trg_financial_connector_operational_events_append_only "
"ON financial_connector_operational_events"
)
op.drop_index(
"ix_financial_connector_operational_events_tenant_provider_time",
table_name="financial_connector_operational_events",
)
op.drop_index(
"ix_financial_connector_operational_events_tenant_type_time",
table_name="financial_connector_operational_events",
)
op.drop_index(
"ix_financial_connector_operational_events_tenant_config_time",
table_name="financial_connector_operational_events",
)
op.drop_table("financial_connector_operational_events")

View File

@@ -0,0 +1,206 @@
"""add blind negative audit samples and recall ground-truth labels
Revision ID: 20260716_0023
Revises: 20260716_0022
Create Date: 2026-07-16 23:50:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260716_0023"
down_revision: str | None = "20260716_0022"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_AUDIT_SAMPLE_TABLE = "agent_asset_release_audit_samples"
_LABEL_TABLE = "agent_asset_release_labels"
def _require_postgresql() -> None:
dialect_name = op.get_bind().dialect.name
if dialect_name != "postgresql":
raise RuntimeError(
"20260716_0023 only supports PostgreSQL; "
f"refusing to mutate {dialect_name} without transactional constraint DDL"
)
def _require_lossless_downgrade() -> None:
bind = op.get_bind()
audit_sample_count = int(
bind.scalar(sa.text(f"SELECT COUNT(*) FROM {_AUDIT_SAMPLE_TABLE}")) or 0
)
extended_label_count = int(
bind.scalar(
sa.text(
f"SELECT COUNT(*) FROM {_LABEL_TABLE} "
"WHERE label IN ('risk_present', 'risk_absent') "
"OR verification_source = 'blind_release_review'"
)
)
or 0
)
if audit_sample_count or extended_label_count:
raise RuntimeError(
"cannot downgrade release blind audit: immutable audit evidence exists "
f"(audit_samples={audit_sample_count}, extended_labels={extended_label_count})"
)
def _replace_label_constraints(*, expanded: bool) -> None:
if not expanded:
op.drop_constraint(
"ck_agent_asset_release_labels_semantics",
_LABEL_TABLE,
type_="check",
)
op.drop_constraint(
"ck_agent_asset_release_labels_label",
_LABEL_TABLE,
type_="check",
)
op.drop_constraint(
"ck_agent_asset_release_labels_source",
_LABEL_TABLE,
type_="check",
)
if expanded:
label_values = (
"label IN ('confirmed', 'false_positive', 'risk_present', 'risk_absent')"
)
source_values = (
"verification_source IN ('typed_risk_disposition', 'release_review', "
"'blind_release_review')"
)
else:
label_values = "label IN ('confirmed', 'false_positive')"
source_values = (
"verification_source IN ('typed_risk_disposition', 'release_review')"
)
op.create_check_constraint(
"ck_agent_asset_release_labels_label",
_LABEL_TABLE,
label_values,
)
op.create_check_constraint(
"ck_agent_asset_release_labels_source",
_LABEL_TABLE,
source_values,
)
if expanded:
op.create_check_constraint(
"ck_agent_asset_release_labels_semantics",
_LABEL_TABLE,
"(verification_source = 'blind_release_review' "
"AND label IN ('risk_present', 'risk_absent')) OR "
"(verification_source IN ('typed_risk_disposition', 'release_review') "
"AND label IN ('confirmed', 'false_positive'))",
)
def upgrade() -> None:
_require_postgresql()
_replace_label_constraints(expanded=True)
op.create_table(
_AUDIT_SAMPLE_TABLE,
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("observation_id", sa.String(length=36), nullable=False),
sa.Column("asset_id", sa.String(length=36), nullable=False),
sa.Column("release_id", sa.String(length=64), nullable=False),
sa.Column("stage", sa.String(length=16), nullable=False),
sa.Column("version", sa.String(length=30), nullable=False),
sa.Column("stratum", sa.String(length=40), nullable=False),
sa.Column("sampling_probability_ppm", sa.Integer(), nullable=False),
sa.Column("selection_score_ppm", sa.Integer(), nullable=False),
sa.Column("source_reference_encrypted", sa.Text(), nullable=False),
sa.Column("idempotency_key", sa.String(length=80), nullable=False),
sa.Column("payload_fingerprint", sa.String(length=64), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.CheckConstraint(
"stratum IN ('candidate_positive_census', "
"'candidate_disagreement_census', 'candidate_negative_random')",
name="ck_agent_asset_release_audit_samples_stratum",
),
sa.CheckConstraint(
"sampling_probability_ppm BETWEEN 1 AND 1000000",
name="ck_agent_asset_release_audit_samples_probability",
),
sa.CheckConstraint(
"selection_score_ppm BETWEEN 0 AND 999999",
name="ck_agent_asset_release_audit_samples_score",
),
sa.ForeignKeyConstraint(
[
"tenant_id",
"observation_id",
"asset_id",
"release_id",
"stage",
"version",
],
[
"agent_asset_release_observations.tenant_id",
"agent_asset_release_observations.id",
"agent_asset_release_observations.asset_id",
"agent_asset_release_observations.release_id",
"agent_asset_release_observations.stage",
"agent_asset_release_observations.version",
],
name="fk_agent_asset_release_audit_samples_observation",
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id",
"id",
name="uq_agent_asset_release_audit_samples_tenant_id",
),
sa.UniqueConstraint(
"tenant_id",
"observation_id",
name="uq_agent_asset_release_audit_samples_observation",
),
sa.UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_agent_asset_release_audit_samples_idempotency",
),
)
op.create_index(
"ix_agent_asset_release_audit_samples_release",
_AUDIT_SAMPLE_TABLE,
["tenant_id", "asset_id", "release_id", "stage", "version", "created_at"],
)
op.execute(
f"""
CREATE TRIGGER trg_{_AUDIT_SAMPLE_TABLE}_append_only
BEFORE UPDATE OR DELETE ON {_AUDIT_SAMPLE_TABLE}
FOR EACH ROW EXECUTE FUNCTION reject_agent_asset_release_telemetry_mutation();
"""
)
def downgrade() -> None:
_require_postgresql()
_require_lossless_downgrade()
op.execute(
f"DROP TRIGGER IF EXISTS trg_{_AUDIT_SAMPLE_TABLE}_append_only "
f"ON {_AUDIT_SAMPLE_TABLE}"
)
op.drop_index(
"ix_agent_asset_release_audit_samples_release",
table_name=_AUDIT_SAMPLE_TABLE,
)
op.drop_table(_AUDIT_SAMPLE_TABLE)
_replace_label_constraints(expanded=False)

View File

@@ -0,0 +1,82 @@
"""expand commercial runtime resource quantity bases
Revision ID: 20260717_0024
Revises: 20260716_0023
Create Date: 2026-07-17 09:30:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260717_0024"
down_revision: str | None = "20260716_0023"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_CONSTRAINT = "ck_commercial_runtime_reservations_basis"
_LEGACY_BASES = (
"quantity_basis IN ('call', 'input_tokens', 'output_tokens', "
"'total_tokens', 'duration_ms')"
)
_RESOURCE_BASES = (
"quantity_basis IN ('call', 'input_tokens', 'output_tokens', "
"'total_tokens', 'duration_ms', 'bytes', 'pages', 'objects', 'events')"
)
def _require_postgresql() -> None:
dialect_name = op.get_bind().dialect.name
if dialect_name != "postgresql":
raise RuntimeError(
"20260717_0024 only supports PostgreSQL; "
f"refusing to mutate {dialect_name} without transactional constraint DDL"
)
def _require_no_resource_reservations_for_downgrade() -> None:
count = int(
op.get_bind().scalar(
sa.text(
"SELECT COUNT(*) FROM commercial_runtime_reservations "
"WHERE quantity_basis IN ('bytes', 'pages', 'objects', 'events')"
)
)
or 0
)
if count:
raise RuntimeError(
"cannot downgrade commercial resource quantity bases: "
f"resource reservations exist ({count})"
)
def upgrade() -> None:
_require_postgresql()
op.drop_constraint(
_CONSTRAINT,
"commercial_runtime_reservations",
type_="check",
)
op.create_check_constraint(
_CONSTRAINT,
"commercial_runtime_reservations",
_RESOURCE_BASES,
)
def downgrade() -> None:
_require_postgresql()
_require_no_resource_reservations_for_downgrade()
op.drop_constraint(
_CONSTRAINT,
"commercial_runtime_reservations",
type_="check",
)
op.create_check_constraint(
_CONSTRAINT,
"commercial_runtime_reservations",
_LEGACY_BASES,
)

View File

@@ -0,0 +1,639 @@
"""establish trusted tenant identity foundation
Revision ID: 20260717_0025
Revises: 20260717_0024
Create Date: 2026-07-17 10:20:00
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260717_0025"
down_revision: str | None = "20260717_0024"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_DEFAULT_TENANT = "default"
_TENANT_SOURCE_TABLES = (
"auth_sessions",
"expense_cases",
"commercial_billing_periods",
"tenant_subscriptions",
"financial_connector_configs",
"agent_asset_release_observations",
)
def _require_postgresql() -> None:
dialect_name = op.get_bind().dialect.name
if dialect_name != "postgresql":
raise RuntimeError(
"20260717_0025 only supports PostgreSQL; "
f"refusing to mutate {dialect_name} without transactional constraint DDL"
)
def _inspector() -> sa.Inspector:
return sa.inspect(op.get_bind())
def _has_table(table_name: str) -> bool:
return _inspector().has_table(table_name)
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
return column_name in {
str(item["name"]) for item in _inspector().get_columns(table_name)
}
def _constraint_exists(table_name: str, constraint_name: str) -> bool:
inspector = _inspector()
names = {
str(item.get("name") or "")
for item in (
*inspector.get_unique_constraints(table_name),
*inspector.get_foreign_keys(table_name),
*inspector.get_check_constraints(table_name),
)
}
return constraint_name in names
def _index_exists(table_name: str, index_name: str) -> bool:
return index_name in {
str(item.get("name") or "") for item in _inspector().get_indexes(table_name)
}
def _drop_unique_for_columns(table_name: str, columns: tuple[str, ...]) -> None:
if not _has_table(table_name):
return
for item in _inspector().get_unique_constraints(table_name):
if tuple(item.get("column_names") or ()) != columns:
continue
name = str(item.get("name") or "")
if name:
op.drop_constraint(name, table_name, type_="unique")
def _drop_foreign_keys_for_columns(
table_name: str,
columns: tuple[str, ...],
) -> None:
if not _has_table(table_name):
return
for item in _inspector().get_foreign_keys(table_name):
if tuple(item.get("constrained_columns") or ()) != columns:
continue
name = str(item.get("name") or "")
if name:
op.drop_constraint(name, table_name, type_="foreignkey")
def _ensure_unique(
table_name: str,
name: str,
columns: tuple[str, ...],
) -> None:
if not _constraint_exists(table_name, name):
op.create_unique_constraint(name, table_name, list(columns))
def _ensure_index(
table_name: str,
name: str,
columns: tuple[str, ...],
) -> None:
if not _index_exists(table_name, name):
op.create_index(name, table_name, list(columns))
def _ensure_tenant_column(table_name: str) -> None:
if not _has_table(table_name):
return
if not _has_column(table_name, "tenant_id"):
op.add_column(
table_name,
sa.Column("tenant_id", sa.String(length=64), nullable=True),
)
if table_name == "expense_claims" and _has_table("expense_case_links"):
op.execute(
sa.text(
"UPDATE expense_claims AS claim SET tenant_id = COALESCE(("
"SELECT link.tenant_id FROM expense_case_links AS link "
"WHERE link.resource_type = 'expense_claim' "
"AND link.resource_id = claim.id ORDER BY link.created_at ASC LIMIT 1"
"), :default_tenant) WHERE claim.tenant_id IS NULL"
).bindparams(default_tenant=_DEFAULT_TENANT)
)
else:
op.execute(
sa.text(
f"UPDATE {table_name} SET tenant_id = :default_tenant "
"WHERE tenant_id IS NULL"
).bindparams(default_tenant=_DEFAULT_TENANT)
)
op.alter_column(
table_name,
"tenant_id",
existing_type=sa.String(length=64),
nullable=False,
server_default=None,
)
def _create_tenant_registry() -> None:
if not _has_table("tenants"):
op.create_table(
"tenants",
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("tenant_code", sa.String(length=64), nullable=False),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
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 ('active', 'suspended', 'disabled')",
name="ck_tenants_status",
),
sa.CheckConstraint(
"length(trim(tenant_id)) > 0 AND length(trim(tenant_code)) > 0 "
"AND length(trim(name)) > 0",
name="ck_tenants_identity",
),
sa.PrimaryKeyConstraint("tenant_id"),
sa.UniqueConstraint("tenant_code", name="uq_tenants_tenant_code"),
)
op.create_index("ix_tenants_status", "tenants", ["status"])
op.execute(
sa.text(
"INSERT INTO tenants (tenant_id, tenant_code, name, status) VALUES "
"('default', 'default', '默认企业', 'active'), "
"('platform', 'platform', '平台管理域', 'active') "
"ON CONFLICT DO NOTHING"
)
)
for table_name in _TENANT_SOURCE_TABLES:
if not _has_column(table_name, "tenant_id"):
continue
op.execute(
sa.text(
f"INSERT INTO tenants (tenant_id, tenant_code, name, status) "
f"SELECT DISTINCT trim(tenant_id), trim(tenant_id), trim(tenant_id), "
f"'active' FROM {table_name} "
"WHERE tenant_id IS NOT NULL AND length(trim(tenant_id)) > 0 "
"ON CONFLICT DO NOTHING"
)
)
def _tenantize_organizations() -> None:
table = "organization_units"
if not _has_table(table):
return
_ensure_tenant_column(table)
_drop_unique_for_columns(table, ("unit_code",))
_drop_foreign_keys_for_columns(table, ("parent_id",))
_ensure_unique(table, "uq_organization_units_tenant_id", ("tenant_id", "id"))
_ensure_unique(
table,
"uq_organization_units_tenant_code",
("tenant_id", "unit_code"),
)
if not _constraint_exists(table, "fk_organization_units_tenant"):
op.create_foreign_key(
"fk_organization_units_tenant",
table,
"tenants",
["tenant_id"],
["tenant_id"],
ondelete="RESTRICT",
)
if not _constraint_exists(table, "fk_organization_units_tenant_parent"):
op.create_foreign_key(
"fk_organization_units_tenant_parent",
table,
table,
["tenant_id", "parent_id"],
["tenant_id", "id"],
ondelete="RESTRICT",
)
_ensure_index(
table,
"ix_organization_units_tenant_name",
("tenant_id", "name"),
)
def _tenantize_employees() -> None:
table = "employees"
if not _has_table(table):
return
_ensure_tenant_column(table)
_drop_unique_for_columns(table, ("employee_no",))
_drop_unique_for_columns(table, ("email",))
_drop_foreign_keys_for_columns(table, ("organization_unit_id",))
_drop_foreign_keys_for_columns(table, ("manager_id",))
_ensure_unique(table, "uq_employees_tenant_id", ("tenant_id", "id"))
_ensure_unique(
table,
"uq_employees_tenant_employee_no",
("tenant_id", "employee_no"),
)
_ensure_unique(table, "uq_employees_tenant_email", ("tenant_id", "email"))
if not _constraint_exists(table, "fk_employees_tenant"):
op.create_foreign_key(
"fk_employees_tenant",
table,
"tenants",
["tenant_id"],
["tenant_id"],
ondelete="RESTRICT",
)
if _has_table("organization_units") and not _constraint_exists(
table,
"fk_employees_tenant_organization_unit",
):
op.create_foreign_key(
"fk_employees_tenant_organization_unit",
table,
"organization_units",
["tenant_id", "organization_unit_id"],
["tenant_id", "id"],
ondelete="RESTRICT",
)
if not _constraint_exists(table, "fk_employees_tenant_manager"):
op.create_foreign_key(
"fk_employees_tenant_manager",
table,
table,
["tenant_id", "manager_id"],
["tenant_id", "id"],
ondelete="RESTRICT",
)
_ensure_index(table, "ix_employees_tenant_status", ("tenant_id", "employment_status"))
_ensure_index(table, "ix_employees_tenant_name", ("tenant_id", "name"))
def _tenantize_financial_records() -> None:
if _has_table("expense_claims"):
table = "expense_claims"
_ensure_tenant_column(table)
_drop_unique_for_columns(table, ("claim_no",))
_drop_foreign_keys_for_columns(table, ("employee_id",))
_drop_foreign_keys_for_columns(table, ("department_id",))
_ensure_unique(table, "uq_expense_claims_tenant_id", ("tenant_id", "id"))
_ensure_unique(
table,
"uq_expense_claims_tenant_claim_no",
("tenant_id", "claim_no"),
)
if not _constraint_exists(table, "fk_expense_claims_tenant"):
op.create_foreign_key(
"fk_expense_claims_tenant",
table,
"tenants",
["tenant_id"],
["tenant_id"],
ondelete="RESTRICT",
)
if _has_table("employees") and not _constraint_exists(
table,
"fk_expense_claims_tenant_employee",
):
op.create_foreign_key(
"fk_expense_claims_tenant_employee",
table,
"employees",
["tenant_id", "employee_id"],
["tenant_id", "id"],
ondelete="RESTRICT",
)
if _has_table("organization_units") and not _constraint_exists(
table,
"fk_expense_claims_tenant_department",
):
op.create_foreign_key(
"fk_expense_claims_tenant_department",
table,
"organization_units",
["tenant_id", "department_id"],
["tenant_id", "id"],
ondelete="RESTRICT",
)
_ensure_index(table, "ix_expense_claims_tenant_status", ("tenant_id", "status"))
_ensure_index(
table,
"ix_expense_claims_tenant_occurred",
("tenant_id", "occurred_at"),
)
for table, number_column, constraint_name, dimension_column, index_name in (
(
"accounts_receivable",
"receivable_no",
"uq_accounts_receivable_tenant_no",
"customer_id",
"ix_accounts_receivable_tenant_customer",
),
(
"accounts_payable",
"payable_no",
"uq_accounts_payable_tenant_no",
"vendor_id",
"ix_accounts_payable_tenant_vendor",
),
):
if not _has_table(table):
continue
_ensure_tenant_column(table)
_drop_unique_for_columns(table, (number_column,))
_ensure_unique(table, constraint_name, ("tenant_id", number_column))
tenant_fk = f"fk_{table}_tenant"
if not _constraint_exists(table, tenant_fk):
op.create_foreign_key(
tenant_fk,
table,
"tenants",
["tenant_id"],
["tenant_id"],
ondelete="RESTRICT",
)
_ensure_index(table, index_name, ("tenant_id", dimension_column))
def _ensure_memberships() -> None:
if not _has_table("employees"):
return
if not _has_table("tenant_memberships"):
op.create_table(
"tenant_memberships",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("employee_id", sa.String(length=36), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("is_primary", sa.Boolean(), nullable=False),
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 ('active', 'inactive')",
name="ck_tenant_memberships_status",
),
sa.ForeignKeyConstraint(
["tenant_id"],
["tenants.tenant_id"],
name="fk_tenant_memberships_tenant",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "employee_id"],
["employees.tenant_id", "employees.id"],
name="fk_tenant_memberships_tenant_employee",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id",
"employee_id",
name="uq_tenant_memberships_tenant_employee",
),
)
op.create_index(
"ix_tenant_memberships_employee_active",
"tenant_memberships",
["employee_id", "status"],
)
op.create_index(
"ix_tenant_memberships_tenant_active",
"tenant_memberships",
["tenant_id", "status"],
)
op.execute(
sa.text(
"INSERT INTO tenant_memberships "
"(id, tenant_id, employee_id, status, is_primary) "
"SELECT gen_random_uuid()::text, employee.tenant_id, employee.id, "
"CASE WHEN employee.employment_status = '停用' THEN 'inactive' "
"ELSE 'active' END, true FROM employees AS employee "
"ON CONFLICT (tenant_id, employee_id) DO NOTHING"
)
)
def _bind_auth_sessions() -> None:
if not _has_table("auth_sessions"):
return
if not _constraint_exists("auth_sessions", "fk_auth_sessions_tenant"):
op.create_foreign_key(
"fk_auth_sessions_tenant",
"auth_sessions",
"tenants",
["tenant_id"],
["tenant_id"],
ondelete="RESTRICT",
)
def upgrade() -> None:
_require_postgresql()
_create_tenant_registry()
_tenantize_organizations()
_tenantize_employees()
_tenantize_financial_records()
_ensure_memberships()
_bind_auth_sessions()
def _require_safe_downgrade() -> None:
for table_name in (
"organization_units",
"employees",
"expense_claims",
"accounts_receivable",
"accounts_payable",
):
if not _has_column(table_name, "tenant_id"):
continue
count = int(
op.get_bind().scalar(
sa.text(
f"SELECT COUNT(*) FROM {table_name} "
"WHERE tenant_id <> :default_tenant"
).bindparams(default_tenant=_DEFAULT_TENANT)
)
or 0
)
if count:
raise RuntimeError(
"cannot downgrade tenant identity foundation: "
f"{table_name} contains non-default tenant data ({count})"
)
def _restore_legacy_financial_constraints() -> None:
if _has_table("expense_claims"):
table = "expense_claims"
for name in (
"fk_expense_claims_tenant_department",
"fk_expense_claims_tenant_employee",
"fk_expense_claims_tenant",
"uq_expense_claims_tenant_claim_no",
"uq_expense_claims_tenant_id",
):
if _constraint_exists(table, name):
op.drop_constraint(name, table)
for name in (
"ix_expense_claims_tenant_occurred",
"ix_expense_claims_tenant_status",
):
if _index_exists(table, name):
op.drop_index(name, table_name=table)
_ensure_unique(table, "uq_expense_claims_claim_no", ("claim_no",))
if _has_table("employees"):
op.create_foreign_key(
"fk_expense_claims_employee_id",
table,
"employees",
["employee_id"],
["id"],
)
if _has_table("organization_units"):
op.create_foreign_key(
"fk_expense_claims_department_id",
table,
"organization_units",
["department_id"],
["id"],
)
op.drop_column(table, "tenant_id")
for table, constraint_name, number_column, index_name in (
(
"accounts_receivable",
"uq_accounts_receivable_tenant_no",
"receivable_no",
"ix_accounts_receivable_tenant_customer",
),
(
"accounts_payable",
"uq_accounts_payable_tenant_no",
"payable_no",
"ix_accounts_payable_tenant_vendor",
),
):
if not _has_column(table, "tenant_id"):
continue
tenant_fk = f"fk_{table}_tenant"
if _constraint_exists(table, tenant_fk):
op.drop_constraint(tenant_fk, table, type_="foreignkey")
if _constraint_exists(table, constraint_name):
op.drop_constraint(constraint_name, table, type_="unique")
if _index_exists(table, index_name):
op.drop_index(index_name, table_name=table)
_ensure_unique(table, f"uq_{table}_{number_column}", (number_column,))
op.drop_column(table, "tenant_id")
def _restore_legacy_employee_constraints() -> None:
if _has_table("employees"):
table = "employees"
for name in (
"fk_employees_tenant_manager",
"fk_employees_tenant_organization_unit",
"fk_employees_tenant",
"uq_employees_tenant_email",
"uq_employees_tenant_employee_no",
"uq_employees_tenant_id",
):
if _constraint_exists(table, name):
op.drop_constraint(name, table)
for name in ("ix_employees_tenant_name", "ix_employees_tenant_status"):
if _index_exists(table, name):
op.drop_index(name, table_name=table)
_ensure_unique(table, "uq_employees_employee_no", ("employee_no",))
_ensure_unique(table, "uq_employees_email", ("email",))
if _has_table("organization_units"):
op.create_foreign_key(
"fk_employees_organization_unit_id",
table,
"organization_units",
["organization_unit_id"],
["id"],
)
op.create_foreign_key(
"fk_employees_manager_id",
table,
table,
["manager_id"],
["id"],
)
op.drop_column(table, "tenant_id")
if _has_table("organization_units"):
table = "organization_units"
for name in (
"fk_organization_units_tenant_parent",
"fk_organization_units_tenant",
"uq_organization_units_tenant_code",
"uq_organization_units_tenant_id",
):
if _constraint_exists(table, name):
op.drop_constraint(name, table)
if _index_exists(table, "ix_organization_units_tenant_name"):
op.drop_index("ix_organization_units_tenant_name", table_name=table)
_ensure_unique(table, "uq_organization_units_unit_code", ("unit_code",))
op.create_foreign_key(
"fk_organization_units_parent_id",
table,
table,
["parent_id"],
["id"],
)
op.drop_column(table, "tenant_id")
def downgrade() -> None:
_require_postgresql()
_require_safe_downgrade()
if _has_table("auth_sessions") and _constraint_exists(
"auth_sessions",
"fk_auth_sessions_tenant",
):
op.drop_constraint(
"fk_auth_sessions_tenant",
"auth_sessions",
type_="foreignkey",
)
if _has_table("tenant_memberships"):
op.drop_table("tenant_memberships")
_restore_legacy_financial_constraints()
_restore_legacy_employee_constraints()
if _has_table("tenants"):
if _index_exists("tenants", "ix_tenants_status"):
op.drop_index("ix_tenants_status", table_name="tenants")
op.drop_table("tenants")

View File

@@ -0,0 +1,444 @@
"""add structural tenant and platform scope to Agent assets
Revision ID: 20260717_0026
Revises: 20260717_0025
Create Date: 2026-07-17 14:30:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260717_0026"
down_revision: str | None = "20260717_0025"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_ASSET_TABLE = "agent_assets"
_ASSET_CHILD_TABLES = (
"agent_asset_versions",
"agent_asset_reviews",
"agent_asset_test_runs",
"agent_asset_rule_feedback",
)
_OWNERSHIP_CHILD_TABLES = (
"agent_asset_versions",
"agent_asset_reviews",
)
_ONLYOFFICE_SESSION_TABLE = "agent_asset_onlyoffice_sessions"
def _require_postgresql() -> None:
dialect_name = op.get_bind().dialect.name
if dialect_name != "postgresql":
raise RuntimeError(
"20260717_0026 only supports PostgreSQL; "
f"refusing to mutate {dialect_name} without transactional constraint DDL"
)
def _has_complete_agent_asset_schema() -> bool:
inspector = sa.inspect(op.get_bind())
table_names = (_ASSET_TABLE, *_ASSET_CHILD_TABLES)
existing = {table_name for table_name in table_names if inspector.has_table(table_name)}
if not existing:
return False
missing = set(table_names) - existing
if missing:
raise RuntimeError(
"cannot migrate partial Agent asset schema; missing tables: "
+ ", ".join(sorted(missing))
)
return True
def _create_onlyoffice_session_table() -> None:
if sa.inspect(op.get_bind()).has_table(_ONLYOFFICE_SESSION_TABLE):
return
op.create_table(
_ONLYOFFICE_SESSION_TABLE,
sa.Column("jti", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("resource_scope", sa.String(length=16), nullable=False),
sa.Column("asset_id", sa.String(length=100), nullable=False),
sa.Column("document_key", sa.String(length=200), nullable=False),
sa.Column("document_version", sa.String(length=30), nullable=False),
sa.Column("document_fingerprint", sa.String(length=160), nullable=False),
sa.Column("audience", sa.String(length=80), nullable=False),
sa.Column("writable", sa.Boolean(), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("actor", sa.String(length=160), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("failure_reason", sa.Text(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.CheckConstraint(
"(resource_scope = 'platform' AND tenant_id = 'platform') OR "
"(resource_scope = 'tenant' AND tenant_id <> 'platform')",
name="ck_agent_asset_onlyoffice_sessions_scope_tenant",
),
sa.CheckConstraint(
"status IN ('active', 'processing', 'consumed', 'failed', 'revoked')",
name="ck_agent_asset_onlyoffice_sessions_status",
),
sa.CheckConstraint(
"(status = 'active' AND claimed_at IS NULL AND consumed_at IS NULL) OR "
"(status IN ('processing', 'failed') AND claimed_at IS NOT NULL "
"AND consumed_at IS NULL) OR "
"(status = 'consumed' AND claimed_at IS NOT NULL AND consumed_at IS NOT NULL) OR "
"(status = 'revoked' AND consumed_at IS NULL)",
name="ck_agent_asset_onlyoffice_sessions_lifecycle",
),
sa.ForeignKeyConstraint(
["tenant_id"],
["tenants.tenant_id"],
name="fk_agent_asset_onlyoffice_sessions_tenant",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("jti"),
)
op.create_index(
"ix_agent_asset_onlyoffice_sessions_tenant_asset",
_ONLYOFFICE_SESSION_TABLE,
["tenant_id", "resource_scope", "asset_id", "created_at"],
)
op.create_index(
"ix_agent_asset_onlyoffice_sessions_status_expiry",
_ONLYOFFICE_SESSION_TABLE,
["status", "expires_at"],
)
def _add_scope_columns(table_name: str) -> None:
op.add_column(table_name, sa.Column("tenant_id", sa.String(length=64), nullable=True))
op.add_column(table_name, sa.Column("scope", sa.String(length=16), nullable=True))
def _assert_known_asset_tenants() -> None:
unknown = int(
op.get_bind().scalar(
sa.text(
"""
SELECT COUNT(*)
FROM agent_assets AS asset
LEFT JOIN tenants AS tenant
ON tenant.tenant_id = NULLIF(BTRIM(asset.config_json ->> 'tenant_id'), '')
WHERE NULLIF(BTRIM(asset.config_json ->> 'tenant_id'), '') IS NOT NULL
AND tenant.tenant_id IS NULL
"""
)
)
or 0
)
if unknown:
raise RuntimeError(
"cannot migrate Agent assets: config_json contains tenant ids absent from tenants "
f"(unknown_assets={unknown})"
)
def _backfill_scope() -> None:
_assert_known_asset_tenants()
op.execute(
"""
UPDATE agent_assets
SET tenant_id = COALESCE(
NULLIF(BTRIM(config_json ->> 'tenant_id'), ''),
'platform'
),
scope = CASE
WHEN NULLIF(BTRIM(config_json ->> 'tenant_id'), '') IS NULL
THEN 'platform'
ELSE 'tenant'
END
"""
)
for table_name in _OWNERSHIP_CHILD_TABLES:
op.execute(
f"""
UPDATE {table_name} AS child
SET tenant_id = asset.tenant_id,
scope = asset.scope
FROM agent_assets AS asset
WHERE child.asset_id = asset.id
"""
)
op.execute(
"""
UPDATE agent_asset_test_runs AS child
SET tenant_id = COALESCE(
NULLIF(BTRIM(child.input_json ->> 'target_tenant_id'), ''),
NULLIF(BTRIM(asset.config_json ->> 'tenant_id'), ''),
asset.tenant_id
),
scope = CASE
WHEN COALESCE(
NULLIF(BTRIM(child.input_json ->> 'target_tenant_id'), ''),
NULLIF(BTRIM(asset.config_json ->> 'tenant_id'), ''),
asset.tenant_id
) = 'platform' THEN 'platform'
ELSE 'tenant'
END
FROM agent_assets AS asset
WHERE child.asset_id = asset.id
"""
)
op.execute(
"""
UPDATE agent_asset_rule_feedback AS child
SET tenant_id = COALESCE(
NULLIF(BTRIM(asset.config_json ->> 'tenant_id'), ''),
asset.tenant_id
),
scope = CASE
WHEN COALESCE(
NULLIF(BTRIM(asset.config_json ->> 'tenant_id'), ''),
asset.tenant_id
) = 'platform' THEN 'platform'
ELSE 'tenant'
END
FROM agent_assets AS asset
WHERE child.asset_id = asset.id
"""
)
def _set_scope_not_null_and_defaults(table_name: str) -> None:
op.alter_column(
table_name,
"tenant_id",
existing_type=sa.String(length=64),
nullable=False,
server_default="platform",
)
op.alter_column(
table_name,
"scope",
existing_type=sa.String(length=16),
nullable=False,
server_default="platform",
)
def _assert_known_scoped_tenants() -> None:
bind = op.get_bind()
for table_name in (_ASSET_TABLE, *_ASSET_CHILD_TABLES):
unknown = int(
bind.scalar(
sa.text(
f"""
SELECT COUNT(*)
FROM {table_name} AS scoped
LEFT JOIN tenants AS tenant ON tenant.tenant_id = scoped.tenant_id
WHERE tenant.tenant_id IS NULL
"""
)
)
or 0
)
if unknown:
raise RuntimeError(
f"cannot migrate {table_name}: scoped tenant is absent from tenants "
f"(unknown_rows={unknown})"
)
def _create_scope_check(table_name: str) -> None:
op.create_check_constraint(
f"ck_{table_name}_scope_tenant",
table_name,
"(scope = 'platform' AND tenant_id = 'platform') OR "
"(scope = 'tenant' AND tenant_id <> 'platform')",
)
def _drop_legacy_code_uniqueness() -> None:
op.execute("ALTER TABLE agent_assets DROP CONSTRAINT IF EXISTS agent_assets_code_key")
op.execute("DROP INDEX IF EXISTS ix_agent_assets_code")
op.create_index("ix_agent_assets_code", _ASSET_TABLE, ["code"], unique=False)
def upgrade() -> None:
_require_postgresql()
has_agent_asset_schema = _has_complete_agent_asset_schema()
_create_onlyoffice_session_table()
if not has_agent_asset_schema:
return
_add_scope_columns(_ASSET_TABLE)
for table_name in _ASSET_CHILD_TABLES:
_add_scope_columns(table_name)
_backfill_scope()
_set_scope_not_null_and_defaults(_ASSET_TABLE)
for table_name in _ASSET_CHILD_TABLES:
_set_scope_not_null_and_defaults(table_name)
_assert_known_scoped_tenants()
_drop_legacy_code_uniqueness()
op.create_unique_constraint(
"uq_agent_assets_tenant_scope_id",
_ASSET_TABLE,
["tenant_id", "scope", "id"],
)
op.create_unique_constraint(
"uq_agent_assets_tenant_scope_code",
_ASSET_TABLE,
["tenant_id", "scope", "code"],
)
op.create_foreign_key(
"fk_agent_assets_tenant",
_ASSET_TABLE,
"tenants",
["tenant_id"],
["tenant_id"],
ondelete="RESTRICT",
)
_create_scope_check(_ASSET_TABLE)
op.create_index(
"ix_agent_assets_scope_tenant",
_ASSET_TABLE,
["scope", "tenant_id"],
)
for table_name in _ASSET_CHILD_TABLES:
op.create_foreign_key(
f"fk_{table_name}_tenant",
table_name,
"tenants",
["tenant_id"],
["tenant_id"],
ondelete="RESTRICT",
)
_create_scope_check(table_name)
op.create_index(
f"ix_{table_name}_tenant_asset",
table_name,
["tenant_id", "scope", "asset_id"],
)
for table_name in _OWNERSHIP_CHILD_TABLES:
op.create_foreign_key(
f"fk_{table_name}_tenant_asset",
table_name,
_ASSET_TABLE,
["tenant_id", "scope", "asset_id"],
["tenant_id", "scope", "id"],
ondelete="CASCADE",
)
def _require_lossless_downgrade() -> None:
bind = op.get_bind()
tenant_assets = int(
bind.scalar(
sa.text("SELECT COUNT(*) FROM agent_assets WHERE scope = 'tenant'")
)
or 0
)
tenant_evidence = sum(
int(
bind.scalar(
sa.text(f"SELECT COUNT(*) FROM {table_name} WHERE scope = 'tenant'")
)
or 0
)
for table_name in ("agent_asset_test_runs", "agent_asset_rule_feedback")
)
if tenant_assets or tenant_evidence:
raise RuntimeError(
"cannot downgrade Agent asset tenant security: tenant-owned facts exist "
f"(tenant_assets={tenant_assets}, tenant_evidence={tenant_evidence})"
)
def _require_no_onlyoffice_sessions() -> None:
if not sa.inspect(op.get_bind()).has_table(_ONLYOFFICE_SESSION_TABLE):
return
session_count = int(
op.get_bind().scalar(
sa.text(f"SELECT COUNT(*) FROM {_ONLYOFFICE_SESSION_TABLE}")
)
or 0
)
if session_count:
raise RuntimeError(
"cannot downgrade Agent asset tenant security: ONLYOFFICE session evidence "
f"exists (sessions={session_count})"
)
def _drop_onlyoffice_session_table() -> None:
if not sa.inspect(op.get_bind()).has_table(_ONLYOFFICE_SESSION_TABLE):
return
op.drop_index(
"ix_agent_asset_onlyoffice_sessions_status_expiry",
table_name=_ONLYOFFICE_SESSION_TABLE,
)
op.drop_index(
"ix_agent_asset_onlyoffice_sessions_tenant_asset",
table_name=_ONLYOFFICE_SESSION_TABLE,
)
op.drop_table(_ONLYOFFICE_SESSION_TABLE)
def _drop_constraint_if_exists(
table_name: str,
constraint_name: str,
) -> None:
"""删除本迁移负责的约束,兼容模型建表产生的不同外键名称。"""
preparer = op.get_bind().dialect.identifier_preparer
op.execute(
sa.text(
f"ALTER TABLE {preparer.quote(table_name)} "
f"DROP CONSTRAINT IF EXISTS {preparer.quote(constraint_name)}"
)
)
def downgrade() -> None:
_require_postgresql()
has_agent_asset_schema = _has_complete_agent_asset_schema()
_require_no_onlyoffice_sessions()
_drop_onlyoffice_session_table()
if not has_agent_asset_schema:
return
_require_lossless_downgrade()
for table_name in reversed(_OWNERSHIP_CHILD_TABLES):
_drop_constraint_if_exists(
table_name,
f"fk_{table_name}_tenant_asset",
)
for table_name in reversed(_ASSET_CHILD_TABLES):
_drop_constraint_if_exists(
table_name,
f"fk_{table_name}_tenant",
)
op.drop_index(f"ix_{table_name}_tenant_asset", table_name=table_name)
_drop_constraint_if_exists(
table_name,
f"ck_{table_name}_scope_tenant",
)
op.drop_index("ix_agent_assets_scope_tenant", table_name=_ASSET_TABLE)
_drop_constraint_if_exists(_ASSET_TABLE, "ck_agent_assets_scope_tenant")
_drop_constraint_if_exists(_ASSET_TABLE, "fk_agent_assets_tenant")
_drop_constraint_if_exists(
_ASSET_TABLE,
"uq_agent_assets_tenant_scope_code",
)
_drop_constraint_if_exists(
_ASSET_TABLE,
"uq_agent_assets_tenant_scope_id",
)
op.drop_index("ix_agent_assets_code", table_name=_ASSET_TABLE)
op.create_index("ix_agent_assets_code", _ASSET_TABLE, ["code"], unique=True)
for table_name in reversed(_ASSET_CHILD_TABLES):
op.drop_column(table_name, "scope")
op.drop_column(table_name, "tenant_id")
op.drop_column(_ASSET_TABLE, "scope")
op.drop_column(_ASSET_TABLE, "tenant_id")

View File

@@ -0,0 +1,122 @@
"""add tenant-bound one-time OnlyOffice sessions
Revision ID: 20260717_0027
Revises: 20260717_0026
Create Date: 2026-07-17 10:00:00
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260717_0027"
down_revision: str | None = "20260717_0026"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _require_postgresql() -> None:
dialect_name = op.get_bind().dialect.name
if dialect_name != "postgresql":
raise RuntimeError(
"20260717_0027 only supports PostgreSQL; "
f"refusing to mutate {dialect_name} without transactional constraint DDL"
)
def _require_empty_for_downgrade() -> None:
count = int(
op.get_bind().scalar(
sa.text("SELECT COUNT(*) FROM knowledge_onlyoffice_sessions")
)
or 0
)
if count:
raise RuntimeError(
"cannot downgrade knowledge tenant security: OnlyOffice session evidence exists "
f"(knowledge_onlyoffice_sessions={count})"
)
def upgrade() -> None:
_require_postgresql()
op.create_table(
"knowledge_onlyoffice_sessions",
sa.Column("jti", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("resource_scope", sa.String(length=16), nullable=False),
sa.Column("document_id", sa.String(length=64), nullable=False),
sa.Column("document_key", sa.String(length=160), nullable=False),
sa.Column("document_version", sa.Integer(), nullable=False),
sa.Column("audience", sa.String(length=80), nullable=False),
sa.Column("editable", sa.Boolean(), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("created_by", sa.String(length=100), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("failure_reason", sa.Text(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.CheckConstraint(
"resource_scope IN ('tenant', 'platform')",
name="ck_knowledge_onlyoffice_sessions_scope",
),
sa.CheckConstraint(
"status IN ('active', 'processing', 'consumed', 'failed', 'revoked')",
name="ck_knowledge_onlyoffice_sessions_status",
),
sa.CheckConstraint(
"tenant_id IS NOT NULL AND "
"(resource_scope = 'tenant' OR "
"(resource_scope = 'platform' AND editable = false))",
name="ck_knowledge_onlyoffice_sessions_scope_tenant",
),
sa.CheckConstraint(
"(status = 'active' AND claimed_at IS NULL AND consumed_at IS NULL) OR "
"(status IN ('processing', 'failed') AND claimed_at IS NOT NULL "
"AND consumed_at IS NULL) OR "
"(status = 'consumed' AND claimed_at IS NOT NULL AND consumed_at IS NOT NULL) OR "
"(status = 'revoked' AND consumed_at IS NULL)",
name="ck_knowledge_onlyoffice_sessions_lifecycle",
),
sa.ForeignKeyConstraint(
["tenant_id"],
["tenants.tenant_id"],
name="fk_knowledge_onlyoffice_sessions_tenant",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("jti"),
)
op.create_index(
"ix_knowledge_onlyoffice_sessions_tenant_document",
"knowledge_onlyoffice_sessions",
["tenant_id", "document_id", "created_at"],
)
op.create_index(
"ix_knowledge_onlyoffice_sessions_status_expiry",
"knowledge_onlyoffice_sessions",
["status", "expires_at"],
)
def downgrade() -> None:
_require_postgresql()
_require_empty_for_downgrade()
op.drop_index(
"ix_knowledge_onlyoffice_sessions_status_expiry",
table_name="knowledge_onlyoffice_sessions",
)
op.drop_index(
"ix_knowledge_onlyoffice_sessions_tenant_document",
table_name="knowledge_onlyoffice_sessions",
)
op.drop_table("knowledge_onlyoffice_sessions")

View File

@@ -0,0 +1,484 @@
"""tenant-scope Hermes profiles, reports, and scheduled work
Revision ID: 20260717_0028
Revises: 20260717_0027
Create Date: 2026-07-17
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260717_0028"
down_revision: str | None = "20260717_0027"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _require_postgresql() -> None:
dialect_name = op.get_bind().dialect.name
if dialect_name != "postgresql":
raise RuntimeError(
"20260717_0028 only supports PostgreSQL; "
f"refusing to mutate {dialect_name} without transactional constraint DDL"
)
def _inspector() -> sa.Inspector:
return sa.inspect(op.get_bind())
def _has_table(table_name: str) -> bool:
return _inspector().has_table(table_name)
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
return column_name in {str(item["name"]) for item in _inspector().get_columns(table_name)}
def _constraint_exists(table_name: str, constraint_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = _inspector()
names = {
str(item.get("name") or "")
for item in (
*inspector.get_unique_constraints(table_name),
*inspector.get_foreign_keys(table_name),
*inspector.get_check_constraints(table_name),
)
}
return constraint_name in names
def _index_exists(table_name: str, index_name: str) -> bool:
if not _has_table(table_name):
return False
return index_name in {
str(item.get("name") or "") for item in _inspector().get_indexes(table_name)
}
def _drop_foreign_keys_for_columns(
table_name: str,
columns: tuple[str, ...],
) -> None:
if not _has_table(table_name):
return
for item in _inspector().get_foreign_keys(table_name):
if tuple(item.get("constrained_columns") or ()) != columns:
continue
name = str(item.get("name") or "")
if name:
op.drop_constraint(name, table_name, type_="foreignkey")
def upgrade() -> None:
_require_postgresql()
_scope_profile_snapshots()
_scope_hermes_task_tables()
_scope_hermes_risk_reports()
_create_finance_report_tables()
def downgrade() -> None:
_require_postgresql()
_assert_safe_downgrade()
if _has_table("tenant_finance_report_runs"):
op.drop_table("tenant_finance_report_runs")
if _has_table("tenant_finance_report_configs"):
op.drop_table("tenant_finance_report_configs")
_downgrade_risk_reports()
_downgrade_task_tables()
_downgrade_profile_snapshots()
def _scope_profile_snapshots() -> None:
table = "employee_behavior_profile_snapshots"
if not _has_table(table):
return
op.add_column(
table,
sa.Column("tenant_id", sa.String(length=64), nullable=False, server_default="default"),
)
op.create_foreign_key(
"fk_employee_behavior_profiles_tenant",
table,
"tenants",
["tenant_id"],
["tenant_id"],
ondelete="RESTRICT",
)
op.create_unique_constraint(
"uq_employee_behavior_profiles_tenant_id",
table,
["tenant_id", "id"],
)
if _has_table("employees"):
op.create_foreign_key(
"fk_employee_behavior_profiles_tenant_employee",
table,
"employees",
["tenant_id", "subject_id"],
["tenant_id", "id"],
ondelete="CASCADE",
)
if _index_exists(table, "ix_employee_behavior_profile_latest"):
op.drop_index("ix_employee_behavior_profile_latest", table_name=table)
op.create_index(
"ix_employee_behavior_profile_latest",
table,
[
"tenant_id",
"subject_id",
"profile_type",
"window_days",
"expense_type_scope",
"calculated_at",
],
)
op.alter_column(table, "tenant_id", server_default=None)
def _scope_hermes_task_tables() -> None:
config_table = "hermes_task_configs"
log_table = "hermes_task_execution_logs"
for table, fk_name in (
(config_table, "fk_hermes_task_configs_tenant"),
(log_table, "fk_hermes_task_execution_logs_tenant"),
):
if not _has_table(table):
continue
op.add_column(
table,
sa.Column(
"tenant_id",
sa.String(length=64),
nullable=False,
server_default="default",
),
)
op.create_foreign_key(
fk_name,
table,
"tenants",
["tenant_id"],
["tenant_id"],
ondelete="RESTRICT",
)
op.alter_column(table, "tenant_id", server_default=None)
if _has_table(config_table):
op.create_unique_constraint(
"uq_hermes_task_configs_tenant_id",
config_table,
["tenant_id", "id"],
)
op.create_index(
"ix_hermes_task_configs_tenant_enabled",
config_table,
["tenant_id", "is_enabled"],
)
if _has_table(log_table):
op.create_unique_constraint(
"uq_hermes_task_execution_logs_tenant_id",
log_table,
["tenant_id", "id"],
)
if _has_table(config_table):
_drop_foreign_keys_for_columns(log_table, ("config_id",))
op.create_foreign_key(
"fk_hermes_task_logs_tenant_config",
log_table,
config_table,
["tenant_id", "config_id"],
["tenant_id", "id"],
ondelete="CASCADE",
)
op.create_index(
"ix_hermes_task_logs_tenant_started",
log_table,
["tenant_id", "started_at"],
)
def _scope_hermes_risk_reports() -> None:
table = "hermes_risk_reports"
if not _has_table(table):
return
op.add_column(
table,
sa.Column("tenant_id", sa.String(length=64), nullable=False, server_default="default"),
)
op.create_foreign_key(
"fk_hermes_risk_reports_tenant",
table,
"tenants",
["tenant_id"],
["tenant_id"],
ondelete="RESTRICT",
)
op.create_unique_constraint(
"uq_hermes_risk_reports_tenant_id",
table,
["tenant_id", "id"],
)
if _has_table("expense_claims"):
_drop_foreign_keys_for_columns(table, ("claim_id",))
op.create_foreign_key(
"fk_hermes_risk_reports_tenant_claim",
table,
"expense_claims",
["tenant_id", "claim_id"],
["tenant_id", "id"],
ondelete="CASCADE",
)
if _has_table("hermes_task_execution_logs"):
_drop_foreign_keys_for_columns(table, ("execution_log_id",))
op.create_foreign_key(
"fk_hermes_risk_reports_tenant_log",
table,
"hermes_task_execution_logs",
["tenant_id", "execution_log_id"],
["tenant_id", "id"],
ondelete="CASCADE",
)
op.create_index(
"ix_hermes_risk_reports_tenant_status",
table,
["tenant_id", "status"],
)
op.alter_column(table, "tenant_id", server_default=None)
def _create_finance_report_tables() -> None:
if not _has_table("tenant_finance_report_configs"):
op.create_table(
"tenant_finance_report_configs",
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False, server_default="disabled"),
sa.Column("delivery_enabled", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("recipients_json", sa.JSON(), nullable=False, server_default="[]"),
sa.Column("updated_by", sa.String(length=100), nullable=False, server_default=""),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.CheckConstraint(
"status IN ('active', 'disabled')",
name="ck_tenant_finance_report_configs_status",
),
sa.ForeignKeyConstraint(
["tenant_id"],
["tenants.tenant_id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("tenant_id"),
)
if not _has_table("tenant_finance_report_runs"):
op.create_table(
"tenant_finance_report_runs",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("report_type", sa.String(length=20), nullable=False),
sa.Column("period_start", sa.Date(), nullable=False),
sa.Column("period_end", sa.Date(), nullable=False),
sa.Column("idempotency_key", sa.String(length=180), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False, server_default="running"),
sa.Column("agent_run_id", sa.String(length=50), nullable=True),
sa.Column("storage_key", sa.String(length=512), nullable=False, server_default=""),
sa.Column("result_json", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column(
"started_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint(
"report_type IN ('weekly', 'quarterly', 'annual')",
name="ck_tenant_finance_report_runs_type",
),
sa.CheckConstraint(
"status IN ('running', 'succeeded', 'failed')",
name="ck_tenant_finance_report_runs_status",
),
sa.ForeignKeyConstraint(
["tenant_id"],
["tenants.tenant_id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_tenant_finance_report_runs_idempotency",
),
)
op.create_index(
"ix_tenant_finance_report_runs_period",
"tenant_finance_report_runs",
["tenant_id", "report_type", "period_start", "period_end"],
)
op.create_index(
"ix_tenant_finance_report_runs_agent_run_id",
"tenant_finance_report_runs",
["agent_run_id"],
)
def _assert_safe_downgrade() -> None:
connection = op.get_bind()
for table in (
"employee_behavior_profile_snapshots",
"hermes_task_configs",
"hermes_task_execution_logs",
"hermes_risk_reports",
):
if not _has_column(table, "tenant_id"):
continue
count = connection.execute(
sa.text(f"SELECT COUNT(*) FROM {table} WHERE tenant_id <> 'default'")
).scalar_one()
if int(count or 0) > 0:
raise RuntimeError(f"Refusing downgrade: {table} contains non-default tenant rows.")
for table in ("tenant_finance_report_configs", "tenant_finance_report_runs"):
if not _has_table(table):
continue
count = connection.execute(sa.text(f"SELECT COUNT(*) FROM {table}")).scalar_one()
if int(count or 0) > 0:
raise RuntimeError(f"Refusing downgrade: {table} contains data.")
def _drop_constraint_if_exists(table: str, name: str, type_: str) -> None:
if _constraint_exists(table, name):
op.drop_constraint(name, table, type_=type_)
def _drop_index_if_exists(table: str, name: str) -> None:
if _index_exists(table, name):
op.drop_index(name, table_name=table)
def _downgrade_risk_reports() -> None:
table = "hermes_risk_reports"
if not _has_column(table, "tenant_id"):
return
_drop_index_if_exists(table, "ix_hermes_risk_reports_tenant_status")
_drop_constraint_if_exists(table, "fk_hermes_risk_reports_tenant_log", "foreignkey")
_drop_constraint_if_exists(table, "fk_hermes_risk_reports_tenant_claim", "foreignkey")
_drop_constraint_if_exists(table, "uq_hermes_risk_reports_tenant_id", "unique")
_drop_constraint_if_exists(table, "fk_hermes_risk_reports_tenant", "foreignkey")
if _has_table("expense_claims"):
op.create_foreign_key(
"hermes_risk_reports_claim_id_fkey",
table,
"expense_claims",
["claim_id"],
["id"],
ondelete="CASCADE",
)
if _has_table("hermes_task_execution_logs"):
op.create_foreign_key(
"hermes_risk_reports_execution_log_id_fkey",
table,
"hermes_task_execution_logs",
["execution_log_id"],
["id"],
ondelete="CASCADE",
)
op.drop_column(table, "tenant_id")
def _downgrade_task_tables() -> None:
config_table = "hermes_task_configs"
log_table = "hermes_task_execution_logs"
if _has_column(log_table, "tenant_id"):
_drop_index_if_exists(log_table, "ix_hermes_task_logs_tenant_started")
_drop_constraint_if_exists(
log_table,
"fk_hermes_task_logs_tenant_config",
"foreignkey",
)
_drop_constraint_if_exists(
log_table,
"uq_hermes_task_execution_logs_tenant_id",
"unique",
)
_drop_constraint_if_exists(
log_table,
"fk_hermes_task_execution_logs_tenant",
"foreignkey",
)
if _has_table(config_table):
op.create_foreign_key(
"hermes_task_execution_logs_config_id_fkey",
log_table,
config_table,
["config_id"],
["id"],
)
op.drop_column(log_table, "tenant_id")
if _has_column(config_table, "tenant_id"):
_drop_index_if_exists(config_table, "ix_hermes_task_configs_tenant_enabled")
_drop_constraint_if_exists(
config_table,
"uq_hermes_task_configs_tenant_id",
"unique",
)
_drop_constraint_if_exists(
config_table,
"fk_hermes_task_configs_tenant",
"foreignkey",
)
op.drop_column(config_table, "tenant_id")
def _downgrade_profile_snapshots() -> None:
table = "employee_behavior_profile_snapshots"
if not _has_column(table, "tenant_id"):
return
_drop_index_if_exists(table, "ix_employee_behavior_profile_latest")
_drop_constraint_if_exists(
table,
"fk_employee_behavior_profiles_tenant_employee",
"foreignkey",
)
_drop_constraint_if_exists(
table,
"uq_employee_behavior_profiles_tenant_id",
"unique",
)
_drop_constraint_if_exists(
table,
"fk_employee_behavior_profiles_tenant",
"foreignkey",
)
op.drop_column(table, "tenant_id")
op.create_index(
"ix_employee_behavior_profile_latest",
table,
[
"subject_id",
"profile_type",
"window_days",
"expense_type_scope",
"calculated_at",
],
)

View File

@@ -0,0 +1,398 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import sys
import uuid
from collections import Counter
from dataclasses import asdict
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from alembic.config import Config
from alembic.script import ScriptDirectory
from sqlalchemy import create_engine, text
from sqlalchemy.engine import Connection
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from sqlalchemy.pool import NullPool
SERVER_DIR = Path(__file__).resolve().parents[1]
SRC_DIR = SERVER_DIR / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
from app.cli.savings_standard_adjustment_backfill import ( # noqa: E402
DEFAULT_BATCH_SIZE,
MAX_BATCH_SIZE,
StandardAdjustmentBackfillCursor,
StandardAdjustmentSavingsBackfillService,
)
from app.db.maintenance_database_target import ( # noqa: E402
MaintenanceDatabaseTargetError,
validate_maintenance_database_target,
)
from app.db.migration_preflight import ( # noqa: E402
MigrationPreflightError,
validate_migration_state,
)
REQUIRED_ALEMBIC_REVISION = "20260716_0015"
EXIT_SAFETY = 3
EXIT_LOCKED = 4
EXIT_RUNTIME = 6
class BackfillCommandError(RuntimeError):
def __init__(self, message: str, *, code: str, exit_code: int) -> None:
super().__init__(message)
self.code = code
self.exit_code = exit_code
def revision_contains_required(
current_revision: str | None,
required_revision: str = REQUIRED_ALEMBIC_REVISION,
) -> bool:
"""确认当前迁移沿 down_revision 链包含回填所需的数据契约。"""
current = str(current_revision or "").strip()
required = str(required_revision or "").strip()
if not current or not required:
return False
config = Config(str(SERVER_DIR / "alembic.ini"))
script_directory = ScriptDirectory.from_config(config)
pending = [current]
visited: set[str] = set()
while pending:
revision_id = pending.pop()
if revision_id in visited:
continue
if revision_id == required:
return True
visited.add(revision_id)
revision = script_directory.get_revision(revision_id)
if revision is None:
return False
down_revision = revision.down_revision
if isinstance(down_revision, str):
pending.append(down_revision)
elif down_revision:
pending.extend(str(item) for item in down_revision)
return False
def parse_timestamp(value: str) -> datetime:
normalized = str(value or "").strip()
if normalized.endswith("Z"):
normalized = f"{normalized[:-1]}+00:00"
try:
parsed = datetime.fromisoformat(normalized)
except ValueError as exc:
raise argparse.ArgumentTypeError("必须是带时区的 ISO 8601 时间") from exc
if parsed.tzinfo is None or parsed.utcoffset() is None:
raise argparse.ArgumentTypeError("时间必须显式包含时区")
return parsed.astimezone(UTC)
def positive_int(value: str) -> int:
try:
parsed = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("必须是正整数") from exc
if parsed < 1:
raise argparse.ArgumentTypeError("必须是正整数")
return parsed
def batch_size(value: str) -> int:
parsed = positive_int(value)
if parsed > MAX_BATCH_SIZE:
raise argparse.ArgumentTypeError(f"不能超过 {MAX_BATCH_SIZE}")
return parsed
def non_empty_text(value: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise argparse.ArgumentTypeError("不能为空")
return normalized
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="按服务端历史证据回填标准调整节省机会;默认只读预览。",
)
mode = parser.add_mutually_exclusive_group()
mode.add_argument("--dry-run", action="store_true", help="只读预览(默认)。")
mode.add_argument("--apply", action="store_true", help="按批写入节省机会及证据链。")
parser.add_argument("--tenant-id", required=True, type=non_empty_text)
parser.add_argument("--created-before", required=True, type=parse_timestamp)
parser.add_argument("--batch-size", type=batch_size, default=DEFAULT_BATCH_SIZE)
parser.add_argument("--max-claims", type=positive_int)
parser.add_argument("--sample-limit", type=positive_int, default=20)
parser.add_argument("--expected-host", required=True)
parser.add_argument("--expected-database", required=True)
parser.add_argument(
"--confirm-target",
help="apply 时必须精确等于解析后的 host:port/database。",
)
parser.add_argument("--allow-non-disposable-target", action="store_true")
return parser
def _cursor_payload(cursor: StandardAdjustmentBackfillCursor | None) -> dict[str, str] | None:
if cursor is None:
return None
return {"created_at": _isoformat(cursor.created_at), "claim_id": cursor.claim_id}
def _item_payload(item: Any) -> dict[str, Any]:
payload = asdict(item)
payload["disposition"] = item.disposition.value
for field in ("original_amount", "target_amount", "saving_amount"):
if payload[field] is not None:
payload[field] = str(payload[field])
return payload
def _base_summary(args: argparse.Namespace, *, target: Any, revision: str) -> dict[str, Any]:
return {
"mode": "apply" if args.apply else "dry-run",
"database": {
"target": target.exact_target,
"url": target.sanitized_url,
"revision": revision,
},
"tenant_id": args.tenant_id,
"created_before": _isoformat(args.created_before),
"claims_inspected": 0,
"flags_inspected": 0,
"eligible": 0,
"created": 0,
"replayed": 0,
"skipped": 0,
"reasons": {},
"batches": 0,
"limited": False,
"last_cursor": None,
"samples": [],
}
def _page_size(configured: int, remaining: int | None) -> int:
return configured if remaining is None else min(configured, remaining)
def _merge_page(summary: dict[str, Any], page: Any, *, sample_limit: int) -> None:
summary["batches"] += 1
summary["claims_inspected"] += page.claims_inspected
summary["flags_inspected"] += page.flags_inspected
for field in ("eligible", "created", "replayed", "skipped"):
if hasattr(page, field):
summary[field] += int(getattr(page, field))
reasons = Counter(summary["reasons"])
reasons.update(page.reasons)
summary["reasons"] = dict(sorted(reasons.items()))
available = max(0, sample_limit - len(summary["samples"]))
summary["samples"].extend(_item_payload(item) for item in page.items[:available])
summary["last_cursor"] = _cursor_payload(page.next_cursor)
def _preview(session: Session, args: argparse.Namespace, summary: dict[str, Any]) -> None:
service = StandardAdjustmentSavingsBackfillService(
session,
tenant_id=args.tenant_id,
created_before=args.created_before,
)
cursor = None
remaining = args.max_claims
last_has_more = False
while remaining is None or remaining > 0:
page = service.preview(
batch_size=_page_size(args.batch_size, remaining),
after=cursor,
)
if page.claims_inspected == 0:
break
_merge_page(summary, page, sample_limit=args.sample_limit)
cursor = page.next_cursor
last_has_more = page.has_more
if remaining is not None:
remaining -= page.claims_inspected
if not page.has_more:
break
summary["limited"] = bool(remaining == 0 and last_has_more)
def _acquire_lock(connection: Connection, tenant_id: str) -> str:
lock_name = f"savings-standard-adjustment-backfill:{tenant_id}"
acquired = connection.scalar(
text("SELECT pg_try_advisory_lock(hashtextextended(:name, 0))"),
{"name": lock_name},
)
connection.commit()
if not acquired:
raise BackfillCommandError(
"同一租户已有标准调整 Savings 回填正在运行。",
code="advisory_lock_unavailable",
exit_code=EXIT_LOCKED,
)
return lock_name
def _release_lock(connection: Connection, lock_name: str) -> None:
if connection.in_transaction():
connection.rollback()
connection.execute(
text("SELECT pg_advisory_unlock(hashtextextended(:name, 0))"),
{"name": lock_name},
)
connection.commit()
def _apply(connection: Connection, args: argparse.Namespace, summary: dict[str, Any]) -> None:
lock_name = _acquire_lock(connection, args.tenant_id)
run_id = f"savings-adjustment-{uuid.uuid4().hex}"
summary["run_id"] = run_id
cursor = None
remaining = args.max_claims
last_has_more = False
try:
with Session(bind=connection, autoflush=False, expire_on_commit=False) as session:
service = StandardAdjustmentSavingsBackfillService(
session,
tenant_id=args.tenant_id,
created_before=args.created_before,
)
while remaining is None or remaining > 0:
session.execute(text("SET LOCAL lock_timeout = '5s'"))
result = service.apply_batch(
run_id=run_id,
batch_size=_page_size(args.batch_size, remaining),
after=cursor,
)
if result.claims_inspected == 0:
session.rollback()
break
session.commit()
_merge_page(summary, result, sample_limit=args.sample_limit)
cursor = result.next_cursor
last_has_more = result.has_more
if remaining is not None:
remaining -= result.claims_inspected
if not result.has_more:
break
summary["limited"] = bool(remaining == 0 and last_has_more)
finally:
_release_lock(connection, lock_name)
def run(args: argparse.Namespace) -> dict[str, Any]:
if args.apply and not str(args.confirm_target or "").strip():
raise BackfillCommandError(
"--apply 必须提供 --confirm-target 精确确认数据库目标。",
code="confirm_target_required",
exit_code=EXIT_SAFETY,
)
database_url = os.environ.get("DATABASE_URL", "")
target = validate_maintenance_database_target(
database_url,
expected_host=args.expected_host,
expected_database=args.expected_database,
apply=args.apply,
allow_non_disposable=args.allow_non_disposable_target,
confirm_target=args.confirm_target,
)
engine = create_engine(database_url, pool_pre_ping=True, poolclass=NullPool)
try:
with engine.connect() as connection:
database = str(connection.scalar(text("SELECT current_database()")) or "")
if database != target.database:
raise BackfillCommandError(
"连接后的数据库名与 DATABASE_URL 不一致。",
code="connected_database_mismatch",
exit_code=EXIT_SAFETY,
)
state = validate_migration_state(connection)
if not revision_contains_required(state.revision):
raise BackfillCommandError(
f"数据库迁移链必须包含 {REQUIRED_ALEMBIC_REVISION}"
f"实际为 {state.revision or 'unversioned/base'}",
code="migration_revision_mismatch",
exit_code=EXIT_SAFETY,
)
connection.rollback()
summary = _base_summary(args, target=target, revision=state.revision)
with Session(bind=connection, autoflush=False) as session:
_preview(session, args, summary)
session.rollback()
if not args.apply:
summary["would_create"] = summary["eligible"]
return summary
preview = dict(summary)
summary.update(
claims_inspected=0,
flags_inspected=0,
eligible=0,
created=0,
replayed=0,
skipped=0,
reasons={},
batches=0,
limited=False,
last_cursor=None,
samples=[],
preview=preview,
)
_apply(connection, args, summary)
return summary
finally:
engine.dispose()
def _isoformat(value: datetime) -> str:
normalized = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
return normalized.isoformat().replace("+00:00", "Z")
def _error_payload(exc: Exception, *, code: str) -> dict[str, str]:
return {"status": "error", "code": code, "message": str(exc)}
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
payload = run(args)
except BackfillCommandError as exc:
print(json.dumps(_error_payload(exc, code=exc.code), ensure_ascii=False), file=sys.stderr)
return exc.exit_code
except MaintenanceDatabaseTargetError as exc:
print(json.dumps(_error_payload(exc, code=exc.code), ensure_ascii=False), file=sys.stderr)
return EXIT_SAFETY
except MigrationPreflightError as exc:
print(
json.dumps(
_error_payload(exc, code="migration_preflight_failed"),
ensure_ascii=False,
),
file=sys.stderr,
)
return EXIT_SAFETY
except (OSError, SQLAlchemyError, ValueError, RuntimeError) as exc:
print(
json.dumps(_error_payload(exc, code="backfill_runtime_error"), ensure_ascii=False),
file=sys.stderr,
)
return EXIT_RUNTIME
print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -24,7 +24,7 @@ class CurrentUserContext:
name: str
role_codes: list[str]
is_admin: bool
tenant_id: str = "default"
tenant_id: str
department_name: str = ""
department_id: str = ""
cost_center: str = ""

View File

@@ -0,0 +1,308 @@
from __future__ import annotations
from typing import Annotated, NoReturn
from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_db, require_rule_reviewer_user
from app.schemas.agent_asset_release import (
AgentAssetReleaseMonitorRead,
AgentAssetReleaseMonitorTriggerWrite,
AgentAssetReleaseReviewLabelRead,
AgentAssetReleaseReviewLabelWrite,
AgentAssetReleaseReviewQueueRead,
AgentAssetReleaseRollbackWrite,
AgentAssetReleaseServingPlanRead,
AgentAssetReleaseStartWrite,
AgentAssetReleaseStateRead,
)
from app.services.agent_asset_access import stable_user_principal
from app.services.agent_asset_release_guard import (
AgentAssetReleaseGuardService,
ReleaseGuardPolicy,
)
from app.services.agent_asset_release_monitor import AgentAssetReleaseMonitor
from app.services.agent_asset_release_monitor_auth import (
ReleaseMonitorAuthenticationError,
ReleaseMonitorConfigurationError,
require_release_monitor_signature,
)
from app.services.agent_asset_release_review import AgentAssetReleaseReviewService
router = APIRouter(prefix="/agent-assets")
DbSession = Annotated[Session, Depends(get_db)]
RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_user)]
RequestIdHeader = Annotated[
str | None,
Header(description="外部请求 ID用于串联审计日志和上游调用链。"),
]
ReleaseMonitorTimestampHeader = Annotated[
str | None,
Header(alias="X-Release-Monitor-Timestamp"),
]
ReleaseMonitorSignatureHeader = Annotated[
str | None,
Header(alias="X-Release-Monitor-Signature"),
]
def _handle_error(exc: Exception) -> NoReturn:
if isinstance(exc, LookupError):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
if isinstance(exc, (PermissionError, ValueError)):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
raise exc
def _actor(user: CurrentUserContext) -> str:
return stable_user_principal(user)
def _state_payload(value: dict) -> AgentAssetReleaseStateRead:
if not value:
return AgentAssetReleaseStateRead()
allowed = set(AgentAssetReleaseStateRead.model_fields)
return AgentAssetReleaseStateRead.model_validate(
{key: item for key, item in value.items() if key in allowed}
)
@router.get(
"/{asset_id}/release",
response_model=AgentAssetReleaseStateRead,
summary="查询 Agent 资产分阶段发布状态",
)
def get_agent_asset_release(
asset_id: str,
current_user: RuleReviewerUser,
db: DbSession,
) -> AgentAssetReleaseStateRead:
try:
return _state_payload(
AgentAssetReleaseGuardService(db).get_state(
asset_id,
tenant_id=current_user.tenant_id,
allow_global_management=current_user.is_admin,
)
)
except Exception as exc:
_handle_error(exc)
@router.get(
"/{asset_id}/release/serving-plan",
response_model=AgentAssetReleaseServingPlanRead,
summary="查询运行时版本路由计划",
)
def get_agent_asset_release_serving_plan(
asset_id: str,
current_user: RuleReviewerUser,
db: DbSession,
) -> AgentAssetReleaseServingPlanRead:
try:
return AgentAssetReleaseServingPlanRead.model_validate(
AgentAssetReleaseGuardService(db).get_serving_plan(
asset_id,
tenant_id=current_user.tenant_id,
allow_global_management=current_user.is_admin,
)
)
except Exception as exc:
_handle_error(exc)
@router.post(
"/{asset_id}/release/shadow",
response_model=AgentAssetReleaseStateRead,
summary="启动受控影子发布",
)
def start_agent_asset_shadow_release(
asset_id: str,
payload: AgentAssetReleaseStartWrite,
current_user: RuleReviewerUser,
db: DbSession,
x_request_id: RequestIdHeader = None,
) -> AgentAssetReleaseStateRead:
try:
policy = ReleaseGuardPolicy(**payload.policy.model_dump())
value = AgentAssetReleaseGuardService(db).start_shadow(
asset_id,
payload.candidate_version,
actor=_actor(current_user),
policy=policy,
tenant_id=current_user.tenant_id,
allow_global_management=current_user.is_admin,
request_id=x_request_id,
)
return _state_payload(value)
except Exception as exc:
_handle_error(exc)
@router.post(
"/{asset_id}/release/evaluations",
response_model=AgentAssetReleaseMonitorRead,
summary="聚合真实发布遥测并在越界时自动回滚",
)
def evaluate_agent_asset_release(
asset_id: str,
payload: AgentAssetReleaseMonitorTriggerWrite,
current_user: RuleReviewerUser,
db: DbSession,
x_release_monitor_timestamp: ReleaseMonitorTimestampHeader = None,
x_release_monitor_signature: ReleaseMonitorSignatureHeader = None,
) -> AgentAssetReleaseMonitorRead:
try:
service = AgentAssetReleaseGuardService(db)
state = service.get_state(
asset_id,
tenant_id=current_user.tenant_id,
allow_global_management=current_user.is_admin,
)
require_release_monitor_signature(
timestamp=x_release_monitor_timestamp,
signature=x_release_monitor_signature,
tenant_id=current_user.tenant_id,
asset_id=asset_id,
release_id=str(state.get("release_id") or ""),
stage=str(state.get("stage") or ""),
payload=payload.model_dump(mode="json", exclude_unset=True),
)
value = AgentAssetReleaseMonitor(db).evaluate_current(
asset_id=asset_id,
actor=_actor(current_user),
tenant_id=current_user.tenant_id,
allow_global_management=current_user.is_admin,
)
return AgentAssetReleaseMonitorRead.model_validate(value)
except ReleaseMonitorConfigurationError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
) from exc
except ReleaseMonitorAuthenticationError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(exc),
) from exc
except Exception as exc:
_handle_error(exc)
@router.get(
"/{asset_id}/release/review-queue",
response_model=AgentAssetReleaseReviewQueueRead,
summary="查询当前发布的去敏人工复核队列",
)
def list_agent_asset_release_review_queue(
asset_id: str,
current_user: RuleReviewerUser,
db: DbSession,
limit: int = 50,
) -> AgentAssetReleaseReviewQueueRead:
try:
value = AgentAssetReleaseReviewService(db).list_pending(
tenant_id=current_user.tenant_id,
asset_id=asset_id,
limit=limit,
)
return AgentAssetReleaseReviewQueueRead.model_validate(value)
except Exception as exc:
_handle_error(exc)
@router.post(
"/{asset_id}/release/review-queue/{observation_id}/labels",
response_model=AgentAssetReleaseReviewLabelRead,
summary="提交类型化发布复核结论并重新聚合",
)
def label_agent_asset_release_observation(
asset_id: str,
observation_id: str,
payload: AgentAssetReleaseReviewLabelWrite,
current_user: RuleReviewerUser,
db: DbSession,
x_request_id: RequestIdHeader = None,
) -> AgentAssetReleaseReviewLabelRead:
try:
request_id = str(x_request_id or "").strip()
if not request_id:
raise ValueError("X-Request-Id is required for release review labels.")
label = AgentAssetReleaseReviewService(db).record_label(
tenant_id=current_user.tenant_id,
asset_id=asset_id,
observation_id=observation_id,
label=payload.label,
actor_id=_actor(current_user),
request_id=request_id,
)
db.commit()
monitor = AgentAssetReleaseMonitor(db).evaluate_current(
asset_id=asset_id,
actor=_actor(current_user),
tenant_id=current_user.tenant_id,
)
return AgentAssetReleaseReviewLabelRead.model_validate(
{
"label_id": label.id,
"observation_id": label.observation_id,
"label": label.label,
"monitor": monitor,
}
)
except Exception as exc:
db.rollback()
_handle_error(exc)
@router.post(
"/{asset_id}/release/promote",
response_model=AgentAssetReleaseStateRead,
summary="将通过评测的版本晋级到下一阶段",
)
def promote_agent_asset_release(
asset_id: str,
current_user: RuleReviewerUser,
db: DbSession,
x_request_id: RequestIdHeader = None,
) -> AgentAssetReleaseStateRead:
try:
return _state_payload(
AgentAssetReleaseGuardService(db).promote(
asset_id,
actor=_actor(current_user),
tenant_id=current_user.tenant_id,
allow_global_management=current_user.is_admin,
request_id=x_request_id,
)
)
except Exception as exc:
_handle_error(exc)
@router.post(
"/{asset_id}/release/rollback",
response_model=AgentAssetReleaseStateRead,
summary="显式回滚到发布前版本",
)
def rollback_agent_asset_release(
asset_id: str,
payload: AgentAssetReleaseRollbackWrite,
current_user: RuleReviewerUser,
db: DbSession,
x_request_id: RequestIdHeader = None,
) -> AgentAssetReleaseStateRead:
try:
return _state_payload(
AgentAssetReleaseGuardService(db).rollback(
asset_id,
actor=_actor(current_user),
reason=payload.reason,
tenant_id=current_user.tenant_id,
allow_global_management=current_user.is_admin,
request_id=x_request_id,
)
)
except Exception as exc:
_handle_error(exc)

View File

@@ -2,35 +2,52 @@ from __future__ import annotations
from typing import Annotated, NoReturn
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.api.deps import (
CurrentUserContext,
get_current_user,
get_db,
require_platform_admin_user,
require_rule_editor_user,
require_rule_reviewer_user,
)
from app.db.session import get_session_factory
from app.schemas.agent_asset import (
AgentAssetRead,
AgentAssetRiskRuleDraftUpdate,
AgentAssetRiskRuleEnabledUpdate,
AgentAssetRiskRuleFeedbackCreate,
AgentAssetRiskRuleFeedbackRead,
AgentAssetRiskRuleGenerateRequest,
AgentAssetRiskRuleLatestTestSummary,
AgentAssetRiskRuleLevelUpdate,
AgentAssetRiskRuleRegenerateRequest,
AgentAssetRiskRuleReportRequest,
AgentAssetRiskRuleReturnRequest,
AgentAssetRiskRuleRevisionCreate,
AgentAssetRiskRuleSampleTestRequest,
AgentAssetRiskRuleScenarioTestRequest,
AgentAssetRiskRuleSimulationRead,
AgentAssetRiskRuleSimulationRequest,
AgentAssetRiskRuleTemplateGroupRead,
AgentAssetRiskRuleTestRunRead,
AgentAssetRuleJsonRead,
AgentAssetRuleJsonWrite,
)
from app.services.agent_asset_access import stable_user_principal
from app.services.agent_asset_risk_rule_regeneration import AgentAssetRiskRuleRegenerationService
from app.services.agent_asset_risk_rule_revision import AgentAssetRiskRuleRevisionService
from app.services.agent_assets import AgentAssetService
from app.services.risk_rule_generation_jobs import RiskRuleGenerationJobService
from app.services.risk_rule_template_catalog import list_risk_rule_template_groups
router = APIRouter(prefix="/agent-assets")
DbSession = Annotated[Session, Depends(get_db)]
ActorHeader = Annotated[
str | None,
Header(description="审计操作人。未传时使用当前登录用户名称"),
Header(description="兼容旧客户端;审计主体始终以登录会话中的稳定身份为准"),
]
RequestIdHeader = Annotated[
str | None,
@@ -38,6 +55,7 @@ RequestIdHeader = Annotated[
]
RuleEditorUser = Annotated[CurrentUserContext, Depends(require_rule_editor_user)]
RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_user)]
PlatformAdminUser = Annotated[CurrentUserContext, Depends(require_platform_admin_user)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@@ -50,21 +68,49 @@ def _handle_asset_error(exc: Exception) -> NoReturn:
def _actor_name(current_user: CurrentUserContext, x_actor: str | None) -> str:
return (x_actor or current_user.name or current_user.username or "system").strip() or "system"
del x_actor
return stable_user_principal(current_user)
def _read_asset(db: Session, asset_id: str) -> AgentAssetRead:
asset = AgentAssetService(db).get_asset(asset_id)
def _read_asset(
db: Session,
asset_id: str,
current_user: CurrentUserContext,
) -> AgentAssetRead:
asset = AgentAssetService(db, current_user=current_user).get_asset(asset_id)
if asset is None:
raise LookupError("Asset not found")
return asset
def _complete_risk_rule_generation_task(
asset_id: str,
payload: dict,
actor: str,
request_id: str | None,
tenant_id: str,
) -> None:
db = get_session_factory()()
try:
body = AgentAssetRiskRuleGenerateRequest.model_validate(payload)
RiskRuleGenerationJobService(db).complete_rule_asset_generation(
asset_id,
body,
tenant_id=tenant_id,
actor=actor,
request_id=request_id,
)
finally:
db.close()
@router.get(
"/risk-rules/templates",
response_model=list[AgentAssetRiskRuleTemplateGroupRead],
summary="查询常见费控风险规则模板",
description="返回模板分组、默认自然语言、字段清单和 DSL 样例;模板只用于预填,不绕过通用生成链路。",
description=(
"返回模板分组、默认自然语言、字段清单和 DSL 样例;模板只用于预填,不绕过通用生成链路。"
),
)
def list_risk_rule_templates(_: CurrentUser) -> list[AgentAssetRiskRuleTemplateGroupRead]:
return list_risk_rule_template_groups()
@@ -85,13 +131,13 @@ def update_risk_rule_draft(
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
AgentAssetRiskRuleRevisionService(db).update_unpublished_draft(
AgentAssetRiskRuleRevisionService(db, current_user=current_user).update_unpublished_draft(
asset_id,
payload,
actor=_actor_name(current_user, x_actor),
request_id=x_request_id,
)
return _read_asset(db, asset_id)
return _read_asset(db, asset_id, current_user)
except Exception as exc:
_handle_asset_error(exc)
@@ -112,13 +158,13 @@ def create_risk_rule_revision(
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
AgentAssetRiskRuleRevisionService(db).create_revision_draft(
AgentAssetRiskRuleRevisionService(db, current_user=current_user).create_revision_draft(
asset_id,
payload,
actor=_actor_name(current_user, x_actor),
request_id=x_request_id,
)
return _read_asset(db, asset_id)
return _read_asset(db, asset_id, current_user)
except Exception as exc:
_handle_asset_error(exc)
@@ -138,14 +184,16 @@ def regenerate_risk_rule(
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
AgentAssetRiskRuleRegenerationService(db).regenerate(
AgentAssetRiskRuleRegenerationService(
db, current_user=current_user
).regenerate(
asset_id,
payload,
tenant_id=current_user.tenant_id,
actor=_actor_name(current_user, x_actor),
request_id=x_request_id,
)
return _read_asset(db, asset_id)
return _read_asset(db, asset_id, current_user)
except Exception as exc:
_handle_asset_error(exc)
@@ -166,7 +214,7 @@ def create_risk_rule_feedback(
x_request_id: RequestIdHeader = None,
) -> AgentAssetRiskRuleFeedbackRead:
try:
return AgentAssetService(db).create_risk_rule_feedback(
return AgentAssetService(db, current_user=current_user).create_risk_rule_feedback(
asset_id,
payload,
actor=_actor_name(current_user, x_actor),
@@ -184,14 +232,14 @@ def create_risk_rule_feedback(
)
def list_risk_rule_feedback(
asset_id: str,
_: RuleReviewerUser,
current_user: RuleReviewerUser,
db: DbSession,
version: Annotated[str | None, Query(max_length=30)] = None,
status_value: Annotated[str | None, Query(alias="status", max_length=30)] = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
) -> list[AgentAssetRiskRuleFeedbackRead]:
try:
return AgentAssetService(db).list_risk_rule_feedback(
return AgentAssetService(db, current_user=current_user).list_risk_rule_feedback(
asset_id,
version=version,
status=status_value,
@@ -199,3 +247,304 @@ def list_risk_rule_feedback(
)
except Exception as exc:
_handle_asset_error(exc)
@router.get(
"/{asset_id}/rule-json",
response_model=AgentAssetRuleJsonRead,
summary="读取风险规则 JSON",
description="读取 JSON 风险规则资产绑定的规则文件内容。",
)
def get_agent_asset_rule_json(
asset_id: str,
current_user: CurrentUser,
db: DbSession,
) -> AgentAssetRuleJsonRead:
try:
return AgentAssetService(db, current_user=current_user).read_rule_json(asset_id)
except Exception as exc:
_handle_asset_error(exc)
@router.put(
"/{asset_id}/rule-json",
response_model=AgentAssetRuleJsonRead,
summary="保存风险规则 JSON",
description="保存 JSON 风险规则资产绑定的规则文件内容,并写入审计日志。",
)
def save_agent_asset_rule_json(
asset_id: str,
payload: AgentAssetRuleJsonWrite,
current_user: RuleEditorUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRuleJsonRead:
try:
return AgentAssetService(db, current_user=current_user).write_rule_json(
asset_id,
body=payload,
actor=_actor_name(current_user, x_actor),
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/risk-rules/generate",
response_model=AgentAssetRead,
status_code=status.HTTP_201_CREATED,
summary="根据自然语言新建风险规则草稿",
description=(
"根据业务域、自然语言描述和风险评分模型生成 JSON 风险规则,并保存为待上线草稿资产。"
),
)
def generate_agent_asset_risk_rule(
payload: AgentAssetRiskRuleGenerateRequest,
background_tasks: BackgroundTasks,
current_user: RuleReviewerUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
actor = _actor_name(current_user, x_actor)
asset_id = RiskRuleGenerationJobService(db).enqueue_rule_asset_generation(
payload,
tenant_id=current_user.tenant_id,
actor=actor,
request_id=x_request_id,
)
background_tasks.add_task(
_complete_risk_rule_generation_task,
asset_id,
payload.model_dump(mode="json"),
actor,
x_request_id,
current_user.tenant_id,
)
return _read_asset(db, asset_id, current_user)
except Exception as exc:
_handle_asset_error(exc)
@router.get(
"/{asset_id}/risk-rule-tests/latest",
response_model=AgentAssetRiskRuleLatestTestSummary,
summary="读取风险规则最近测试摘要",
description="返回当前风险规则工作版本最近一次样例测试、场景试运行和测试报告。",
)
def get_agent_asset_risk_rule_latest_test(
asset_id: str,
current_user: CurrentUser,
db: DbSession,
) -> AgentAssetRiskRuleLatestTestSummary:
try:
return AgentAssetService(
db, current_user=current_user
).get_latest_risk_rule_test_summary(asset_id)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rule-tests/simulate",
response_model=AgentAssetRiskRuleSimulationRead,
summary="执行风险规则对话仿真",
description="基于临时对话输入和附件元信息执行风险识别,不创建业务单据,不写入测试记录。",
)
def simulate_agent_asset_risk_rule_test(
asset_id: str,
payload: AgentAssetRiskRuleSimulationRequest,
current_user: PlatformAdminUser,
db: DbSession,
) -> AgentAssetRiskRuleSimulationRead:
try:
return AgentAssetService(
db, current_user=current_user
).simulate_risk_rule_message(asset_id, payload)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rule-tests/sample",
response_model=AgentAssetRiskRuleTestRunRead,
summary="执行风险规则快速样例测试",
description="使用人工样例或系统默认样例执行当前 JSON 风险规则,不依赖大模型判断结果。",
)
def run_agent_asset_risk_rule_sample_test(
asset_id: str,
payload: AgentAssetRiskRuleSampleTestRequest,
current_user: PlatformAdminUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRiskRuleTestRunRead:
try:
return AgentAssetService(db, current_user=current_user).run_risk_rule_sample_test(
asset_id,
payload,
actor=_actor_name(current_user, x_actor),
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rule-tests/scenario",
response_model=AgentAssetRiskRuleTestRunRead,
summary="执行风险规则真实场景试运行",
description="按测试意图读取真实业务样本并沙盒执行风险规则,不写回业务单据。",
)
def run_agent_asset_risk_rule_scenario_test(
asset_id: str,
payload: AgentAssetRiskRuleScenarioTestRequest,
current_user: PlatformAdminUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRiskRuleTestRunRead:
try:
return AgentAssetService(
db, current_user=current_user
).run_risk_rule_scenario_test(
asset_id,
payload,
actor=_actor_name(current_user, x_actor),
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rule-tests/report",
response_model=AgentAssetRiskRuleTestRunRead,
summary="确认风险规则测试报告",
description="在样例测试和真实场景试运行通过后,保存当前版本测试通过记录。",
)
def confirm_agent_asset_risk_rule_test_report(
asset_id: str,
payload: AgentAssetRiskRuleReportRequest,
current_user: PlatformAdminUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRiskRuleTestRunRead:
try:
return AgentAssetService(
db, current_user=current_user
).confirm_risk_rule_test_report(
asset_id,
payload,
actor=_actor_name(current_user, x_actor),
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rule-enabled",
response_model=AgentAssetRead,
summary="设置风险规则启用状态",
description=(
"高级财务人员可独立启用或停用 JSON 风险规则;停用后即使已上线也不会进入真实业务扫描。"
),
)
def set_agent_asset_risk_rule_enabled(
asset_id: str,
payload: AgentAssetRiskRuleEnabledUpdate,
current_user: RuleReviewerUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
asset = AgentAssetService(db, current_user=current_user).set_risk_rule_enabled(
asset_id,
enabled=payload.enabled,
actor=_actor_name(current_user, x_actor),
request_id=x_request_id,
)
return _read_asset(db, asset.id, current_user)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rule-level",
response_model=AgentAssetRead,
summary="风险规则风险等级已由评分模型接管",
description="风险规则等级和分数由自然语言规则评分模型生成,不再允许人工调整。",
)
def set_agent_asset_risk_rule_level(
asset_id: str,
payload: AgentAssetRiskRuleLevelUpdate,
current_user: RuleEditorUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
del asset_id, payload, current_user, db, x_actor, x_request_id
raise ValueError("风险等级和分数由评分模型自动计算,不能手动修改。")
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/return",
response_model=AgentAssetRiskRuleLatestTestSummary,
summary="回退待审核风险规则",
description="高级财务人员将待审核风险规则回退到草稿,并记录回退原因。",
)
def return_agent_asset_risk_rule(
asset_id: str,
payload: AgentAssetRiskRuleReturnRequest,
current_user: RuleReviewerUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRiskRuleLatestTestSummary:
try:
return AgentAssetService(db, current_user=current_user).return_risk_rule(
asset_id,
note=payload.note,
actor=_actor_name(current_user, x_actor),
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/publish",
response_model=AgentAssetRead,
summary="审核并启动风险规则影子发布",
description=(
"高级财务人员确认测试与 Golden 门禁通过后,将候选规则送入 shadow"
"该入口不会直接 active后续必须通过 Canary 质量门禁。"
),
)
def publish_agent_asset_risk_rule(
asset_id: str,
current_user: RuleReviewerUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
asset = AgentAssetService(db, current_user=current_user).publish_risk_rule(
asset_id,
actor=_actor_name(current_user, x_actor),
tenant_id=current_user.tenant_id,
allow_global_management=current_user.is_admin,
request_id=x_request_id,
)
return _read_asset(db, asset.id, current_user)
except Exception as exc:
_handle_asset_error(exc)

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Body, Depends, Header, HTTPException, Query, status
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
@@ -15,7 +15,6 @@ from app.api.deps import (
require_rule_reviewer_user,
)
from app.api.pagination import PageNumber, PageSize, page_payload, wants_page
from app.db.session import get_session_factory
from app.schemas.agent_asset import (
AgentAssetCreate,
AgentAssetListItem,
@@ -25,19 +24,6 @@ from app.schemas.agent_asset import (
AgentAssetRead,
AgentAssetReviewCreate,
AgentAssetReviewRead,
AgentAssetRiskRuleEnabledUpdate,
AgentAssetRiskRuleGenerateRequest,
AgentAssetRiskRuleLatestTestSummary,
AgentAssetRiskRuleLevelUpdate,
AgentAssetRiskRuleReportRequest,
AgentAssetRiskRuleReturnRequest,
AgentAssetRiskRuleSampleTestRequest,
AgentAssetRiskRuleScenarioTestRequest,
AgentAssetRiskRuleSimulationRead,
AgentAssetRiskRuleSimulationRequest,
AgentAssetRiskRuleTestRunRead,
AgentAssetRuleJsonRead,
AgentAssetRuleJsonWrite,
AgentAssetSpreadsheetChangeRecordRead,
AgentAssetUpdate,
AgentAssetVersionCreate,
@@ -49,14 +35,20 @@ from app.schemas.agent_asset import (
GoldenEvalRequest,
)
from app.schemas.common import ErrorResponse, PaginatedResponse
from app.services.agent_asset_access import stable_user_principal
from app.services.agent_asset_onlyoffice_security import (
AGENT_ASSET_ONLYOFFICE_CALLBACK_SCOPE,
AgentAssetOnlyOfficeReplayError,
AgentAssetOnlyOfficeSecurityError,
AgentAssetOnlyOfficeValidatedSession,
)
from app.services.agent_assets import AgentAssetService
from app.services.risk_rule_generation_jobs import RiskRuleGenerationJobService
router = APIRouter(prefix="/agent-assets")
DbSession = Annotated[Session, Depends(get_db)]
ActorHeader = Annotated[
str | None,
Header(description="审计操作者。未传时回退到请求体中的 owner / reviewer 或 `system`"),
Header(description="兼容旧客户端;审计主体始终以登录会话中的稳定身份为准"),
]
RequestIdHeader = Annotated[
str | None,
@@ -68,6 +60,24 @@ RuleEditorUser = Annotated[CurrentUserContext, Depends(require_rule_editor_user)
RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_user)]
def _actor(current_user: CurrentUserContext) -> str:
return stable_user_principal(current_user)
def _onlyoffice_service_for_session(
db: Session,
session: AgentAssetOnlyOfficeValidatedSession,
) -> AgentAssetService:
machine_user = CurrentUserContext(
username=session.actor,
name="ONLYOFFICE",
role_codes=["manager"],
is_admin=session.resource_scope == "platform",
tenant_id=session.tenant_id,
)
return AgentAssetService(db, current_user=machine_user)
def _handle_asset_error(exc: Exception) -> None:
if isinstance(exc, (LookupError, FileNotFoundError)):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -78,27 +88,6 @@ def _handle_asset_error(exc: Exception) -> None:
raise exc
def _complete_risk_rule_generation_task(
asset_id: str,
payload: dict,
actor: str,
request_id: str | None,
tenant_id: str,
) -> None:
db = get_session_factory()()
try:
body = AgentAssetRiskRuleGenerateRequest.model_validate(payload)
RiskRuleGenerationJobService(db).complete_rule_asset_generation(
asset_id,
body,
tenant_id=tenant_id,
actor=actor,
request_id=request_id,
)
finally:
db.close()
@router.get(
"",
response_model=list[AgentAssetListItem] | PaginatedResponse[AgentAssetListItem],
@@ -106,6 +95,7 @@ def _complete_risk_rule_generation_task(
description="按资产类型、状态、领域和关键字筛选规则、技能、MCP 与任务资产。",
)
def list_agent_assets(
current_user: CurrentUser,
db: DbSession,
asset_type: Annotated[
str | None,
@@ -126,7 +116,7 @@ def list_agent_assets(
page: PageNumber = None,
page_size: PageSize = None,
) -> list[AgentAssetListItem] | PaginatedResponse[AgentAssetListItem]:
service = AgentAssetService(db)
service = AgentAssetService(db, current_user=current_user)
if wants_page(page, page_size):
return page_payload(
service.list_assets_page(
@@ -158,204 +148,17 @@ def list_agent_assets(
}
},
)
def get_agent_asset(asset_id: str, db: DbSession) -> AgentAssetRead:
asset = AgentAssetService(db).get_asset(asset_id)
def get_agent_asset(
asset_id: str,
current_user: CurrentUser,
db: DbSession,
) -> AgentAssetRead:
asset = AgentAssetService(db, current_user=current_user).get_asset(asset_id)
if asset is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found")
return asset
@router.get(
"/{asset_id}/rule-json",
response_model=AgentAssetRuleJsonRead,
summary="读取风险规则 JSON",
description="读取 JSON 风险规则资产绑定的规则文件内容。",
)
def get_agent_asset_rule_json(
asset_id: str,
_: CurrentUser,
db: DbSession,
) -> AgentAssetRuleJsonRead:
try:
return AgentAssetService(db).read_rule_json(asset_id)
except Exception as exc:
_handle_asset_error(exc)
@router.get(
"/{asset_id}/risk-rule-tests/latest",
response_model=AgentAssetRiskRuleLatestTestSummary,
summary="读取风险规则最近测试摘要",
description="返回当前风险规则工作版本最近一次样例测试、场景试运行和测试报告。",
)
def get_agent_asset_risk_rule_latest_test(
asset_id: str,
_: CurrentUser,
db: DbSession,
) -> AgentAssetRiskRuleLatestTestSummary:
try:
return AgentAssetService(db).get_latest_risk_rule_test_summary(asset_id)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rule-tests/simulate",
response_model=AgentAssetRiskRuleSimulationRead,
summary="执行风险规则对话仿真",
description="基于临时对话输入和附件元信息执行风险识别,不创建业务单据,不写入测试记录。",
)
def simulate_agent_asset_risk_rule_test(
asset_id: str,
payload: AgentAssetRiskRuleSimulationRequest,
_: PlatformAdminUser,
db: DbSession,
) -> AgentAssetRiskRuleSimulationRead:
try:
return AgentAssetService(db).simulate_risk_rule_message(asset_id, payload)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rule-tests/sample",
response_model=AgentAssetRiskRuleTestRunRead,
summary="执行风险规则快速样例测试",
description="使用人工样例或系统默认样例执行当前 JSON 风险规则,不依赖大模型判断结果。",
)
def run_agent_asset_risk_rule_sample_test(
asset_id: str,
payload: AgentAssetRiskRuleSampleTestRequest,
current_user: PlatformAdminUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRiskRuleTestRunRead:
try:
return AgentAssetService(db).run_risk_rule_sample_test(
asset_id,
payload,
actor=(x_actor or current_user.name or "system").strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rule-tests/scenario",
response_model=AgentAssetRiskRuleTestRunRead,
summary="执行风险规则真实场景试运行",
description="按测试意图读取真实业务样本并沙盒执行风险规则,不写回业务单据。",
)
def run_agent_asset_risk_rule_scenario_test(
asset_id: str,
payload: AgentAssetRiskRuleScenarioTestRequest,
current_user: PlatformAdminUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRiskRuleTestRunRead:
try:
return AgentAssetService(db).run_risk_rule_scenario_test(
asset_id,
payload,
actor=(x_actor or current_user.name or "system").strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rule-tests/report",
response_model=AgentAssetRiskRuleTestRunRead,
summary="确认风险规则测试报告",
description="在样例测试和真实场景试运行通过后,保存当前版本测试通过记录。",
)
def confirm_agent_asset_risk_rule_test_report(
asset_id: str,
payload: AgentAssetRiskRuleReportRequest,
current_user: PlatformAdminUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRiskRuleTestRunRead:
try:
return AgentAssetService(db).confirm_risk_rule_test_report(
asset_id,
payload,
actor=(x_actor or current_user.name or "system").strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.put(
"/{asset_id}/rule-json",
response_model=AgentAssetRuleJsonRead,
summary="保存风险规则 JSON",
description="保存 JSON 风险规则资产绑定的规则文件内容,并写入审计日志。",
)
def save_agent_asset_rule_json(
asset_id: str,
payload: AgentAssetRuleJsonWrite,
current_user: RuleEditorUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRuleJsonRead:
try:
return AgentAssetService(db).write_rule_json(
asset_id,
body=payload,
actor=(x_actor or current_user.name or "system").strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/risk-rules/generate",
response_model=AgentAssetRead,
status_code=status.HTTP_201_CREATED,
summary="根据自然语言新建风险规则草稿",
description="根据业务域、自然语言描述和风险评分模型生成 JSON 风险规则,并保存为待上线草稿资产。",
)
def generate_agent_asset_risk_rule(
payload: AgentAssetRiskRuleGenerateRequest,
background_tasks: BackgroundTasks,
current_user: RuleReviewerUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
actor = (x_actor or current_user.name or "system").strip() or "system"
asset_id = RiskRuleGenerationJobService(db).enqueue_rule_asset_generation(
payload,
tenant_id=current_user.tenant_id,
actor=actor,
request_id=x_request_id,
)
background_tasks.add_task(
_complete_risk_rule_generation_task,
asset_id,
payload.model_dump(mode="json"),
actor,
x_request_id,
current_user.tenant_id,
)
asset = AgentAssetService(db).get_asset(asset_id)
if asset is None:
raise LookupError("Asset not found")
return asset
except Exception as exc:
_handle_asset_error(exc)
@router.get(
"/{asset_id}/spreadsheet/onlyoffice-config",
response_model=AgentAssetOnlyOfficeConfigRead,
@@ -372,7 +175,9 @@ def get_agent_asset_spreadsheet_onlyoffice_config(
] = None,
) -> AgentAssetOnlyOfficeConfigRead:
try:
return AgentAssetService(db).build_rule_spreadsheet_onlyoffice_config(
return AgentAssetService(
db, current_user=current_user
).build_rule_spreadsheet_onlyoffice_config(
asset_id,
current_user,
version=version,
@@ -389,7 +194,7 @@ def get_agent_asset_spreadsheet_onlyoffice_config(
)
def get_agent_asset_spreadsheet_content(
asset_id: str,
_: CurrentUser,
current_user: CurrentUser,
db: DbSession,
version: Annotated[
str | None,
@@ -397,10 +202,9 @@ def get_agent_asset_spreadsheet_content(
] = None,
) -> FileResponse:
try:
file_path, media_type, filename = AgentAssetService(db).get_rule_spreadsheet_content(
asset_id,
version=version,
)
file_path, media_type, filename = AgentAssetService(
db, current_user=current_user
).get_rule_spreadsheet_content(asset_id, version=version)
except Exception as exc:
_handle_asset_error(exc)
@@ -426,15 +230,19 @@ def get_agent_asset_spreadsheet_onlyoffice_content(
] = None,
) -> FileResponse:
try:
service = AgentAssetService(db)
service.validate_rule_spreadsheet_access_token(asset_id, access_token)
bootstrap_service = AgentAssetService(db)
validated_session = bootstrap_service.validate_rule_spreadsheet_access_token(
asset_id, access_token
)
service = _onlyoffice_service_for_session(db, validated_session)
file_path, media_type, filename = service.get_rule_spreadsheet_content(
asset_id,
version=version,
validated_session=validated_session,
)
except FileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ValueError as exc:
except AgentAssetOnlyOfficeSecurityError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
except Exception as exc:
_handle_asset_error(exc)
@@ -464,11 +272,11 @@ def upload_agent_asset_spreadsheet(
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
return AgentAssetService(db).upload_rule_spreadsheet(
return AgentAssetService(db, current_user=current_user).upload_rule_spreadsheet(
asset_id,
filename=filename,
content=content,
actor=current_user.name,
actor=_actor(current_user),
request_id=x_request_id,
)
except Exception as exc:
@@ -497,11 +305,13 @@ def import_agent_asset_spreadsheet_content(
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
return AgentAssetService(db).import_rule_spreadsheet_content(
return AgentAssetService(
db, current_user=current_user
).import_rule_spreadsheet_content(
asset_id,
filename=filename,
content=content,
actor=current_user.name,
actor=_actor(current_user),
request_id=x_request_id,
)
except Exception as exc:
@@ -518,22 +328,33 @@ def handle_agent_asset_spreadsheet_onlyoffice_callback(
asset_id: str,
payload: AgentAssetOnlyOfficeCallbackWrite,
db: DbSession,
access_token: Annotated[
str,
Query(min_length=1, description="ONLYOFFICE 回调专用短时令牌。"),
],
version: Annotated[
str | None,
Query(description="兼容旧 ONLYOFFICE 回调;当前表格模式不再使用。"),
] = None,
actor_name: Annotated[
str | None,
Query(description="发起编辑的用户显示名。"),
] = None,
) -> AgentAssetOnlyOfficeCallbackRead:
try:
AgentAssetService(db).handle_rule_spreadsheet_onlyoffice_callback(
bootstrap_service = AgentAssetService(db)
validated_session = bootstrap_service.validate_rule_spreadsheet_access_token(
asset_id,
access_token,
expected_scope=AGENT_ASSET_ONLYOFFICE_CALLBACK_SCOPE,
)
service = _onlyoffice_service_for_session(db, validated_session)
service.handle_rule_spreadsheet_onlyoffice_callback(
asset_id,
version=version,
payload=payload.model_dump(),
actor_name=actor_name,
callback_token=access_token,
)
except AgentAssetOnlyOfficeReplayError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except AgentAssetOnlyOfficeSecurityError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
except Exception as exc:
_handle_asset_error(exc)
@@ -548,12 +369,14 @@ def handle_agent_asset_spreadsheet_onlyoffice_callback(
)
def list_agent_asset_spreadsheet_change_records(
asset_id: str,
_: CurrentUser,
current_user: CurrentUser,
db: DbSession,
limit: Annotated[int, Query(ge=1, le=30, description="返回条数,最多 30 条。")] = 30,
) -> list[AgentAssetSpreadsheetChangeRecordRead]:
try:
return AgentAssetService(db).list_spreadsheet_change_records(asset_id, limit=limit)
return AgentAssetService(
db, current_user=current_user
).list_spreadsheet_change_records(asset_id, limit=limit)
except Exception as exc:
_handle_asset_error(exc)
@@ -579,9 +402,9 @@ def create_agent_asset(
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
return AgentAssetService(db).create_asset(
return AgentAssetService(db, current_user=current_user).create_asset(
payload,
actor=(x_actor or current_user.name or payload.owner).strip() or "system",
actor=_actor(current_user),
request_id=x_request_id,
)
except Exception as exc:
@@ -607,7 +430,7 @@ def create_agent_asset(
def update_agent_asset(
asset_id: str,
payload: AgentAssetUpdate,
current_user: CurrentUser,
current_user: RuleEditorUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
@@ -618,10 +441,10 @@ def update_agent_asset(
current_user.is_admin or "manager" in role_codes
):
raise PermissionError("只有高级管理员或 admin 管理员可以更改规则上线状态。")
return AgentAssetService(db).update_asset(
return AgentAssetService(db, current_user=current_user).update_asset(
asset_id,
payload,
actor=(x_actor or current_user.name or "system").strip() or "system",
actor=_actor(current_user),
request_id=x_request_id,
)
except Exception as exc:
@@ -642,6 +465,7 @@ def update_agent_asset(
)
def list_agent_asset_versions(
asset_id: str,
current_user: CurrentUser,
db: DbSession,
limit: Annotated[
int,
@@ -649,7 +473,9 @@ def list_agent_asset_versions(
] = 20,
) -> list[AgentAssetVersionRead]:
try:
return AgentAssetService(db).list_versions(asset_id, limit=limit)
return AgentAssetService(db, current_user=current_user).list_versions(
asset_id, limit=limit
)
except Exception as exc:
_handle_asset_error(exc)
@@ -659,7 +485,9 @@ def list_agent_asset_versions(
response_model=AgentAssetVersionRead,
status_code=status.HTTP_201_CREATED,
summary="创建资产版本",
description="为指定资产创建新版本;规则和任务源文件可使用 Markdown技能与 MCP 使用 JSON 快照。",
description=(
"为指定资产创建新版本;规则和任务源文件可使用 Markdown技能与 MCP 使用 JSON 快照。"
),
responses={
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
@@ -674,15 +502,17 @@ def list_agent_asset_versions(
def create_agent_asset_version(
asset_id: str,
payload: AgentAssetVersionCreate,
current_user: RuleEditorUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetVersionRead:
try:
return AgentAssetService(db).create_version(
payload = payload.model_copy(update={"created_by": _actor(current_user)})
return AgentAssetService(db, current_user=current_user).create_version(
asset_id,
payload,
actor=(x_actor or payload.created_by).strip() or "system",
actor=_actor(current_user),
request_id=x_request_id,
)
except Exception as exc:
@@ -721,10 +551,11 @@ def create_agent_asset_review(
raise PermissionError("只有财务人员或高级财务人员可以提交审核。")
elif not (current_user.is_admin or "manager" in role_codes):
raise PermissionError("只有高级财务人员可以审核规则。")
return AgentAssetService(db).create_review(
payload = payload.model_copy(update={"reviewer": _actor(current_user)})
return AgentAssetService(db, current_user=current_user).create_review(
asset_id,
payload,
actor=(x_actor or payload.reviewer).strip() or "system",
actor=_actor(current_user),
request_id=x_request_id,
)
except Exception as exc:
@@ -749,121 +580,17 @@ def create_agent_asset_review(
)
def activate_agent_asset(
asset_id: str,
_: RuleReviewerUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
return AgentAssetService(db).activate_asset(
asset_id,
actor=(x_actor or "system").strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rule-enabled",
response_model=AgentAssetRead,
summary="设置风险规则启用状态",
description=(
"高级财务人员可独立启用或停用 JSON 风险规则;停用后即使已上线也不会进入真实业务扫描。"
),
)
def set_agent_asset_risk_rule_enabled(
asset_id: str,
payload: AgentAssetRiskRuleEnabledUpdate,
current_user: RuleReviewerUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
asset = AgentAssetService(db).set_risk_rule_enabled(
return AgentAssetService(db, current_user=current_user).activate_asset(
asset_id,
enabled=payload.enabled,
actor=(x_actor or current_user.name or "system").strip() or "system",
actor=_actor(current_user),
request_id=x_request_id,
)
detail = AgentAssetService(db).get_asset(asset.id)
if detail is None:
raise LookupError("Asset not found")
return detail
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rule-level",
response_model=AgentAssetRead,
summary="风险规则风险等级已由评分模型接管",
description="风险规则等级和分数由自然语言规则评分模型生成,不再允许人工调整。",
)
def set_agent_asset_risk_rule_level(
asset_id: str,
payload: AgentAssetRiskRuleLevelUpdate,
current_user: RuleEditorUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
del asset_id, payload, current_user, db, x_actor, x_request_id
raise ValueError("风险等级和分数由评分模型自动计算,不能手动修改。")
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/return",
response_model=AgentAssetRiskRuleLatestTestSummary,
summary="回退待审核风险规则",
description="高级财务人员将待审核风险规则回退到草稿,并记录回退原因。",
)
def return_agent_asset_risk_rule(
asset_id: str,
payload: AgentAssetRiskRuleReturnRequest,
current_user: RuleReviewerUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRiskRuleLatestTestSummary:
try:
return AgentAssetService(db).return_risk_rule(
asset_id,
note=payload.note,
actor=(x_actor or current_user.name or "system").strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/publish",
response_model=AgentAssetRead,
summary="审核并发布风险规则",
description="高级财务人员确认测试通过后,将待审核风险规则一次性审核通过并发布上线。",
)
def publish_agent_asset_risk_rule(
asset_id: str,
current_user: RuleReviewerUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
asset = AgentAssetService(db).publish_risk_rule(
asset_id,
actor=(x_actor or current_user.name or "system").strip() or "system",
request_id=x_request_id,
)
detail = AgentAssetService(db).get_asset(asset.id)
if detail is None:
raise LookupError("Asset not found")
return detail
except Exception as exc:
_handle_asset_error(exc)
@@ -882,9 +609,9 @@ def delete_agent_asset(
x_request_id: RequestIdHeader = None,
) -> None:
try:
AgentAssetService(db).delete_unpublished_asset(
AgentAssetService(db, current_user=current_user).delete_unpublished_asset(
asset_id,
actor=(x_actor or current_user.name or "system").strip() or "system",
actor=_actor(current_user),
request_id=x_request_id,
)
except Exception as exc:
@@ -906,10 +633,12 @@ def restore_agent_asset_version(
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
return AgentAssetService(db).restore_version_as_working_copy(
return AgentAssetService(
db, current_user=current_user
).restore_version_as_working_copy(
asset_id,
version,
actor=(x_actor or current_user.name or "system").strip() or "system",
actor=_actor(current_user),
request_id=x_request_id,
)
except Exception as exc:
@@ -924,11 +653,11 @@ def restore_agent_asset_version(
)
def get_agent_asset_version_timeline(
asset_id: str,
_: CurrentUser,
current_user: CurrentUser,
db: DbSession,
) -> list[AgentAssetVersionTimelineItemRead]:
try:
return AgentAssetService(db).list_version_timeline(asset_id)
return AgentAssetService(db, current_user=current_user).list_version_timeline(asset_id)
except Exception as exc:
_handle_asset_error(exc)
@@ -999,22 +728,25 @@ def list_golden_cases(
def run_golden_eval(
asset_id: str,
body: GoldenEvalRequest,
_: RuleReviewerUser,
current_user: RuleReviewerUser,
db: DbSession,
) -> GoldenEvalRead:
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.risk_rule_golden_evaluator import RiskRuleGoldenEvaluator
try:
asset = AgentAssetService(db).get_asset(asset_id)
service = AgentAssetService(db, current_user=current_user)
asset = service.get_asset(asset_id)
if asset is None:
raise LookupError("Asset not found")
config = asset.config_json if isinstance(asset.config_json, dict) else {}
rule_document = config.get("rule_document") if isinstance(config.get("rule_document"), dict) else {}
rule_document = (
config.get("rule_document") if isinstance(config.get("rule_document"), dict) else {}
)
file_name = str(rule_document.get("file_name") or "").strip()
if not file_name:
raise ValueError("该规则没有可执行的 manifest 文件。")
manager = AgentAssetService(db).rule_library_manager
manager = service.rule_library_manager
manifest = manager.read_rule_library_json(library=RISK_RULES_LIBRARY, file_name=file_name)
rule_code = str(manifest.get("rule_code") or "").strip()
if not rule_code:

View File

@@ -5,13 +5,15 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.agent_run import AgentRunRead, AgentRunStatsRead
from app.schemas.common import ErrorResponse
from app.services.agent_run_access_policy import AgentRunAccessPolicy
from app.services.agent_runs import AgentRunService
router = APIRouter(prefix="/agent-runs", dependencies=[Depends(get_current_user)])
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@router.get(
@@ -22,6 +24,7 @@ DbSession = Annotated[Session, Depends(get_db)]
)
def list_agent_runs(
db: DbSession,
current_user: CurrentUser,
agent: Annotated[
str | None,
Query(description="Agent 名称筛选。"),
@@ -39,9 +42,17 @@ def list_agent_runs(
Query(ge=1, le=100, description="返回记录上限。"),
] = 20,
) -> list[AgentRunRead]:
return AgentRunService(db).list_runs(
agent=agent, status=status_value, source=source, limit=limit
tenant_id = AgentRunAccessPolicy.require_current_tenant_id(current_user)
scope_clause = AgentRunAccessPolicy.build_query_scope(current_user)
runs = AgentRunService(db).list_runs_for_tenant(
tenant_id=tenant_id,
agent=agent,
status=status_value,
source=source,
limit=limit,
scope_clause=scope_clause,
)
return AgentRunAccessPolicy.filter_list_items(runs, current_user, db)
@router.get(
@@ -52,6 +63,7 @@ def list_agent_runs(
)
def summarize_agent_runs(
db: DbSession,
current_user: CurrentUser,
agent: Annotated[
str | None,
Query(description="Agent 名称筛选。"),
@@ -69,11 +81,15 @@ def summarize_agent_runs(
Query(ge=1, le=500, description="统计最近记录数。"),
] = 200,
) -> AgentRunStatsRead:
return AgentRunService(db).summarize_runs(
tenant_id = AgentRunAccessPolicy.require_current_tenant_id(current_user)
scope_clause = AgentRunAccessPolicy.build_query_scope(current_user)
return AgentRunService(db).summarize_runs_for_tenant(
tenant_id=tenant_id,
agent=agent,
status=status_value,
source=source,
limit=limit,
scope_clause=scope_clause,
)
@@ -89,8 +105,17 @@ def summarize_agent_runs(
}
},
)
def get_agent_run(run_id: str, db: DbSession) -> AgentRunRead:
run = AgentRunService(db).get_run(run_id)
def get_agent_run(
run_id: str,
db: DbSession,
current_user: CurrentUser,
) -> AgentRunRead:
tenant_id = AgentRunAccessPolicy.require_current_tenant_id(current_user)
run = AgentRunService(db).get_run_for_tenant(
run_id,
tenant_id=tenant_id,
)
if run is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Run not found")
AgentRunAccessPolicy.require_detail_read(run, current_user)
return run

View File

@@ -6,16 +6,18 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.digital_employee_dashboard import DigitalEmployeeDashboardRead
from app.schemas.finance_dashboard import FinanceDashboardRead
from app.schemas.system_dashboard import SystemDashboardRead
from app.services.digital_employee_dashboard import DigitalEmployeeDashboardService
from app.services.finance_dashboard_access_policy import FinanceDashboardAccessPolicy
from app.services.finance_dashboard_snapshot import FinanceDashboardSnapshotService
from app.services.system_dashboard import SystemDashboardService
router = APIRouter(prefix="/analytics", dependencies=[Depends(get_current_user)])
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@router.get(
@@ -42,6 +44,7 @@ def get_system_dashboard(
)
def get_digital_employee_dashboard(
db: DbSession,
current_user: CurrentUser,
days: Annotated[
int,
Query(ge=1, le=30, description="统计窗口天数。"),
@@ -51,7 +54,10 @@ def get_digital_employee_dashboard(
Query(ge=1, le=1000, description="窗口内最多读取的运行记录数。"),
] = 300,
) -> DigitalEmployeeDashboardRead:
return DigitalEmployeeDashboardService(db).build_dashboard(days=days, limit=limit)
return DigitalEmployeeDashboardService(
db,
tenant_id=current_user.tenant_id,
).build_dashboard(days=days, limit=limit)
@router.get(
@@ -62,17 +68,20 @@ def get_digital_employee_dashboard(
)
def get_finance_dashboard(
db: DbSession,
current_user: CurrentUser,
range_key: Annotated[str, Query(max_length=30, description="顶部时间范围。")] = "近10日",
start_date: Annotated[date | None, Query(description="自定义开始日期。")] = None,
end_date: Annotated[date | None, Query(description="自定义结束日期。")] = None,
trend_range: Annotated[str, Query(max_length=30, description="趋势图时间范围。")] = (
"近12天"
),
trend_range: Annotated[str, Query(max_length=30, description="趋势图时间范围。")] = ("近12天"),
department_range: Annotated[str, Query(max_length=30, description="排行分析时间范围。")] = (
"本月"
),
) -> FinanceDashboardRead:
return FinanceDashboardSnapshotService(db).build_dashboard(
FinanceDashboardAccessPolicy.require_read(current_user)
return FinanceDashboardSnapshotService(
db,
tenant_id=current_user.tenant_id,
).build_dashboard(
range_key=range_key,
start_date=start_date,
end_date=end_date,

View File

@@ -53,7 +53,10 @@ def get_current_auth_user(
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
db: DbSession,
) -> AuthUserRead:
user = AuthService(db).get_user_snapshot(current_user.username)
user = AuthService(db).get_user_snapshot(
current_user.username,
tenant_id=current_user.tenant_id,
)
if user is not None:
return user
@@ -78,6 +81,7 @@ def get_current_auth_user(
),
avatar=name[:1].upper(),
isAdmin=True,
tenantId=current_user.tenant_id,
)
raise HTTPException(

View File

@@ -0,0 +1,73 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.cfo_value import CfoValueDashboardRead, CfoValueFiltersRead
from app.services.cfo_value_analytics import CfoValueAnalyticsService
from app.services.savings_access_policy import SavingsPermissionError
router = APIRouter(prefix="/analytics")
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@router.get(
"/cfo-value",
response_model=CfoValueDashboardRead,
summary="查询 CFO 经营价值看板",
description=(
"仅汇总 Savings Ledger 中有基线、证据、去重和独立财务确认的价值事实;"
"风险暴露、预计机会和待确认结果不会混入已确认现金节省。"
),
)
def get_cfo_value_dashboard(
db: DbSession,
current_user: CurrentUser,
start: datetime | None = None,
end: datetime | None = None,
as_of: datetime | None = None,
department_id: Annotated[str | None, Query(max_length=160)] = None,
project_code: Annotated[str | None, Query(max_length=160)] = None,
expense_type: Annotated[str | None, Query(max_length=80)] = None,
supplier_id: Annotated[str | None, Query(max_length=160)] = None,
city: Annotated[str | None, Query(max_length=160)] = None,
owner_id: Annotated[str | None, Query(max_length=120)] = None,
source_type: Annotated[str | None, Query(max_length=50)] = None,
value_kind: Annotated[Literal["cash", "labor"] | None, Query()] = None,
) -> CfoValueDashboardRead:
now = datetime.now(UTC)
normalized_end = end or now
normalized_start = start or (normalized_end - timedelta(days=90))
normalized_as_of = as_of or now
try:
return CfoValueAnalyticsService(db).build_dashboard(
current_user,
start=normalized_start,
end=normalized_end,
as_of=normalized_as_of,
filters=CfoValueFiltersRead(
department_id=department_id,
project_code=project_code,
expense_type=expense_type,
supplier_id=supplier_id,
city=city,
owner_id=owner_id,
source_type=source_type,
value_kind=value_kind,
),
)
except SavingsPermissionError as error:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(error),
) from error
except (ValueError, PermissionError) as error:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(error),
) from error

View File

@@ -0,0 +1,587 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.deps import (
CurrentUserContext,
get_current_user,
get_db,
require_platform_admin_user,
)
from app.schemas.commercial import (
CommercialAccountRead,
CommercialAnalyticsRead,
CommercialCostEventCreate,
CommercialCostEventRead,
CommercialEntitlementRead,
CommercialEntitlementUpsert,
CommercialMutationRead,
CommercialPlanActivationRead,
CommercialPlanCreate,
CommercialPlanRead,
CommercialPricingScenarioRead,
CommercialPricingScenarioWrite,
CommercialSubscriptionCreate,
CommercialSubscriptionRead,
CommercialSubscriptionTransition,
CommercialVersionAction,
UsageMeterEventCreate,
UsageMeterEventRead,
)
from app.services.commercial_access_policy import (
CommercialAccessPolicy,
CommercialConfigurationError,
CommercialConflictError,
CommercialPermissionError,
)
from app.services.commercial_admin import CommercialAdminService
from app.services.commercial_analytics import CommercialAnalyticsService
from app.services.commercial_entitlements import CommercialEntitlementService
from app.services.commercial_metering import CommercialMeteringService
from app.services.commercial_pricing import CommercialPricingService
from app.services.commercial_queries import CommercialQueryService
router = APIRouter(prefix="/commercial")
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
PlatformAdmin = Annotated[CurrentUserContext, Depends(require_platform_admin_user)]
RequestId = Annotated[
str,
Header(alias="X-Request-Id", min_length=1, max_length=120),
]
@router.get("/account", response_model=CommercialAccountRead, summary="读取当前租户商业账户")
def get_current_commercial_account(
db: DbSession,
current_user: CurrentUser,
as_of: datetime | None = None,
) -> CommercialAccountRead:
try:
return CommercialEntitlementService(db).get_account(current_user, as_of=as_of)
except Exception as error:
raise _http_error(error) from error
@router.get(
"/admin/tenants/{tenant_id}/account",
response_model=CommercialAccountRead,
summary="平台管理员读取租户商业账户",
)
def get_tenant_commercial_account(
tenant_id: str,
db: DbSession,
current_user: PlatformAdmin,
as_of: datetime | None = None,
) -> CommercialAccountRead:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
return CommercialEntitlementService(db).get_account_for_tenant(target, as_of=as_of)
@router.post(
"/admin/tenants/{tenant_id}/plans",
response_model=CommercialPlanRead,
status_code=status.HTTP_201_CREATED,
summary="创建租户套餐新版本",
)
def create_commercial_plan(
tenant_id: str,
payload: CommercialPlanCreate,
db: DbSession,
current_user: PlatformAdmin,
request_id: RequestId,
) -> CommercialPlanRead:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
row = CommercialAdminService(db).create_plan(
target,
payload,
actor_id=current_user.username,
request_id=request_id,
reason=payload.reason,
)
db.commit()
db.refresh(row)
return row
except Exception as error:
db.rollback()
raise _http_error(error) from error
@router.post(
"/admin/tenants/{tenant_id}/plans/{plan_id}/activate",
response_model=CommercialPlanActivationRead,
summary="激活套餐版本并退役同编码旧版本",
)
def activate_commercial_plan(
tenant_id: str,
plan_id: str,
payload: CommercialVersionAction,
db: DbSession,
current_user: PlatformAdmin,
request_id: RequestId,
) -> CommercialPlanActivationRead:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
result = CommercialAdminService(db).activate_plan(
target,
plan_id,
expected_version=payload.expected_version,
actor_id=current_user.username,
request_id=request_id,
reason=payload.reason,
)
db.commit()
return result
except Exception as error:
db.rollback()
raise _http_error(error) from error
@router.post(
"/admin/tenants/{tenant_id}/subscriptions",
response_model=CommercialSubscriptionRead,
status_code=status.HTTP_201_CREATED,
summary="创建并激活租户订阅快照",
)
def create_commercial_subscription(
tenant_id: str,
payload: CommercialSubscriptionCreate,
db: DbSession,
current_user: PlatformAdmin,
request_id: RequestId,
) -> CommercialSubscriptionRead:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
row = CommercialAdminService(db).create_subscription(
target,
payload,
actor_id=current_user.username,
request_id=request_id,
reason=payload.reason,
)
db.commit()
db.refresh(row)
return row
except Exception as error:
db.rollback()
raise _http_error(error) from error
@router.post(
"/admin/tenants/{tenant_id}/subscriptions/{subscription_id}/activate",
response_model=CommercialSubscriptionRead,
summary="重新激活可恢复的租户订阅",
)
def activate_commercial_subscription(
tenant_id: str,
subscription_id: str,
payload: CommercialVersionAction,
db: DbSession,
current_user: PlatformAdmin,
request_id: RequestId,
) -> CommercialSubscriptionRead:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
row = CommercialAdminService(db).activate_subscription(
target,
subscription_id,
expected_version=payload.expected_version,
actor_id=current_user.username,
request_id=request_id,
reason=payload.reason,
)
db.commit()
db.refresh(row)
return row
except Exception as error:
db.rollback()
raise _http_error(error) from error
@router.post(
"/admin/tenants/{tenant_id}/subscriptions/{subscription_id}/transition",
response_model=CommercialSubscriptionRead,
summary="暂停、标记逾期或终止租户订阅",
)
def transition_commercial_subscription(
tenant_id: str,
subscription_id: str,
payload: CommercialSubscriptionTransition,
db: DbSession,
current_user: PlatformAdmin,
request_id: RequestId,
) -> CommercialSubscriptionRead:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
row = CommercialAdminService(db).transition_subscription(
target,
subscription_id,
payload,
actor_id=current_user.username,
request_id=request_id,
)
db.commit()
db.refresh(row)
return row
except Exception as error:
db.rollback()
raise _http_error(error) from error
@router.get(
"/admin/tenants/{tenant_id}/plans",
response_model=list[CommercialPlanRead],
summary="查询租户套餐版本历史",
)
def list_commercial_plans(
tenant_id: str,
db: DbSession,
current_user: PlatformAdmin,
plan_status: str | None = None,
limit: int = Query(default=100, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> list[CommercialPlanRead]:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
return CommercialQueryService(db).list_plans(
target,
status=plan_status,
limit=limit,
offset=offset,
)
@router.get(
"/admin/tenants/{tenant_id}/subscriptions",
response_model=list[CommercialSubscriptionRead],
summary="查询租户订阅历史",
)
def list_commercial_subscriptions(
tenant_id: str,
db: DbSession,
current_user: PlatformAdmin,
subscription_status: str | None = None,
limit: int = Query(default=100, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> list[CommercialSubscriptionRead]:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
return CommercialQueryService(db).list_subscriptions(
target,
status=subscription_status,
limit=limit,
offset=offset,
)
@router.get(
"/admin/tenants/{tenant_id}/entitlements",
response_model=list[CommercialEntitlementRead],
summary="查询租户商业权益历史",
)
def list_commercial_entitlements(
tenant_id: str,
db: DbSession,
current_user: PlatformAdmin,
subscription_id: str | None = None,
billing_period_id: str | None = None,
entitlement_status: str | None = None,
limit: int = Query(default=200, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[CommercialEntitlementRead]:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
return CommercialQueryService(db).list_entitlements(
target,
subscription_id=subscription_id,
billing_period_id=billing_period_id,
status=entitlement_status,
limit=limit,
offset=offset,
)
@router.get(
"/admin/tenants/{tenant_id}/usage-events",
response_model=list[UsageMeterEventRead],
summary="查询租户追加式用量事实",
)
def list_commercial_usage_events(
tenant_id: str,
db: DbSession,
current_user: PlatformAdmin,
subscription_id: str | None = None,
billing_period_id: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
limit: int = Query(default=200, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[UsageMeterEventRead]:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
return CommercialQueryService(db).list_usage_events(
target,
subscription_id=subscription_id,
billing_period_id=billing_period_id,
start=start,
end=end,
limit=limit,
offset=offset,
)
except Exception as error:
raise _http_error(error) from error
@router.get(
"/admin/tenants/{tenant_id}/cost-events",
response_model=list[CommercialCostEventRead],
summary="查询租户追加式内部成本事实",
)
def list_commercial_cost_events(
tenant_id: str,
db: DbSession,
current_user: PlatformAdmin,
subscription_id: str | None = None,
billing_period_id: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
limit: int = Query(default=200, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[CommercialCostEventRead]:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
return CommercialQueryService(db).list_cost_events(
target,
subscription_id=subscription_id,
billing_period_id=billing_period_id,
start=start,
end=end,
limit=limit,
offset=offset,
)
except Exception as error:
raise _http_error(error) from error
@router.put(
"/admin/tenants/{tenant_id}/entitlements",
response_model=CommercialEntitlementRead,
summary="创建或版本化更新租户商业权益",
)
def upsert_commercial_entitlement(
tenant_id: str,
payload: CommercialEntitlementUpsert,
db: DbSession,
current_user: PlatformAdmin,
request_id: RequestId,
) -> CommercialEntitlementRead:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
row = CommercialAdminService(db).upsert_entitlement(
target,
payload,
actor_id=current_user.username,
request_id=request_id,
reason=payload.reason,
)
db.commit()
db.refresh(row)
return row
except Exception as error:
db.rollback()
raise _http_error(error) from error
@router.post(
"/admin/tenants/{tenant_id}/entitlements/{entitlement_id}/activate",
response_model=CommercialEntitlementRead,
summary="激活租户商业权益",
)
def activate_commercial_entitlement(
tenant_id: str,
entitlement_id: str,
payload: CommercialVersionAction,
db: DbSession,
current_user: PlatformAdmin,
request_id: RequestId,
) -> CommercialEntitlementRead:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
row = CommercialAdminService(db).activate_entitlement(
target,
entitlement_id,
expected_version=payload.expected_version,
actor_id=current_user.username,
request_id=request_id,
reason=payload.reason,
)
db.commit()
db.refresh(row)
return row
except Exception as error:
db.rollback()
raise _http_error(error) from error
@router.post(
"/admin/tenants/{tenant_id}/usage-events",
response_model=CommercialMutationRead,
summary="幂等写入租户用量事件",
)
def record_commercial_usage(
tenant_id: str,
payload: UsageMeterEventCreate,
db: DbSession,
current_user: PlatformAdmin,
) -> CommercialMutationRead:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
row, created = CommercialMeteringService(db).record_usage(
target,
payload,
actor_type="admin",
actor_id=current_user.username,
)
db.commit()
return CommercialMutationRead(created=created, usage_event=row)
except Exception as error:
db.rollback()
raise _http_error(error) from error
@router.post(
"/admin/tenants/{tenant_id}/cost-events",
response_model=CommercialMutationRead,
summary="幂等写入平台内部成本事件",
)
def record_commercial_cost(
tenant_id: str,
payload: CommercialCostEventCreate,
db: DbSession,
current_user: PlatformAdmin,
) -> CommercialMutationRead:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
row, created = CommercialMeteringService(db).record_cost(target, payload)
db.commit()
return CommercialMutationRead(created=created, cost_event=row)
except Exception as error:
db.rollback()
raise _http_error(error) from error
@router.get(
"/admin/tenants/{tenant_id}/analytics",
response_model=CommercialAnalyticsRead,
summary="平台商业与客户价值分账分析",
)
def get_commercial_analytics(
tenant_id: str,
db: DbSession,
current_user: PlatformAdmin,
start: datetime | None = None,
end: datetime | None = None,
as_of: datetime | None = None,
) -> CommercialAnalyticsRead:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
now = datetime.now(UTC)
normalized_end = end or now
normalized_start = start or (normalized_end - timedelta(days=90))
try:
return CommercialAnalyticsService(db).build(
target,
start=normalized_start,
end=normalized_end,
as_of=as_of or now,
)
except Exception as error:
raise _http_error(error) from error
@router.post(
"/admin/tenants/{tenant_id}/pricing-scenarios",
response_model=CommercialPricingScenarioRead,
summary="按真实成本与确认价值计算可持续定价走廊",
)
def build_commercial_pricing_scenario(
tenant_id: str,
payload: CommercialPricingScenarioWrite,
db: DbSession,
current_user: PlatformAdmin,
) -> CommercialPricingScenarioRead:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
return CommercialPricingService(db).build(target, payload)
except Exception as error:
raise _http_error(error) from error
def _http_error(error: Exception) -> HTTPException:
if isinstance(error, LookupError):
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error))
if isinstance(error, CommercialPermissionError):
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(error))
if isinstance(error, CommercialConflictError):
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error))
if isinstance(error, IntegrityError):
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="商业配置被并发修改或违反数据库唯一性约束,请刷新后重试。",
)
if isinstance(error, (CommercialConfigurationError, ValueError)):
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error))
raise error

View File

@@ -0,0 +1,142 @@
"""商业不可变账期和管理审计历史。"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.api.deps import (
CurrentUserContext,
get_current_user,
get_db,
require_platform_admin_user,
)
from app.schemas.commercial_billing import (
CommercialAdminEventRead,
CommercialBillingPeriodRead,
CommercialBillingPeriodTenantRead,
)
from app.services.commercial_access_policy import (
CommercialAccessPolicy,
CommercialPermissionError,
)
from app.services.commercial_billing_periods import CommercialBillingPeriodService
from app.services.commercial_queries import CommercialQueryService
router = APIRouter(prefix="/commercial")
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
PlatformAdmin = Annotated[CurrentUserContext, Depends(require_platform_admin_user)]
@router.get(
"/billing-periods",
response_model=list[CommercialBillingPeriodTenantRead],
summary="读取当前租户不可变账期历史",
)
def list_current_tenant_billing_periods(
db: DbSession,
current_user: CurrentUser,
subscription_id: str | None = None,
as_of: datetime | None = None,
limit: int = Query(default=200, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[CommercialBillingPeriodTenantRead]:
try:
tenant_id = CommercialAccessPolicy.require_account_read(current_user)
rows = _period_reads(
db,
tenant_id,
subscription_id=subscription_id,
as_of=as_of,
limit=limit,
offset=offset,
)
return [CommercialBillingPeriodTenantRead.model_validate(row.model_dump()) for row in rows]
except CommercialPermissionError as error:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(error)) from error
@router.get(
"/admin/tenants/{tenant_id}/billing-periods",
response_model=list[CommercialBillingPeriodRead],
summary="平台管理员读取租户不可变账期历史",
)
def list_tenant_billing_periods(
tenant_id: str,
db: DbSession,
current_user: PlatformAdmin,
subscription_id: str | None = None,
as_of: datetime | None = None,
limit: int = Query(default=200, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[CommercialBillingPeriodRead]:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
return _period_reads(
db,
target,
subscription_id=subscription_id,
as_of=as_of,
limit=limit,
offset=offset,
)
@router.get(
"/admin/tenants/{tenant_id}/admin-events",
response_model=list[CommercialAdminEventRead],
summary="平台管理员读取商业配置与续期审计",
)
def list_tenant_commercial_admin_events(
tenant_id: str,
db: DbSession,
current_user: PlatformAdmin,
action: str | None = None,
resource_type: str | None = None,
resource_id: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
limit: int = Query(default=200, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[CommercialAdminEventRead]:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
return CommercialQueryService(db).list_admin_events(
target,
action=action,
resource_type=resource_type,
resource_id=resource_id,
start=start,
end=end,
limit=limit,
offset=offset,
)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
def _period_reads(
db: Session,
tenant_id: str,
*,
subscription_id: str | None,
as_of: datetime | None,
limit: int,
offset: int,
) -> list[CommercialBillingPeriodRead]:
rows = CommercialQueryService(db).list_billing_periods(
tenant_id,
subscription_id=subscription_id,
limit=limit,
offset=offset,
)
return [CommercialBillingPeriodService.to_read(row, as_of=as_of) for row in rows]

View File

@@ -2,12 +2,13 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.schemas.employee_profile import EmployeeProfileLatestRead
from app.services.account_behavior_profile import AccountBehaviorProfileService
from app.services.employee_behavior_profile_service import EmployeeBehaviorProfileService
@@ -32,7 +33,10 @@ def get_current_employee_latest_profile(
) -> EmployeeProfileLatestRead:
employee = _resolve_current_employee(db, current_user)
if employee is None:
return AccountBehaviorProfileService(db).get_latest_account_profile(
return AccountBehaviorProfileService(
db,
tenant_id=current_user.tenant_id,
).get_latest_account_profile(
account_id=current_user.username,
account_name=current_user.name,
identifiers=_current_account_identifiers(current_user),
@@ -41,7 +45,7 @@ def get_current_employee_latest_profile(
expense_type_scope=expense_type_scope,
)
service = EmployeeBehaviorProfileService(db)
service = EmployeeBehaviorProfileService(db, tenant_id=current_user.tenant_id)
latest = service.get_latest_profile(
employee_id=employee.id,
scene=scene,
@@ -65,13 +69,13 @@ def get_current_employee_latest_profile(
@router.get(
"/{employee_id}/latest",
"/{target_employee_id}/latest",
response_model=EmployeeProfileLatestRead,
summary="读取员工最新业务行为画像",
description="返回员工在指定场景下的最新画像快照,审批场景默认只展示费用支出和流程质量画像。",
)
def get_employee_latest_profile(
employee_id: str,
target_employee_id: str,
db: DbSession,
current_user: CurrentUser,
scene: Annotated[str, Query(max_length=50)] = "approval",
@@ -79,9 +83,25 @@ def get_employee_latest_profile(
window_days: Annotated[int, Query(ge=1, le=365)] = 90,
expense_type_scope: Annotated[str, Query(max_length=50)] = "overall",
) -> EmployeeProfileLatestRead:
del current_user
return EmployeeBehaviorProfileService(db).get_latest_profile(
employee_id=employee_id,
target = _resolve_tenant_employee(
db,
current_user.tenant_id,
target_employee_id,
)
if target is None or not _can_read_target(db, current_user, target):
_raise_not_found()
if claim_id and not _claim_matches_target(
db,
tenant_id=current_user.tenant_id,
claim_id=claim_id,
employee_id=target.id,
):
_raise_not_found()
return EmployeeBehaviorProfileService(
db,
tenant_id=current_user.tenant_id,
).get_latest_profile(
employee_id=target.id,
scene=scene,
claim_id=claim_id,
window_days=window_days,
@@ -93,7 +113,11 @@ def _resolve_current_employee(
db: Session,
current_user: CurrentUserContext,
) -> Employee | None:
tenant_id = str(current_user.tenant_id or "").strip()
if not tenant_id:
return None
identities = [
str(current_user.employee_id or "").strip(),
str(current_user.username or "").strip(),
str(current_user.name or "").strip(),
]
@@ -108,16 +132,95 @@ def _resolve_current_employee(
if email_values:
conditions.append(func.lower(Employee.email).in_(email_values))
if exact_values:
conditions.append(Employee.id.in_(exact_values))
conditions.append(Employee.name.in_(exact_values))
conditions.append(Employee.employee_no.in_(exact_values))
if not conditions:
return None
stmt = select(Employee).where(or_(*conditions)).order_by(Employee.created_at.asc()).limit(1)
stmt = (
select(Employee)
.where(
Employee.tenant_id == tenant_id,
or_(*conditions),
)
.order_by(Employee.created_at.asc())
.limit(1)
)
return db.scalars(stmt).first()
def _resolve_tenant_employee(
db: Session,
tenant_id: str,
identifier: str,
) -> Employee | None:
normalized_tenant = str(tenant_id or "").strip()
normalized = str(identifier or "").strip()
if not normalized_tenant or not normalized:
return None
conditions = [
Employee.id == normalized,
Employee.employee_no == normalized,
Employee.name == normalized,
]
if "@" in normalized:
conditions.append(func.lower(Employee.email) == normalized.lower())
return db.scalars(
select(Employee)
.where(
Employee.tenant_id == normalized_tenant,
or_(*conditions),
)
.order_by(Employee.created_at.asc())
.limit(1)
).first()
def _can_read_target(
db: Session,
current_user: CurrentUserContext,
target: Employee,
) -> bool:
current = _resolve_current_employee(db, current_user)
if current is not None and current.id == target.id:
return True
role_codes = {str(item or "").strip().lower() for item in current_user.role_codes or []}
if current_user.is_admin or role_codes & {"finance", "executive"}:
return True
return bool(
current is not None
and role_codes & {"manager", "approver"}
and target.manager_id == current.id
)
def _claim_matches_target(
db: Session,
*,
tenant_id: str,
claim_id: str,
employee_id: str,
) -> bool:
return bool(
db.scalar(
select(ExpenseClaim.id).where(
ExpenseClaim.tenant_id == str(tenant_id or "").strip(),
ExpenseClaim.id == str(claim_id or "").strip(),
ExpenseClaim.employee_id == employee_id,
)
)
)
def _raise_not_found() -> None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="员工画像不存在。",
)
def _missing_usage_duration_metric(latest: EmployeeProfileLatestRead) -> bool:
if latest.scene != "operations":
return False

View File

@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile,
from fastapi.responses import Response
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_admin_user
from app.api.deps import CurrentUserContext, get_db, require_admin_user
from app.api.pagination import PageNumber, PageSize, page_payload, wants_page
from app.schemas.common import ErrorResponse, PaginatedResponse
from app.schemas.employee import (
@@ -21,6 +21,11 @@ from app.services.employee_pagination import EmployeePaginationService
router = APIRouter(dependencies=[Depends(require_admin_user)])
DbSession = Annotated[Session, Depends(get_db)]
AdminUser = Annotated[CurrentUserContext, Depends(require_admin_user)]
def _employee_service(db: Session, current_user: CurrentUserContext) -> EmployeeService:
return EmployeeService(db, tenant_id=current_user.tenant_id)
@router.get(
@@ -29,8 +34,8 @@ DbSession = Annotated[Session, Depends(get_db)]
summary="读取员工目录元数据",
description="返回员工总数、状态汇总和可选角色列表,供员工管理页面初始化使用。",
)
def get_employee_meta(db: DbSession) -> EmployeeMetaRead:
return EmployeeService(db).get_employee_meta()
def get_employee_meta(db: DbSession, current_user: AdminUser) -> EmployeeMetaRead:
return _employee_service(db, current_user).get_employee_meta()
@router.get(
@@ -41,6 +46,7 @@ def get_employee_meta(db: DbSession) -> EmployeeMetaRead:
)
def list_employees(
db: DbSession,
current_user: AdminUser,
status_filter: Annotated[
str | None,
Query(alias="status", description="员工状态筛选值。"),
@@ -54,14 +60,20 @@ def list_employees(
) -> list[EmployeeRead] | PaginatedResponse[EmployeeRead]:
if wants_page(page, page_size):
return page_payload(
EmployeePaginationService(db).list_employees_page(
EmployeePaginationService(
db,
tenant_id=current_user.tenant_id,
).list_employees_page(
status=status_filter,
keyword=keyword,
page=page,
page_size=page_size,
)
)
return EmployeeService(db).list_employees(status=status_filter, keyword=keyword)
return _employee_service(db, current_user).list_employees(
status=status_filter,
keyword=keyword,
)
@router.get(
@@ -69,8 +81,8 @@ def list_employees(
summary="下载员工导入模板",
description="下载固定格式的员工 Excel 导入模板。",
)
def download_employee_import_template(db: DbSession) -> Response:
content = EmployeeService(db).build_import_template()
def download_employee_import_template(db: DbSession, current_user: AdminUser) -> Response:
content = _employee_service(db, current_user).build_import_template()
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
@@ -87,6 +99,7 @@ def download_employee_import_template(db: DbSession) -> Response:
)
def export_employees(
db: DbSession,
current_user: AdminUser,
status_filter: Annotated[
str | None,
Query(alias="status", description="员工状态筛选值。"),
@@ -96,7 +109,10 @@ def export_employees(
Query(description="姓名、工号、邮箱等关键字模糊查询。"),
] = None,
) -> Response:
content = EmployeeService(db).export_employees(status=status_filter, keyword=keyword)
content = _employee_service(db, current_user).export_employees(
status=status_filter,
keyword=keyword,
)
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
@@ -112,6 +128,7 @@ def export_employees(
)
async def import_employees(
db: DbSession,
current_user: AdminUser,
file: Annotated[UploadFile, File(description="待导入的员工 Excel 文件。")],
) -> EmployeeImportResultRead:
filename = (file.filename or "").lower()
@@ -122,7 +139,10 @@ async def import_employees(
)
content = await file.read()
return EmployeeService(db).import_employees(content)
return _employee_service(db, current_user).import_employees(
content,
actor=current_user.username,
)
@router.post(
@@ -138,9 +158,13 @@ async def import_employees(
}
},
)
def create_employee(payload: EmployeeCreate, db: DbSession) -> EmployeeRead:
def create_employee(
payload: EmployeeCreate,
db: DbSession,
current_user: AdminUser,
) -> EmployeeRead:
try:
return EmployeeService(db).create_employee(payload)
return _employee_service(db, current_user).create_employee(payload)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@@ -157,8 +181,12 @@ def create_employee(payload: EmployeeCreate, db: DbSession) -> EmployeeRead:
}
},
)
def get_employee(employee_id: str, db: DbSession) -> EmployeeRead:
employee = EmployeeService(db).get_employee(employee_id)
def get_employee(
employee_id: str,
db: DbSession,
current_user: AdminUser,
) -> EmployeeRead:
employee = _employee_service(db, current_user).get_employee(employee_id)
if employee is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Employee not found")
return employee
@@ -180,9 +208,14 @@ def get_employee(employee_id: str, db: DbSession) -> EmployeeRead:
},
},
)
def update_employee(employee_id: str, payload: EmployeeUpdate, db: DbSession) -> EmployeeRead:
def update_employee(
employee_id: str,
payload: EmployeeUpdate,
db: DbSession,
current_user: AdminUser,
) -> EmployeeRead:
try:
return EmployeeService(db).update_employee(employee_id, payload)
return _employee_service(db, current_user).update_employee(employee_id, payload)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ValueError as exc:
@@ -201,9 +234,13 @@ def update_employee(employee_id: str, payload: EmployeeUpdate, db: DbSession) ->
}
},
)
def disable_employee(employee_id: str, db: DbSession) -> EmployeeRead:
def disable_employee(
employee_id: str,
db: DbSession,
current_user: AdminUser,
) -> EmployeeRead:
try:
return EmployeeService(db).disable_employee(employee_id)
return _employee_service(db, current_user).disable_employee(employee_id)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -220,8 +257,12 @@ def disable_employee(employee_id: str, db: DbSession) -> EmployeeRead:
}
},
)
def enable_employee(employee_id: str, db: DbSession) -> EmployeeRead:
def enable_employee(
employee_id: str,
db: DbSession,
current_user: AdminUser,
) -> EmployeeRead:
try:
return EmployeeService(db).enable_employee(employee_id)
return _employee_service(db, current_user).enable_employee(employee_id)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc

View File

@@ -0,0 +1,76 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.finance_report_config import (
FinanceReportConfigRead,
FinanceReportConfigUpdate,
)
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.finance_report_tenant import TenantFinanceReportConfigService
router = APIRouter(prefix="/finance-report-config")
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
MANAGE_ROLES = frozenset({"finance", "executive"})
@router.get("", response_model=FinanceReportConfigRead)
def get_finance_report_config(
db: DbSession,
current_user: CurrentUser,
) -> FinanceReportConfigRead:
_require_manage(current_user)
row = TenantFinanceReportConfigService(db).get(tenant_id=current_user.tenant_id)
if row is None:
return FinanceReportConfigRead(
tenant_id=current_user.tenant_id,
status="disabled",
delivery_enabled=False,
)
return _serialize(row)
@router.put("", response_model=FinanceReportConfigRead)
def update_finance_report_config(
payload: FinanceReportConfigUpdate,
db: DbSession,
current_user: CurrentUser,
) -> FinanceReportConfigRead:
_require_manage(current_user)
try:
row = TenantFinanceReportConfigService(db).upsert(
tenant_id=current_user.tenant_id,
recipients=payload.recipients,
delivery_enabled=payload.delivery_enabled,
updated_by=current_user.username,
)
except (LookupError, ValueError) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return _serialize(row)
def _require_manage(current_user: CurrentUserContext) -> None:
tenant_id = str(current_user.tenant_id or "").strip()
roles = ExpenseClaimAccessPolicy.normalize_role_codes(current_user)
if tenant_id and (current_user.is_admin or roles & MANAGE_ROLES):
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="只有本租户财务人员或管理员可以维护报告收件配置。",
)
def _serialize(row) -> FinanceReportConfigRead:
return FinanceReportConfigRead(
tenant_id=row.tenant_id,
status=row.status,
delivery_enabled=row.delivery_enabled,
recipients=[str(item) for item in list(row.recipients_json or [])],
updated_by=row.updated_by,
updated_at=row.updated_at,
)

View File

@@ -0,0 +1,544 @@
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.deps import (
CurrentUserContext,
get_current_user,
get_db,
require_platform_admin_user,
)
from app.schemas.financial_connector import (
FinancialConnectorConfigCreate,
FinancialConnectorConfigEventRead,
FinancialConnectorConfigLifecycleAction,
FinancialConnectorConfigRead,
FinancialConnectorConfigRotateAction,
FinancialConnectorConfigRotationRead,
FinancialConnectorObservabilityRead,
FinancialConnectorSimulationCreate,
FinancialConnectorSimulationRead,
FinancialEventEnvelope,
FinancialEventIngestionRead,
FinancialPaymentEvidenceRead,
PaymentReconciliationActionCreate,
PaymentReconciliationCaseDetailRead,
PaymentReconciliationListRead,
)
from app.services.expense_claims import ExpenseClaimService
from app.services.financial_connector_auth import FinancialConnectorAuthError
from app.services.financial_connector_commercial import (
FinancialConnectorCommercialAccessDenied,
)
from app.services.financial_connector_config_lifecycle import (
FinancialConnectorConfigConflictError,
FinancialConnectorConfigLifecycleService,
)
from app.services.financial_connector_configs import (
FinancialConnectorConfigError,
FinancialConnectorConfigService,
)
from app.services.financial_connector_ingestion import (
FinancialConnectorConflictError,
FinancialConnectorIngestionService,
)
from app.services.financial_connector_mock_adapter import (
FinancialConnectorMockAdapter,
FinancialConnectorMockAdapterError,
)
from app.services.financial_connector_observability import (
FinancialConnectorObservabilityPermissionError,
FinancialConnectorObservabilityService,
)
from app.services.financial_connector_operational_events import (
FinancialConnectorOperationalEventCandidate,
FinancialConnectorOperationalEventService,
)
from app.services.financial_connector_payment_evidence import (
FinancialConnectorPaymentEvidenceService,
)
from app.services.financial_connector_projection import (
FinancialConnectorProjectionService,
FinancialReconciliationConflictError,
FinancialReconciliationPermissionError,
)
router = APIRouter()
logger = logging.getLogger(__name__)
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
PlatformAdmin = Annotated[CurrentUserContext, Depends(require_platform_admin_user)]
@router.post(
"/integrations/financial-events",
response_model=FinancialEventIngestionRead,
summary="接收租户绑定且签名可验证的财务事件",
)
def ingest_financial_event(
payload: FinancialEventEnvelope,
db: DbSession,
tenant_id: Annotated[str, Header(alias="X-Financial-Tenant")],
provider: Annotated[str, Header(alias="X-Financial-Provider")],
key_version: Annotated[str, Header(alias="X-Financial-Key-Version")],
timestamp_value: Annotated[str, Header(alias="X-Financial-Timestamp")],
signature: Annotated[str, Header(alias="X-Financial-Signature")],
) -> FinancialEventIngestionRead:
try:
result = FinancialConnectorIngestionService(db).ingest(
payload,
tenant_header=tenant_id,
provider_header=provider,
key_version_header=key_version,
timestamp_header=timestamp_value,
signature_header=signature,
)
db.commit()
return result
except FinancialConnectorAuthError as error:
db.rollback()
if error.operational_event is not None:
_persist_operational_event(db, error.operational_event)
else:
logger.warning(
"financial_connector_auth_failure_unattributed code=%s "
"path=/api/v1/integrations/financial-events",
error.code,
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": error.code, "message": str(error)},
) from error
except FinancialConnectorConflictError as error:
db.rollback()
_persist_operational_event(db, error.operational_event)
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
except IntegrityError as error:
db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="事件正由另一请求处理,请使用相同 external_event_id 安全重试。",
) from error
except FinancialConnectorCommercialAccessDenied as error:
db.rollback()
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=str(error),
) from error
except (ValueError, PermissionError) as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
except Exception:
db.rollback()
raise
@router.post(
"/financial-connectors/admin/tenants/{tenant_id}/configs",
response_model=FinancialConnectorConfigRead,
status_code=status.HTTP_201_CREATED,
summary="平台管理员创建不含密钥明文的连接器配置",
)
def create_financial_connector_config(
tenant_id: str,
payload: FinancialConnectorConfigCreate,
db: DbSession,
current_user: PlatformAdmin,
) -> FinancialConnectorConfigRead:
try:
row = FinancialConnectorConfigService(db).create(
tenant_id=tenant_id,
payload=payload,
actor_id=current_user.username,
)
db.commit()
db.refresh(row)
return FinancialConnectorConfigRead.model_validate(row)
except (FinancialConnectorConfigError, IntegrityError) as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.get(
"/financial-connectors/admin/tenants/{tenant_id}/configs",
response_model=list[FinancialConnectorConfigRead],
summary="平台管理员读取脱敏连接器配置",
)
def list_financial_connector_configs(
tenant_id: str,
db: DbSession,
current_user: PlatformAdmin,
) -> list[FinancialConnectorConfigRead]:
del current_user
return [
FinancialConnectorConfigRead.model_validate(item)
for item in FinancialConnectorConfigService(db).list_for_tenant(tenant_id)
]
@router.post(
"/financial-connectors/admin/tenants/{tenant_id}/configs/{config_id}/activate",
response_model=FinancialConnectorConfigRead,
summary="校验服务端密钥后激活指定版本连接器",
)
def activate_financial_connector_config(
tenant_id: str,
config_id: str,
payload: FinancialConnectorConfigLifecycleAction,
db: DbSession,
current_user: PlatformAdmin,
) -> FinancialConnectorConfigRead:
return _change_config_status(
tenant_id,
config_id,
payload,
db,
current_user,
action="activate",
)
@router.post(
"/financial-connectors/admin/tenants/{tenant_id}/configs/{config_id}/disable",
response_model=FinancialConnectorConfigRead,
summary="按乐观版本停用连接器",
)
def disable_financial_connector_config(
tenant_id: str,
config_id: str,
payload: FinancialConnectorConfigLifecycleAction,
db: DbSession,
current_user: PlatformAdmin,
) -> FinancialConnectorConfigRead:
return _change_config_status(
tenant_id,
config_id,
payload,
db,
current_user,
action="disable",
)
@router.post(
"/financial-connectors/admin/tenants/{tenant_id}/configs/{config_id}/rotate",
response_model=FinancialConnectorConfigRotationRead,
summary="原子轮换连接器密钥版本",
)
def rotate_financial_connector_config(
tenant_id: str,
config_id: str,
payload: FinancialConnectorConfigRotateAction,
db: DbSession,
current_user: PlatformAdmin,
) -> FinancialConnectorConfigRotationRead:
try:
result = FinancialConnectorConfigLifecycleService(db).rotate(
tenant_id=tenant_id,
config_id=config_id,
payload=payload,
actor_id=current_user.username,
)
db.commit()
db.refresh(result.previous)
db.refresh(result.replacement)
return FinancialConnectorConfigRotationRead(
previous=FinancialConnectorConfigRead.model_validate(result.previous),
replacement=FinancialConnectorConfigRead.model_validate(result.replacement),
)
except LookupError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except FinancialConnectorAuthError as error:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": error.code, "message": str(error)},
) from error
except (FinancialConnectorConfigConflictError, IntegrityError) as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.get(
"/financial-connectors/admin/tenants/{tenant_id}/config-events",
response_model=list[FinancialConnectorConfigEventRead],
summary="读取不含密钥材料的连接器配置审计时间线",
)
def list_financial_connector_config_events(
tenant_id: str,
db: DbSession,
current_user: PlatformAdmin,
config_id: str | None = Query(default=None),
) -> list[FinancialConnectorConfigEventRead]:
del current_user
return [
FinancialConnectorConfigEventRead.model_validate(item)
for item in FinancialConnectorConfigLifecycleService(db).list_events(
tenant_id=tenant_id,
config_id=config_id,
)
]
@router.post(
"/financial-connectors/admin/tenants/{tenant_id}/configs/{config_id}/simulate",
response_model=FinancialConnectorSimulationRead,
summary="平台管理员运行确定性的非生产连接器场景",
)
def simulate_financial_connector_event(
tenant_id: str,
config_id: str,
payload: FinancialConnectorSimulationCreate,
db: DbSession,
current_user: PlatformAdmin,
) -> FinancialConnectorSimulationRead:
del current_user
try:
result = FinancialConnectorMockAdapter(db).run(
tenant_id=tenant_id,
config_id=config_id,
payload=payload,
)
db.commit()
return result
except LookupError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except FinancialConnectorAuthError as error:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": error.code, "message": str(error)},
) from error
except FinancialConnectorMockAdapterError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
except FinancialConnectorCommercialAccessDenied as error:
db.rollback()
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=str(error),
) from error
except IntegrityError as error:
db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="模拟场景正由另一请求处理,请使用相同 request_id 安全重试。",
) from error
except Exception:
db.rollback()
raise
@router.get(
"/financial-connectors/observability",
response_model=FinancialConnectorObservabilityRead,
summary="读取当前租户脱敏连接器运行指标",
)
def get_current_tenant_connector_observability(
db: DbSession,
current_user: CurrentUser,
window_hours: int = Query(default=24, ge=1, le=720),
) -> FinancialConnectorObservabilityRead:
try:
return FinancialConnectorObservabilityService(db).read_for_current_user(
current_user,
window_hours=window_hours,
)
except FinancialConnectorObservabilityPermissionError as error:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(error)) from error
@router.get(
"/financial-connectors/admin/tenants/{tenant_id}/observability",
response_model=FinancialConnectorObservabilityRead,
summary="平台管理员读取目标租户脱敏连接器运行指标",
)
def get_tenant_connector_observability(
tenant_id: str,
db: DbSession,
current_user: PlatformAdmin,
window_hours: int = Query(default=24, ge=1, le=720),
) -> FinancialConnectorObservabilityRead:
del current_user
return FinancialConnectorObservabilityService(db).read_for_tenant(
tenant_id,
window_hours=window_hours,
)
@router.get(
"/financial-connectors/payment-evidence/{claim_id}",
response_model=FinancialPaymentEvidenceRead,
summary="读取当前租户单据的付款证据等级",
)
def get_financial_payment_evidence(
claim_id: str,
db: DbSession,
current_user: CurrentUser,
) -> FinancialPaymentEvidenceRead:
claim = ExpenseClaimService(db).get_claim(claim_id, current_user)
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在。")
return FinancialConnectorPaymentEvidenceService.read(claim)
@router.get(
"/financial-reconciliation/cases",
response_model=PaymentReconciliationListRead,
summary="分页读取当前租户对账记录",
)
def list_payment_reconciliation_cases(
db: DbSession,
current_user: CurrentUser,
reconciliation_status: str | None = Query(default=None, alias="status"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
) -> PaymentReconciliationListRead:
try:
return FinancialConnectorProjectionService(db).list_cases(
current_user,
status_filter=reconciliation_status,
page=page,
page_size=page_size,
)
except FinancialReconciliationPermissionError as error:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(error)) from error
@router.get(
"/financial-reconciliation/cases/{case_id}",
response_model=PaymentReconciliationCaseDetailRead,
summary="读取当前租户脱敏对账详情和追加式时间线",
)
def get_payment_reconciliation_case(
case_id: str,
db: DbSession,
current_user: CurrentUser,
) -> PaymentReconciliationCaseDetailRead:
try:
result = FinancialConnectorProjectionService(db).detail(case_id, current_user)
except FinancialReconciliationPermissionError as error:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(error)) from error
if result is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="对账记录不存在。")
return result
@router.post(
"/financial-reconciliation/cases/{case_id}/confirm",
response_model=PaymentReconciliationCaseDetailRead,
summary="财务人员确认异常处置,不伪造付款成功",
)
def confirm_payment_reconciliation_case(
case_id: str,
payload: PaymentReconciliationActionCreate,
db: DbSession,
current_user: CurrentUser,
) -> PaymentReconciliationCaseDetailRead:
return _resolve_case(case_id, payload, db, current_user, action="confirmed")
@router.post(
"/financial-reconciliation/cases/{case_id}/reject",
response_model=PaymentReconciliationCaseDetailRead,
summary="财务人员拒绝错误回执",
)
def reject_payment_reconciliation_case(
case_id: str,
payload: PaymentReconciliationActionCreate,
db: DbSession,
current_user: CurrentUser,
) -> PaymentReconciliationCaseDetailRead:
return _resolve_case(case_id, payload, db, current_user, action="rejected")
def _resolve_case(
case_id: str,
payload: PaymentReconciliationActionCreate,
db: Session,
current_user: CurrentUserContext,
*,
action: str,
) -> PaymentReconciliationCaseDetailRead:
try:
result = FinancialConnectorProjectionService(db).resolve(
case_id,
payload,
current_user,
action=action,
)
if result is None:
db.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="对账记录不存在。")
db.commit()
return result
except HTTPException:
raise
except FinancialReconciliationPermissionError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(error)) from error
except FinancialReconciliationConflictError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
def _change_config_status(
tenant_id: str,
config_id: str,
payload: FinancialConnectorConfigLifecycleAction,
db: Session,
current_user: CurrentUserContext,
*,
action: str,
) -> FinancialConnectorConfigRead:
service = FinancialConnectorConfigLifecycleService(db)
try:
operation = service.activate if action == "activate" else service.disable
row = operation(
tenant_id=tenant_id,
config_id=config_id,
payload=payload,
actor_id=current_user.username,
)
db.commit()
db.refresh(row)
return FinancialConnectorConfigRead.model_validate(row)
except LookupError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except FinancialConnectorAuthError as error:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": error.code, "message": str(error)},
) from error
except (FinancialConnectorConfigConflictError, IntegrityError) as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
def _persist_operational_event(
db: Session,
candidate: FinancialConnectorOperationalEventCandidate,
) -> None:
"""失败响应回滚后独立提交运营事实,写入失败不得改变原始业务结果。"""
try:
FinancialConnectorOperationalEventService(db).record(candidate)
db.commit()
except Exception: # noqa: BLE001 - 审计降级不能覆盖原始 401/409 契约
db.rollback()
logger.exception(
"financial_connector_operational_event_persist_failed "
"event_type=%s reason_code=%s config_id=%s",
candidate.event_type,
candidate.reason_code,
candidate.context.config_id,
)

View File

@@ -4,8 +4,7 @@ from typing import Annotated
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
from fastapi.responses import FileResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db, require_admin_user
from app.core.agent_enums import AgentName, AgentRunSource
@@ -24,9 +23,17 @@ from app.schemas.knowledge import (
LlmWikiSyncWrite,
)
from app.services.agent_runs import AgentRunService
from app.services.knowledge import (
KnowledgeService,
)
from app.services.knowledge import (
KnowledgeService,
)
from app.services.knowledge_onlyoffice_callback import (
handle_onlyoffice_callback,
resolve_onlyoffice_content,
)
from app.services.knowledge_onlyoffice_security import (
OnlyOfficeReplayError,
OnlyOfficeSecurityError,
)
from app.services.knowledge_sync import KnowledgeSyncDispatchService
router = APIRouter(prefix="/knowledge")
@@ -44,11 +51,11 @@ router = APIRouter(prefix="/knowledge")
}
},
)
def get_knowledge_library(
_: Annotated[CurrentUserContext, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
) -> KnowledgeLibraryRead:
return KnowledgeService(db=db).list_library()
def get_knowledge_library(
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
) -> KnowledgeLibraryRead:
return KnowledgeService(db=db, tenant_id=current_user.tenant_id).list_library()
@router.get(
@@ -67,14 +74,18 @@ def get_knowledge_library(
},
},
)
def get_llm_wiki_index(
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
def get_llm_wiki_index(
current_user: Annotated[CurrentUserContext, Depends(require_admin_user)],
db: Annotated[Session, Depends(get_db)],
) -> LlmWikiIndexRead:
run_service = AgentRunService(db)
sync_runs = [
item
for item in run_service.list_runs(agent=AgentName.HERMES.value, limit=200)
for item in run_service.list_runs_for_tenant(
tenant_id=current_user.tenant_id,
agent=AgentName.HERMES.value,
limit=200,
)
if str(item.route_json.get("job_type") or "").strip() == "knowledge_index_sync"
]
return LlmWikiIndexRead(documents=[], sync_run_count=len(sync_runs))
@@ -198,7 +209,10 @@ def sync_knowledge_library(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if isinstance(exc, FileNotFoundError):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(exc),
) from exc
@router.get(
"/documents/{document_id}",
@@ -216,13 +230,16 @@ def sync_knowledge_library(
},
},
)
def get_knowledge_document(
document_id: str,
_: Annotated[CurrentUserContext, Depends(get_current_user)],
def get_knowledge_document(
document_id: str,
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
) -> KnowledgeDocumentDetailRead:
try:
return KnowledgeService(db=db).get_document_detail(document_id)
return KnowledgeService(
db=db,
tenant_id=current_user.tenant_id,
).get_document_detail(document_id)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -250,12 +267,24 @@ def get_knowledge_document(
},
},
)
def get_knowledge_document_onlyoffice_config(
document_id: str,
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
) -> KnowledgeOnlyOfficeConfigRead:
try:
return KnowledgeService().build_onlyoffice_config(document_id, current_user)
def get_knowledge_document_onlyoffice_config(
document_id: str,
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
editable: Annotated[
bool,
Query(description="是否申请租户知识文档编辑会话;默认仅预览。"),
] = False,
) -> KnowledgeOnlyOfficeConfigRead:
try:
return KnowledgeService(
db=db,
tenant_id=current_user.tenant_id,
).build_onlyoffice_config(
document_id,
current_user,
editable=editable,
)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -286,7 +315,7 @@ def get_knowledge_document_onlyoffice_config(
},
},
)
def upload_knowledge_document(
def upload_knowledge_document(
content: Annotated[
bytes,
Body(
@@ -296,10 +325,14 @@ def upload_knowledge_document(
],
folder: Annotated[str, Query(min_length=1, description="目标知识库目录名称。")],
filename: Annotated[str, Query(min_length=1, description="原始文件名。")],
current_user: Annotated[CurrentUserContext, Depends(require_admin_user)],
) -> KnowledgeDocumentDetailRead:
try:
return KnowledgeService().upload_document(folder, filename, content, current_user)
current_user: Annotated[CurrentUserContext, Depends(require_admin_user)],
db: Annotated[Session, Depends(get_db)],
) -> KnowledgeDocumentDetailRead:
try:
return KnowledgeService(
db=db,
tenant_id=current_user.tenant_id,
).upload_document(folder, filename, content, current_user)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@@ -324,12 +357,16 @@ def upload_knowledge_document(
},
},
)
def delete_knowledge_document(
document_id: str,
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
) -> KnowledgeActionResponse:
try:
KnowledgeService().delete_document(document_id)
def delete_knowledge_document(
document_id: str,
current_user: Annotated[CurrentUserContext, Depends(require_admin_user)],
db: Annotated[Session, Depends(get_db)],
) -> KnowledgeActionResponse:
try:
KnowledgeService(
db=db,
tenant_id=current_user.tenant_id,
).delete_document(document_id)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -359,19 +396,23 @@ def delete_knowledge_document(
},
},
)
def get_knowledge_document_content(
document_id: str,
disposition: Annotated[
str,
Query(
def get_knowledge_document_content(
document_id: str,
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
disposition: Annotated[
str,
Query(
pattern="^(inline|attachment)$",
description="内容展示方式,支持 `inline` 或 `attachment`。",
),
] = "inline",
_: Annotated[CurrentUserContext, Depends(get_current_user)] = None,
) -> FileResponse:
try:
file_path, media_type, filename = KnowledgeService().get_document_content(document_id)
description="内容展示方式,支持 `inline` 或 `attachment`。",
),
] = "inline",
) -> FileResponse:
try:
file_path, media_type, filename = KnowledgeService(
db=db,
tenant_id=current_user.tenant_id,
).get_document_content(document_id)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -402,24 +443,28 @@ def get_knowledge_document_content(
},
},
)
def get_knowledge_document_onlyoffice_content(
def get_knowledge_document_onlyoffice_content(
document_id: str,
access_token: Annotated[
access_token: Annotated[
str,
Query(min_length=1, description="ONLYOFFICE 临时访问令牌。"),
],
) -> FileResponse:
try:
service = KnowledgeService()
service.validate_onlyoffice_access_token(document_id, access_token)
file_path, media_type, filename = service.get_document_content(document_id)
],
db: Annotated[Session, Depends(get_db)],
) -> FileResponse:
try:
file_path, media_type, filename = resolve_onlyoffice_content(
db=db,
storage_root=None,
document_id=document_id,
access_token=access_token,
)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="知识库文件不存在。",
) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
except (OnlyOfficeSecurityError, OnlyOfficeReplayError) as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
return FileResponse(file_path, media_type=media_type, filename=filename)
@@ -440,18 +485,33 @@ def get_knowledge_document_onlyoffice_content(
},
},
)
def handle_knowledge_document_onlyoffice_callback(
document_id: str,
payload: KnowledgeOnlyOfficeCallbackWrite,
) -> KnowledgeOnlyOfficeCallbackRead:
try:
KnowledgeService().handle_onlyoffice_callback(document_id, payload.model_dump())
def handle_knowledge_document_onlyoffice_callback(
document_id: str,
payload: KnowledgeOnlyOfficeCallbackWrite,
callback_token: Annotated[
str,
Query(min_length=1, description="绑定租户与文档的一次性回调会话令牌。"),
],
db: Annotated[Session, Depends(get_db)],
) -> KnowledgeOnlyOfficeCallbackRead:
try:
handle_onlyoffice_callback(
db=db,
storage_root=None,
document_id=document_id,
callback_token=callback_token,
payload=payload.model_dump(),
)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="知识库文件不存在。",
) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except OnlyOfficeReplayError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except OnlyOfficeSecurityError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return KnowledgeOnlyOfficeCallbackRead()

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, UploadFile, status
from sqlalchemy.orm import Session
from starlette.concurrency import run_in_threadpool
@@ -10,6 +10,11 @@ from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.common import ErrorResponse
from app.schemas.ocr import OcrRecognizeBatchRead
from app.services.ocr import OcrService
from app.services.ocr_commercial import (
OcrCommercialAccessDenied,
content_digest,
trusted_ocr_operation_context,
)
from app.services.receipt_folder import ReceiptFolderService
router = APIRouter(prefix="/ocr")
@@ -29,6 +34,10 @@ router = APIRouter(prefix="/ocr")
"model": ErrorResponse,
"description": "未提供当前登录用户。",
},
status.HTTP_429_TOO_MANY_REQUESTS: {
"model": ErrorResponse,
"description": "OCR 商业额度不足或计量配置不允许本次真实执行。",
},
status.HTTP_503_SERVICE_UNAVAILABLE: {
"model": ErrorResponse,
"description": "OCR 运行时不可用或执行失败。",
@@ -39,7 +48,10 @@ async def recognize_ocr_documents(
files: Annotated[list[UploadFile], File(description="待识别的票据图片或 PDF。")],
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
receipt_ids: Annotated[list[str] | None, Form(description="可选,来源于票据夹的持久化票据 ID。")] = None,
receipt_ids: Annotated[
list[str] | None, Form(description="可选,来源于票据夹的持久化票据 ID。")
] = None,
x_request_id: Annotated[str | None, Header(alias="X-Request-ID")] = None,
) -> OcrRecognizeBatchRead:
try:
payload = []
@@ -51,7 +63,15 @@ async def recognize_ocr_documents(
upload.content_type,
)
)
result = await run_in_threadpool(lambda: OcrService(db).recognize_files(payload))
operation_context = trusted_ocr_operation_context(
current_user,
operation_scope="ocr-endpoint",
content_digests=[content_digest(content) for _, content, _ in payload],
request_id=x_request_id or "",
)
result = await run_in_threadpool(
lambda: OcrService(db, operation_context=operation_context).recognize_files(payload)
)
return ReceiptFolderService().persist_ocr_batch(
files=payload,
result=result,
@@ -60,6 +80,11 @@ async def recognize_ocr_documents(
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except OcrCommercialAccessDenied as exc:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=str(exc),
) from exc
except RuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,

View File

@@ -5,13 +5,14 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.common import ErrorResponse
from app.schemas.ontology import OntologyParseRequest, OntologyParseResult
from app.services.ontology import SemanticOntologyService
router = APIRouter(prefix="/ontology")
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@router.post(
@@ -19,8 +20,7 @@ DbSession = Annotated[Session, Depends(get_db)]
response_model=OntologyParseResult,
summary="解析自然语言为语义本体",
description=(
"把自然语言问题解析成 Day 3 约定的 8 个核心字段,"
"并写入 AgentRun 与 SemanticParseLog。"
"把自然语言问题解析成 Day 3 约定的 8 个核心字段,并写入 AgentRun 与 SemanticParseLog。"
),
responses={
status.HTTP_400_BAD_REQUEST: {
@@ -29,8 +29,15 @@ DbSession = Annotated[Session, Depends(get_db)]
}
},
)
def parse_ontology(payload: OntologyParseRequest, db: DbSession) -> OntologyParseResult:
def parse_ontology(
payload: OntologyParseRequest,
db: DbSession,
current_user: CurrentUser,
) -> OntologyParseResult:
try:
return SemanticOntologyService(db).parse(payload)
return SemanticOntologyService(db).parse(
payload.model_copy(update={"user_id": current_user.username}),
tenant_id=current_user.tenant_id,
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc

View File

@@ -24,7 +24,12 @@ DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
def _raise_action_error(error: ValueError) -> NoReturn:
def _raise_action_error(error: Exception) -> NoReturn:
if isinstance(error, LookupError):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(error),
) from error
if isinstance(error, ExpenseClaimRiskBlockedError):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
@@ -87,7 +92,7 @@ def return_expense_claim(
task_id=payload.task_id,
expected_task_version=payload.expected_task_version,
)
except ValueError as error:
except (LookupError, ValueError) as error:
_raise_action_error(error)
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
@@ -128,7 +133,7 @@ def approve_expense_claim(
task_id=payload.task_id,
expected_task_version=payload.expected_task_version,
)
except ValueError as error:
except (LookupError, ValueError) as error:
_raise_action_error(error)
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
@@ -166,7 +171,7 @@ def pay_expense_claim(
expected_status=payload.expected_status,
expected_approval_stage=payload.expected_approval_stage,
)
except ValueError as error:
except (LookupError, ValueError) as error:
_raise_action_error(error)
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")

View File

@@ -27,6 +27,9 @@ from app.schemas.reimbursement import (
TravelReimbursementCalculatorResponse,
)
from app.services.budget import BudgetService
from app.services.expense_claim_attachment_commercial import (
ExpenseClaimAttachmentCommercialAccessDenied,
)
from app.services.expense_claims import ExpenseClaimService
from app.services.reimbursement import ReimbursementService
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
@@ -393,9 +396,14 @@ def delete_expense_claim_item(
current_user=current_user,
)
except LookupError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except ValueError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
except Exception:
db.rollback()
raise
if payload is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
@@ -427,6 +435,7 @@ async def upload_expense_claim_item_attachment(
receipt_id: Annotated[
str | None, Form(description="可选,来源于票据夹的持久化票据 ID。")
] = None,
request_id: RequestIdHeader = None,
) -> ExpenseClaimAttachmentActionResponse:
service = ExpenseClaimService(db)
try:
@@ -438,11 +447,23 @@ async def upload_expense_claim_item_attachment(
media_type=file.content_type,
current_user=current_user,
source_receipt_id=receipt_id or "",
request_id=request_id or "",
)
except LookupError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except ExpenseClaimAttachmentCommercialAccessDenied as error:
db.rollback()
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=str(error),
) from error
except ValueError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
except Exception:
db.rollback()
raise
if payload is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
@@ -588,11 +609,17 @@ def delete_expense_claim_item_attachment(
current_user=current_user,
)
except LookupError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except FileNotFoundError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except ValueError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
except Exception:
db.rollback()
raise
if payload is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
@@ -682,7 +709,11 @@ def delete_expense_claim(
try:
claim = service.delete_claim(claim_id, current_user)
except ValueError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
except Exception:
db.rollback()
raise
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")

View File

@@ -0,0 +1,235 @@
from __future__ import annotations
from datetime import datetime
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.savings import (
SavingsOpportunityActionCreate,
SavingsOpportunityListRead,
SavingsOpportunityMutationRead,
SavingsOpportunityRead,
SavingsRealizationActionCreate,
SavingsRealizationCreate,
SavingsRealizationMutationRead,
)
from app.schemas.savings_insights import (
SavingsBaselineGenerateRequest,
SavingsBaselineGenerationRead,
SavingsInsightAnalysisRead,
SavingsInsightAnalyzeRequest,
)
from app.services.savings_access_policy import SavingsPermissionError
from app.services.savings_actions import SavingsActionService, SavingsTransitionError
from app.services.savings_baseline_generation import SavingsBaselineGenerationService
from app.services.savings_insight_analysis import SavingsInsightAnalysisService
from app.services.savings_protocol import (
SavingsIdempotencyConflictError,
SavingsVersionConflictError,
)
from app.services.savings_query import SavingsQueryService
from app.services.savings_realization import (
SavingsRealizationError,
SavingsRealizationService,
)
router = APIRouter(prefix="/savings")
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@router.post(
"/baselines/generate",
response_model=SavingsBaselineGenerationRead,
summary="冻结租户费用历史基线",
)
def generate_savings_baselines(
payload: SavingsBaselineGenerateRequest,
db: DbSession,
current_user: CurrentUser,
) -> SavingsBaselineGenerationRead:
try:
return SavingsBaselineGenerationService(db).generate(payload, current_user)
except Exception as error:
raise _mutation_http_error(error) from error
@router.post(
"/insights/analyze",
response_model=SavingsInsightAnalysisRead,
summary="分析费用节省候选信号",
)
def analyze_savings_insights(
payload: SavingsInsightAnalyzeRequest,
db: DbSession,
current_user: CurrentUser,
) -> SavingsInsightAnalysisRead:
try:
return SavingsInsightAnalysisService(db).analyze(payload, current_user)
except Exception as error:
raise _mutation_http_error(error) from error
@router.get(
"/opportunities",
response_model=SavingsOpportunityListRead,
summary="查询节省机会台账",
)
def list_savings_opportunities(
db: DbSession,
current_user: CurrentUser,
page: Annotated[int, Query(ge=1)] = 1,
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
status_value: Annotated[str | None, Query(alias="status", max_length=24)] = None,
source_type: Annotated[str | None, Query(max_length=50)] = None,
value_kind: Annotated[Literal["cash", "labor"] | None, Query()] = None,
department_id: Annotated[str | None, Query(max_length=160)] = None,
project_code: Annotated[str | None, Query(max_length=160)] = None,
expense_type: Annotated[str | None, Query(max_length=80)] = None,
supplier_id: Annotated[str | None, Query(max_length=160)] = None,
city: Annotated[str | None, Query(max_length=160)] = None,
owner_id: Annotated[str | None, Query(max_length=120)] = None,
claim_id: Annotated[str | None, Query(max_length=36)] = None,
created_from: datetime | None = None,
created_to: datetime | None = None,
sort: Annotated[
Literal["created_desc", "created_asc", "due_asc", "estimated_desc"],
Query(),
] = "created_desc",
) -> SavingsOpportunityListRead:
return SavingsQueryService(db).list_opportunities(
current_user,
page=page,
page_size=page_size,
status=status_value,
source_type=source_type,
value_kind=value_kind,
department_id=department_id,
project_code=project_code,
expense_type=expense_type,
supplier_id=supplier_id,
city=city,
owner_id=owner_id,
claim_id=claim_id,
created_from=created_from,
created_to=created_to,
sort=sort,
)
@router.get(
"/opportunities/{opportunity_id}",
response_model=SavingsOpportunityRead,
summary="读取节省机会证据链",
)
def get_savings_opportunity(
opportunity_id: str,
db: DbSession,
current_user: CurrentUser,
) -> SavingsOpportunityRead:
opportunity = SavingsQueryService(db).get_opportunity(opportunity_id, current_user)
if opportunity is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="节省机会不存在。")
return opportunity
@router.post(
"/opportunities/{opportunity_id}/actions",
response_model=SavingsOpportunityMutationRead,
summary="执行节省机会状态动作",
)
def execute_savings_opportunity_action(
opportunity_id: str,
payload: SavingsOpportunityActionCreate,
db: DbSession,
current_user: CurrentUser,
) -> SavingsOpportunityMutationRead:
try:
return (
SavingsActionService(db)
.execute(
opportunity_id,
payload,
current_user,
)
.response
)
except Exception as error:
raise _mutation_http_error(error) from error
@router.post(
"/opportunities/{opportunity_id}/realizations",
response_model=SavingsRealizationMutationRead,
summary="记录节省机会实际结果",
)
def record_savings_realization(
opportunity_id: str,
payload: SavingsRealizationCreate,
db: DbSession,
current_user: CurrentUser,
) -> SavingsRealizationMutationRead:
try:
return (
SavingsRealizationService(db)
.record(
opportunity_id,
payload,
current_user,
)
.response
)
except Exception as error:
raise _mutation_http_error(error) from error
@router.post(
"/realizations/{realization_id}/actions",
response_model=SavingsRealizationMutationRead,
summary="确认、拒绝或冲回实际节省",
)
def execute_savings_realization_action(
realization_id: str,
payload: SavingsRealizationActionCreate,
db: DbSession,
current_user: CurrentUser,
) -> SavingsRealizationMutationRead:
try:
return (
SavingsRealizationService(db)
.execute_action(
realization_id,
payload,
current_user,
)
.response
)
except Exception as error:
raise _mutation_http_error(error) from error
def _mutation_http_error(error: Exception) -> HTTPException:
if isinstance(error, LookupError):
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error))
if isinstance(error, SavingsPermissionError):
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(error))
if isinstance(error, SavingsVersionConflictError):
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"message": str(error), "current_version": error.current_version},
)
if isinstance(
error,
(
SavingsIdempotencyConflictError,
SavingsTransitionError,
SavingsRealizationError,
),
):
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error))
if isinstance(error, (ValueError, PermissionError)):
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error))
raise error

View File

@@ -27,6 +27,7 @@ from app.schemas.steward import (
)
from app.services.agent_conversations import AgentConversationService
from app.services.expense_claim_draft_flow import APPROVED_APPLICATION_LINK_STATUSES
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.expense_claims import ExpenseClaimService
from app.services.runtime_chat import RuntimeChatService
from app.services.steward_context_resume import (
@@ -61,15 +62,30 @@ StewardPlannerLike = StewardPlannerService | StewardGraphPlannerService
}
},
)
def create_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StewardPlanResponse:
def create_steward_plan(
payload: StewardPlanRequest,
db: DbSession,
current_user: CurrentUser,
) -> StewardPlanResponse:
try:
payload = _bind_authenticated_plan_request(payload, current_user)
planner = _build_steward_planner(db)
hydrated_payload = _hydrate_required_application_gate(db, payload, planner)
hydrated_payload = _hydrate_required_application_gate(
db,
payload,
planner,
tenant_id=_require_steward_tenant_id(current_user),
)
if isinstance(planner, StewardGraphPlannerService):
plan = planner.build_plan(hydrated_payload, db=db)
else:
plan = planner.build_plan(hydrated_payload)
return _attach_conversation_state(db, hydrated_payload, plan)
return _attach_conversation_state(
db,
hydrated_payload,
plan,
current_user=current_user,
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@@ -83,8 +99,10 @@ def create_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StewardPl
def create_steward_slot_decision(
payload: StewardSlotDecisionRequest,
db: DbSession,
current_user: CurrentUser,
) -> StewardSlotDecisionResponse:
return _decide_steward_slot(payload, RuntimeChatService(db))
authenticated_payload = _bind_authenticated_slot_request(payload, current_user)
return _decide_steward_slot(authenticated_payload, RuntimeChatService(db))
@router.post(
@@ -96,10 +114,21 @@ def create_steward_slot_decision(
def create_steward_runtime_decision(
payload: StewardRuntimeDecisionRequest,
db: DbSession,
current_user: CurrentUser,
) -> StewardRuntimeDecisionResponse:
hydrated_payload = _hydrate_runtime_decision_payload(db, payload)
authenticated_payload = _bind_authenticated_runtime_request(payload, current_user)
hydrated_payload = _hydrate_runtime_decision_payload(
db,
authenticated_payload,
current_user=current_user,
)
decision = _decide_steward_runtime(hydrated_payload, RuntimeChatService(db))
return _attach_runtime_conversation_state(db, hydrated_payload, decision)
return _attach_runtime_conversation_state(
db,
hydrated_payload,
decision,
current_user=current_user,
)
@router.post(
@@ -124,9 +153,19 @@ def execute_steward_action(
summary="流式生成小财管家任务计划",
description="以 NDJSON 逐条返回小财管家的过程摘要事件,最后返回完整任务计划。",
)
async def stream_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StreamingResponse:
async def stream_steward_plan(
payload: StewardPlanRequest,
db: DbSession,
current_user: CurrentUser,
) -> StreamingResponse:
authenticated_payload = _bind_authenticated_plan_request(payload, current_user)
return StreamingResponse(
_iter_steward_plan_events(payload, _build_steward_planner(db), db),
_iter_steward_plan_events(
authenticated_payload,
_build_steward_planner(db),
db,
current_user=current_user,
),
media_type="application/x-ndjson",
)
@@ -135,6 +174,8 @@ async def _iter_steward_plan_events(
payload: StewardPlanRequest,
planner: StewardPlannerLike,
db: Session,
*,
current_user: CurrentUserContext,
) -> AsyncIterator[str]:
yield _encode_stream_event(
"thinking",
@@ -149,12 +190,22 @@ async def _iter_steward_plan_events(
await asyncio.sleep(0)
try:
hydrated_payload = _hydrate_required_application_gate(db, payload, planner)
hydrated_payload = _hydrate_required_application_gate(
db,
payload,
planner,
tenant_id=_require_steward_tenant_id(current_user),
)
if isinstance(planner, StewardGraphPlannerService):
plan = planner.build_plan(hydrated_payload, db=db)
else:
plan = planner.build_plan(hydrated_payload)
plan = _attach_conversation_state(db, hydrated_payload, plan)
plan = _attach_conversation_state(
db,
hydrated_payload,
plan,
current_user=current_user,
)
except ValueError as exc:
yield _encode_stream_event("error", {"message": str(exc)})
return
@@ -170,6 +221,114 @@ def _encode_stream_event(event: str, data: dict[str, Any]) -> str:
return json.dumps({"event": event, "data": data}, ensure_ascii=False) + "\n"
def _bind_authenticated_plan_request(
payload: StewardPlanRequest,
current_user: CurrentUserContext,
) -> StewardPlanRequest:
user_id = _require_steward_user_id(current_user)
return payload.model_copy(
update={
"user_id": user_id,
"context_json": _build_trusted_steward_context(
payload.context_json,
current_user,
),
}
)
def _bind_authenticated_slot_request(
payload: StewardSlotDecisionRequest,
current_user: CurrentUserContext,
) -> StewardSlotDecisionRequest:
return payload.model_copy(
update={
"task_context": _build_trusted_steward_context(
payload.task_context,
current_user,
)
}
)
def _bind_authenticated_runtime_request(
payload: StewardRuntimeDecisionRequest,
current_user: CurrentUserContext,
) -> StewardRuntimeDecisionRequest:
return payload.model_copy(
update={
"context_json": _build_trusted_steward_context(
payload.context_json,
current_user,
),
"runtime_state": _build_trusted_steward_context(
payload.runtime_state,
current_user,
),
}
)
def _build_trusted_steward_context(
context_json: dict[str, Any] | None,
current_user: CurrentUserContext,
) -> dict[str, Any]:
tenant_id = _require_steward_tenant_id(current_user)
user_id = _require_steward_user_id(current_user)
trusted_context = dict(context_json or {})
for untrusted_alias in (
"tenantId",
"userId",
"auth_session_id",
):
trusted_context.pop(untrusted_alias, None)
trusted_context.update(
{
"tenant_id": tenant_id,
"user_id": user_id,
"username": current_user.username,
"name": current_user.name,
"role_codes": list(current_user.role_codes),
"is_admin": current_user.is_admin,
"department": current_user.department_name,
"department_name": current_user.department_name,
"department_id": current_user.department_id,
"cost_center": current_user.cost_center,
"position": current_user.position,
"grade": current_user.grade,
"employee_grade": current_user.grade,
"employee_no": current_user.employee_no,
"employee_id": current_user.employee_id,
"manager_name": current_user.manager_name,
"requested_by_username": current_user.username,
"requested_by_name": current_user.name,
"actor": user_id,
"actor_id": user_id,
}
)
return trusted_context
def _require_steward_tenant_id(current_user: CurrentUserContext) -> str:
tenant_id = str(current_user.tenant_id or "").strip()
if tenant_id:
return tenant_id
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="当前登录用户缺少租户归属,无法使用小财管家。",
)
def _require_steward_user_id(current_user: CurrentUserContext) -> str:
user_id = str(current_user.username or current_user.employee_id or "").strip()
if user_id:
return user_id
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="当前登录用户缺少可信用户标识,无法使用小财管家。",
)
def _build_steward_planner(db: Session) -> StewardPlannerLike:
runtime_chat = RuntimeChatService(db)
if get_settings().steward_agent_runtime.strip().lower() == "langgraph":
@@ -217,6 +376,8 @@ def _hydrate_required_application_gate(
db: Session,
payload: StewardPlanRequest,
planner: StewardPlannerLike,
*,
tenant_id: str,
) -> StewardPlanRequest:
context_json = dict(payload.context_json or {})
required_gate = context_json.get("required_application_gate")
@@ -230,7 +391,12 @@ def _hydrate_required_application_gate(
if not planner._looks_like_ambiguous_travel_flow(message, base_date, payload):
return payload
candidates = _query_required_application_gate_candidates(db, payload, context_json)
candidates = _query_required_application_gate_candidates(
db,
payload,
context_json,
tenant_id=tenant_id,
)
next_required_gate = dict(required_gate) if isinstance(required_gate, dict) else {}
next_required_gate["travel"] = {
"checked": True,
@@ -251,10 +417,15 @@ def _query_required_application_gate_candidates(
db: Session,
payload: StewardPlanRequest,
context_json: dict[str, Any],
*,
tenant_id: str,
) -> list[dict[str, Any]]:
identities = _resolve_required_application_gate_identities(payload, context_json)
stmt = (
select(ExpenseClaim)
.where(
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant_id)
)
.order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.updated_at.desc())
.limit(200)
)
@@ -383,16 +554,19 @@ def _attach_conversation_state(
db: Session,
payload: StewardPlanRequest,
plan: StewardPlanResponse,
*,
current_user: CurrentUserContext,
) -> StewardPlanResponse:
context_json = dict(payload.context_json or {})
context_json["session_type"] = str(context_json.get("session_type") or "steward").strip() or "steward"
conversation_service = AgentConversationService(db)
conversation = conversation_service.get_or_create_conversation(
conversation_id=_resolve_conversation_id(context_json),
user_id=payload.user_id,
user_id=_require_steward_user_id(current_user),
source="user_message",
context_json=context_json,
)
_require_steward_conversation_access(conversation, current_user)
current_state = _resolve_current_steward_state(conversation.state_json, context_json)
steward_state = StewardFlowStateService().merge_plan(current_state, plan)
conversation = conversation_service.update_state(
@@ -433,6 +607,8 @@ def _attach_runtime_conversation_state(
db: Session,
payload: StewardRuntimeDecisionRequest,
decision: StewardRuntimeDecisionResponse,
*,
current_user: CurrentUserContext,
) -> StewardRuntimeDecisionResponse:
steward_state = decision.steward_state
if not isinstance(steward_state, dict) or not steward_state:
@@ -443,6 +619,10 @@ def _attach_runtime_conversation_state(
return decision
conversation_service = AgentConversationService(db)
conversation = conversation_service.get_conversation(conversation_id)
if conversation is None:
return decision
_require_steward_conversation_access(conversation, current_user)
conversation_service.update_state(
conversation_id=conversation_id,
run_id=None,
@@ -459,18 +639,26 @@ def _attach_runtime_conversation_state(
def _hydrate_runtime_decision_payload(
db: Session,
payload: StewardRuntimeDecisionRequest,
*,
current_user: CurrentUserContext,
) -> StewardRuntimeDecisionRequest:
context_json = dict(payload.context_json or {})
runtime_state = dict(payload.runtime_state or {})
conversation_id = _resolve_conversation_id(context_json)
conversation = (
AgentConversationService(db).get_conversation(conversation_id)
if conversation_id
else None
)
if conversation is not None:
_require_steward_conversation_access(conversation, current_user)
if isinstance(runtime_state.get("steward_state"), dict) and runtime_state["steward_state"]:
return payload
if isinstance(context_json.get("steward_state"), dict) and context_json["steward_state"]:
return payload
conversation_id = _resolve_conversation_id(context_json)
if not conversation_id:
return payload
conversation = AgentConversationService(db).get_conversation(conversation_id)
stored_state = conversation.state_json.get("steward_state") if conversation and isinstance(conversation.state_json, dict) else None
if not isinstance(stored_state, dict) or not stored_state:
return payload
@@ -487,6 +675,30 @@ def _hydrate_runtime_decision_payload(
)
def _require_steward_conversation_access(
conversation: Any,
current_user: CurrentUserContext,
) -> None:
expected_tenant_id = _require_steward_tenant_id(current_user)
expected_user_id = _require_steward_user_id(current_user)
state_json = (
dict(conversation.state_json)
if isinstance(conversation.state_json, dict)
else {}
)
conversation_tenant_id = str(state_json.get("tenant_id") or "").strip()
conversation_user_id = str(conversation.user_id or "").strip()
if (
conversation_tenant_id == expected_tenant_id
and conversation_user_id == expected_user_id
):
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="当前会话不属于登录用户或租户,已拒绝访问。",
)
def _resolve_conversation_id(context_json: dict[str, Any]) -> str | None:
return str(
context_json.get("conversation_id")

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter
from app.api.v1.endpoints.agent_asset_releases import router as agent_asset_releases_router
from app.api.v1.endpoints.agent_asset_risk_rules import router as agent_asset_risk_rules_router
from app.api.v1.endpoints.agent_assets import router as agent_assets_router
from app.api.v1.endpoints.agent_feedback import router as agent_feedback_router
@@ -15,6 +16,9 @@ from app.api.v1.endpoints.audit_logs import router as audit_logs_router
from app.api.v1.endpoints.auth import router as auth_router
from app.api.v1.endpoints.bootstrap import router as bootstrap_router
from app.api.v1.endpoints.budgets import router as budgets_router
from app.api.v1.endpoints.cfo_value import router as cfo_value_router
from app.api.v1.endpoints.commercial import router as commercial_router
from app.api.v1.endpoints.commercial_billing import router as commercial_billing_router
from app.api.v1.endpoints.employee_profiles import router as employee_profiles_router
from app.api.v1.endpoints.employees import router as employees_router
from app.api.v1.endpoints.expense_application_memories import (
@@ -24,6 +28,8 @@ from app.api.v1.endpoints.expense_application_previews import (
router as expense_application_previews_router,
)
from app.api.v1.endpoints.expense_cases import router as expense_cases_router
from app.api.v1.endpoints.financial_connectors import router as financial_connectors_router
from app.api.v1.endpoints.finance_report_configs import router as finance_report_configs_router
from app.api.v1.endpoints.health import router as health_router
from app.api.v1.endpoints.knowledge import router as knowledge_router
from app.api.v1.endpoints.linked_reimbursement_draft_jobs import (
@@ -36,6 +42,7 @@ from app.api.v1.endpoints.orchestrator import router as orchestrator_router
from app.api.v1.endpoints.receipt_folder import router as receipt_folder_router
from app.api.v1.endpoints.reimbursements import router as reimbursements_router
from app.api.v1.endpoints.risk_observations import router as risk_observations_router
from app.api.v1.endpoints.savings import router as savings_router
from app.api.v1.endpoints.settings import router as settings_router
from app.api.v1.endpoints.steward import router as steward_router
from app.api.v1.endpoints.system_logs import router as system_logs_router
@@ -45,8 +52,12 @@ router.include_router(health_router, tags=["health"])
router.include_router(bootstrap_router, tags=["bootstrap"])
router.include_router(auth_router, tags=["auth"])
router.include_router(budgets_router, tags=["budgets"])
router.include_router(cfo_value_router, tags=["analytics"])
router.include_router(commercial_router, tags=["commercial"])
router.include_router(commercial_billing_router, tags=["commercial"])
router.include_router(agent_assets_router, tags=["agent-assets"])
router.include_router(agent_asset_risk_rules_router, tags=["agent-assets"])
router.include_router(agent_asset_releases_router, tags=["agent-assets"])
router.include_router(agent_feedback_router, tags=["agent-feedback"])
router.include_router(agent_runs_router, tags=["agent-runs"])
router.include_router(agent_traces_router, tags=["agent-traces"])
@@ -67,11 +78,14 @@ router.include_router(orchestrator_router, tags=["orchestrator"])
router.include_router(receipt_folder_router, tags=["receipt-folder"])
router.include_router(employees_router, prefix="/employees", tags=["employees"])
router.include_router(expense_cases_router, tags=["expense-cases"])
router.include_router(financial_connectors_router, tags=["financial-connectors"])
router.include_router(finance_report_configs_router, tags=["finance-report-config"])
router.include_router(expense_application_memories_router, tags=["expense-application-memories"])
router.include_router(expense_application_previews_router, tags=["reimbursements"])
router.include_router(employee_profiles_router, tags=["employee-profiles"])
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])
router.include_router(risk_observations_router, tags=["risk-observations"])
router.include_router(savings_router, tags=["savings"])
router.include_router(settings_router, tags=["settings"])
router.include_router(steward_router, tags=["steward"])
router.include_router(system_logs_router, tags=["system-logs"])

View File

@@ -0,0 +1 @@
"""受控运维命令的可测试业务编排。"""

View File

@@ -0,0 +1,700 @@
from __future__ import annotations
import hashlib
import hmac
import json
import re
from collections import Counter
from collections.abc import Sequence
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from decimal import Decimal, InvalidOperation
from enum import StrEnum
from typing import Any
from sqlalchemy import and_, or_, 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.models.savings import SavingsOpportunity
from app.services.expense_claim_constants import STANDARD_ADJUSTMENT_RISK_SOURCE
from app.services.savings_discovery import SavingsDiscoveryService
DEFAULT_BATCH_SIZE = 100
MAX_BATCH_SIZE = 1000
_FINGERPRINT_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
class StandardAdjustmentBackfillDisposition(StrEnum):
ELIGIBLE = "eligible"
CREATED = "created"
REPLAYED = "replayed"
MISSING_CASE_LINK = "missing_case_link"
TENANT_CONFLICT = "tenant_conflict"
INVALID_CASE_LINK = "invalid_case_link"
MISSING_ITEM = "missing_item"
AMBIGUOUS_ITEM_HISTORY = "ambiguous_item_history"
DUPLICATE_FLAG = "duplicate_flag"
MISSING_SERVER_POLICY = "missing_server_policy"
MISSING_POLICY_VERSION = "missing_policy_version"
INVALID_POLICY_SNAPSHOT = "invalid_policy_snapshot"
INVALID_CALCULATION_FINGERPRINT = "invalid_calculation_fingerprint"
CALCULATION_FINGERPRINT_MISMATCH = "calculation_fingerprint_mismatch"
INVALID_ORIGINAL_AMOUNT = "invalid_original_amount"
ORIGINAL_AMOUNT_MISMATCH = "original_amount_mismatch"
INVALID_TARGET_AMOUNT = "invalid_target_amount"
INVALID_SAVING_DIFFERENCE = "invalid_saving_difference"
INVALID_CURRENCY = "invalid_currency"
@dataclass(frozen=True, slots=True)
class StandardAdjustmentBackfillCursor:
created_at: datetime
claim_id: str
@dataclass(frozen=True, slots=True)
class StandardAdjustmentBackfillItem:
claim_id: str
claim_no: str
item_id: str
disposition: StandardAdjustmentBackfillDisposition
reason: str
calculation_fingerprint: str = ""
policy_version: str = ""
currency: str = ""
original_amount: Decimal | None = None
target_amount: Decimal | None = None
saving_amount: Decimal | None = None
opportunity_id: str | None = None
@dataclass(frozen=True, slots=True)
class StandardAdjustmentBackfillPage:
tenant_id: str
claims_inspected: int
flags_inspected: int
eligible: int
replayed: int
skipped: int
reasons: dict[str, int]
has_more: bool
next_cursor: StandardAdjustmentBackfillCursor | None
items: tuple[StandardAdjustmentBackfillItem, ...]
@dataclass(frozen=True, slots=True)
class StandardAdjustmentBackfillResult:
tenant_id: str
run_id: str
claims_inspected: int
flags_inspected: int
created: int
replayed: int
skipped: int
reasons: dict[str, int]
has_more: bool
next_cursor: StandardAdjustmentBackfillCursor | None
items: tuple[StandardAdjustmentBackfillItem, ...]
@dataclass(slots=True)
class _BatchContext:
links_by_claim_id: dict[str, ExpenseCaseLink]
cases_by_id: dict[str, ExpenseCase]
opportunities_by_key: dict[str, SavingsOpportunity]
class StandardAdjustmentSavingsBackfillService:
"""严格按历史服务端证据回填节省机会;从不自行提交事务。"""
def __init__(
self,
db: Session,
*,
tenant_id: str,
created_before: datetime | None = None,
) -> None:
self.db = db
self.tenant_id = self._required_text(tenant_id, field_name="tenant_id", max_length=64)
self.created_before = (
self._aware(created_before, field_name="created_before")
if created_before is not None
else None
)
def preview(
self,
*,
batch_size: int = DEFAULT_BATCH_SIZE,
after: StandardAdjustmentBackfillCursor | None = None,
) -> StandardAdjustmentBackfillPage:
"""只读预览;不 add、不 flush、不 commit。"""
with self.db.no_autoflush:
claims, has_more = self._load_claims(
batch_size=self._batch_size(batch_size),
after=after,
lock_rows=False,
)
context = self._load_context(claims)
items = self._classify(claims, context=context)
return StandardAdjustmentBackfillPage(
tenant_id=self.tenant_id,
claims_inspected=len(claims),
flags_inspected=len(items),
eligible=self._count(items, StandardAdjustmentBackfillDisposition.ELIGIBLE),
replayed=self._count(items, StandardAdjustmentBackfillDisposition.REPLAYED),
skipped=self._skipped(items),
reasons=self._reason_counts(items),
has_more=has_more,
next_cursor=self._next_cursor(claims),
items=items,
)
def apply_batch(
self,
*,
run_id: str,
batch_size: int = DEFAULT_BATCH_SIZE,
after: StandardAdjustmentBackfillCursor | None = None,
) -> StandardAdjustmentBackfillResult:
"""锁定并应用一批;调用方负责按批 commit/rollback。"""
normalized_run_id = self._required_text(run_id, field_name="run_id", max_length=64)
claims, has_more = self._load_claims(
batch_size=self._batch_size(batch_size),
after=after,
lock_rows=True,
)
context = self._load_context(claims)
classified = self._classify(claims, context=context)
claims_by_id = {str(claim.id): claim for claim in claims}
items_by_claim = {
str(claim.id): {str(item.id): item for item in claim.items} for claim in claims
}
actor = self._system_actor(run_id=normalized_run_id)
result_items: list[StandardAdjustmentBackfillItem] = []
for item in classified:
if item.disposition is not StandardAdjustmentBackfillDisposition.ELIGIBLE:
result_items.append(item)
continue
claim = claims_by_id[item.claim_id]
claim_items = items_by_claim[item.claim_id]
source_flag = self._find_flag(claim, item=item)
discovered = SavingsDiscoveryService(self.db).discover_standard_adjustments(
claim=claim,
items_by_id=claim_items,
adjustment_flags=[source_flag],
current_user=actor,
request_id=f"savings-backfill:{normalized_run_id}",
)
if len(discovered) != 1:
raise RuntimeError("历史标准调整回填未返回唯一节省机会,当前批次必须回滚。")
opportunity = discovered[0]
context.opportunities_by_key[opportunity.opportunity_key] = opportunity
result_items.append(
replace(
item,
disposition=StandardAdjustmentBackfillDisposition.CREATED,
reason="证据完整,已通过 SavingsDiscoveryService 创建机会。",
opportunity_id=opportunity.id,
)
)
result = tuple(result_items)
return StandardAdjustmentBackfillResult(
tenant_id=self.tenant_id,
run_id=normalized_run_id,
claims_inspected=len(claims),
flags_inspected=len(result),
created=self._count(result, StandardAdjustmentBackfillDisposition.CREATED),
replayed=self._count(result, StandardAdjustmentBackfillDisposition.REPLAYED),
skipped=self._skipped(result),
reasons=self._reason_counts(result),
has_more=has_more,
next_cursor=self._next_cursor(claims),
items=result,
)
def _load_claims(
self,
*,
batch_size: int,
after: StandardAdjustmentBackfillCursor | None,
lock_rows: bool,
) -> tuple[list[ExpenseClaim], bool]:
stmt = select(ExpenseClaim).options(selectinload(ExpenseClaim.items))
if self.created_before is not None:
stmt = stmt.where(ExpenseClaim.created_at < self.created_before)
if after is not None:
cursor_time = self._aware(after.created_at, field_name="after.created_at")
cursor_id = self._required_text(
after.claim_id,
field_name="after.claim_id",
max_length=36,
)
stmt = stmt.where(
or_(
ExpenseClaim.created_at > cursor_time,
and_(ExpenseClaim.created_at == cursor_time, ExpenseClaim.id > cursor_id),
)
)
stmt = stmt.order_by(ExpenseClaim.created_at.asc(), ExpenseClaim.id.asc()).limit(
batch_size + 1
)
if lock_rows:
stmt = stmt.with_for_update()
# JSON 文本过滤在不同数据库方言上语义不完全一致,因此在 Python 中确认来源;
# 这里按 Claim 分页,空标志页仍可通过 cursor 继续推进。
rows = list(self.db.scalars(stmt).unique().all())
return rows[:batch_size], len(rows) > batch_size
def _load_context(self, claims: Sequence[ExpenseClaim]) -> _BatchContext:
claim_ids = [str(claim.id) for claim in claims]
if not claim_ids:
return _BatchContext({}, {}, {})
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 = {str(link.expense_case_id) for link in links}
cases_by_id = (
{
str(expense_case.id): expense_case
for expense_case in self.db.scalars(
select(ExpenseCase).where(ExpenseCase.id.in_(case_ids))
).all()
}
if case_ids
else {}
)
opportunities = list(
self.db.scalars(
select(SavingsOpportunity).where(
SavingsOpportunity.tenant_id == self.tenant_id,
SavingsOpportunity.claim_id.in_(claim_ids),
SavingsOpportunity.source_type == "standard_adjustment",
)
).all()
)
return _BatchContext(
links_by_claim_id=links_by_claim_id,
cases_by_id=cases_by_id,
opportunities_by_key={item.opportunity_key: item for item in opportunities},
)
def _classify(
self,
claims: Sequence[ExpenseClaim],
*,
context: _BatchContext,
) -> tuple[StandardAdjustmentBackfillItem, ...]:
results: list[StandardAdjustmentBackfillItem] = []
for claim in claims:
flags = self._standard_adjustment_flags(claim)
if not flags:
continue
grouped: dict[str, list[dict[str, Any]]] = {}
for flag in flags:
grouped.setdefault(str(flag.get("item_id") or "").strip(), []).append(flag)
for _item_id, item_flags in grouped.items():
fingerprints = {
str(flag.get("calculation_fingerprint") or "").strip()
for flag in item_flags
}
if len(fingerprints) > 1:
results.extend(
self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.AMBIGUOUS_ITEM_HISTORY,
"同一明细存在多个不同政策计算快照,无法安全判断应货币化哪一版。",
)
for flag in item_flags
)
continue
first, *duplicates = item_flags
results.append(self._classify_flag(claim, first, context=context))
results.extend(
self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.DUPLICATE_FLAG,
"同一明细存在重复标准调整标志;仅首条进入证据校验。",
)
for flag in duplicates
)
return tuple(results)
def _classify_flag(
self,
claim: ExpenseClaim,
flag: dict[str, Any],
*,
context: _BatchContext,
) -> StandardAdjustmentBackfillItem:
item_id = str(flag.get("item_id") or "").strip()
link = context.links_by_claim_id.get(str(claim.id))
if link is None:
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.MISSING_CASE_LINK,
"Claim 没有 ExpenseCaseLink租户归属无法证明。",
)
if link.tenant_id != self.tenant_id:
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.TENANT_CONFLICT,
"Claim 的 ExpenseCaseLink 属于其他租户。",
)
expense_case = context.cases_by_id.get(str(link.expense_case_id))
if (
expense_case is None
or expense_case.tenant_id != self.tenant_id
or not str(link.relation_type or "").strip()
):
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.INVALID_CASE_LINK,
"ExpenseCaseLink 关联目标缺失、租户不一致或关系类型为空。",
)
item = next((entry for entry in claim.items if str(entry.id) == item_id), None)
if item is None:
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.MISSING_ITEM,
"标准调整引用的费用明细不存在。",
)
if str(flag.get("calculation_source") or "").strip() != "server_policy":
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.MISSING_SERVER_POLICY,
"缺少 calculation_source=server_policy不能证明由服务端政策计算。",
)
policy_version = str(flag.get("policy_rule_version") or "").strip()
if not policy_version:
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.MISSING_POLICY_VERSION,
"缺少服务端政策版本。",
)
calculation_fingerprint = str(flag.get("calculation_fingerprint") or "").strip()
if not _FINGERPRINT_PATTERN.fullmatch(calculation_fingerprint):
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.INVALID_CALCULATION_FINGERPRINT,
"政策计算指纹不是合法 sha256 快照。",
)
original = self._money(flag.get("original_amount"))
if original is None or original <= Decimal("0"):
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.INVALID_ORIGINAL_AMOUNT,
"原金额缺失、格式错误或不为正数。",
)
item_amount = self._money(item.item_amount)
if item_amount is None or item_amount != original:
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.ORIGINAL_AMOUNT_MISMATCH,
"历史原金额与当前 ExpenseClaimItem.item_amount 不一致。",
original=original,
)
target = self._money(flag.get("reimbursable_amount"))
if target is None or target < Decimal("0"):
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.INVALID_TARGET_AMOUNT,
"目标报销金额缺失、格式错误或为负数。",
original=original,
)
saving = (original - target).quantize(Decimal("0.01"))
absorbed = self._money(flag.get("employee_absorbed_amount"))
if saving <= Decimal("0") or absorbed is None or absorbed != saving:
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.INVALID_SAVING_DIFFERENCE,
"差额不为正数,或员工承担金额与原金额减目标金额不一致。",
original=original,
target=target,
saving=saving,
)
currency = str(claim.currency or "").strip().upper()
if len(currency) != 3 or not currency.isalpha():
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.INVALID_CURRENCY,
"Claim 缺少合法三位币种,禁止默认猜测币种。",
original=original,
target=target,
saving=saving,
)
expected_fingerprint = self._calculation_fingerprint(flag, original, target)
if expected_fingerprint is None:
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.INVALID_POLICY_SNAPSHOT,
"政策天数、地点、职级、酒店标准或规则名称快照不完整。",
original=original,
target=target,
saving=saving,
)
if not hmac.compare_digest(expected_fingerprint, calculation_fingerprint):
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.CALCULATION_FINGERPRINT_MISMATCH,
"按历史政策快照重算的 SHA-256 与存储指纹不一致。",
original=original,
target=target,
saving=saving,
)
opportunity_key = self._opportunity_key(
claim_id=str(claim.id),
item_id=item_id,
policy_version=policy_version,
calculation_fingerprint=calculation_fingerprint,
)
existing = context.opportunities_by_key.get(opportunity_key)
if existing is not None:
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.REPLAYED,
"相同稳定键的节省机会已存在。",
original=original,
target=target,
saving=saving,
opportunity_id=existing.id,
)
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.ELIGIBLE,
"Case、租户、金额及服务端政策指纹均验证通过。",
original=original,
target=target,
saving=saving,
)
@staticmethod
def _standard_adjustment_flags(claim: ExpenseClaim) -> list[dict[str, Any]]:
return [
flag
for flag in list(claim.risk_flags_json or [])
if isinstance(flag, dict)
and str(flag.get("source") or "").strip() == STANDARD_ADJUSTMENT_RISK_SOURCE
]
@classmethod
def _find_flag(
cls,
claim: ExpenseClaim,
*,
item: StandardAdjustmentBackfillItem,
) -> dict[str, Any]:
matches = [
flag
for flag in cls._standard_adjustment_flags(claim)
if str(flag.get("item_id") or "").strip() == item.item_id
and str(flag.get("calculation_fingerprint") or "").strip()
== item.calculation_fingerprint
]
if len(matches) != 1:
raise RuntimeError("历史标准调整证据在锁定后发生歧义,当前批次必须回滚。")
return matches[0]
@classmethod
def _calculation_fingerprint(
cls,
flag: dict[str, Any],
original: Decimal,
target: Decimal,
) -> str | None:
try:
days = int(flag["policy_days"])
location = cls._snapshot_text(flag, "policy_location")
matched_city = cls._snapshot_text(flag, "policy_matched_city")
grade = cls._snapshot_text(flag, "policy_grade")
grade_band = cls._snapshot_text(flag, "policy_grade_band")
hotel_rate = cls._snapshot_money(flag, "policy_hotel_rate")
hotel_amount = cls._snapshot_money(flag, "policy_hotel_amount")
rule_name = cls._snapshot_text(flag, "policy_rule_name")
rule_version = cls._snapshot_text(flag, "policy_rule_version")
except (KeyError, TypeError, ValueError, InvalidOperation):
return None
if days <= 0 or hotel_rate < Decimal("0") or hotel_amount < Decimal("0"):
return None
material = {
"item_id": str(flag.get("item_id") or "").strip(),
"original_amount": f"{original:.2f}",
"reimbursable_amount": f"{target:.2f}",
"policy_days": days,
"policy_location": location,
"policy_matched_city": matched_city,
"policy_grade": grade,
"policy_grade_band": grade_band,
"policy_hotel_rate": f"{hotel_rate:.2f}",
"policy_hotel_amount": f"{hotel_amount:.2f}",
"policy_rule_name": rule_name,
"policy_rule_version": rule_version,
}
canonical = json.dumps(
material,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return f"sha256:{hashlib.sha256(canonical.encode('utf-8')).hexdigest()}"
def _opportunity_key(
self,
*,
claim_id: str,
item_id: str,
policy_version: str,
calculation_fingerprint: str,
) -> str:
material = {
"tenant_id": self.tenant_id,
"claim_id": claim_id,
"item_id": item_id,
"policy_version": policy_version,
"calculation_fingerprint": calculation_fingerprint,
}
canonical = json.dumps(material, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return f"standard-adjustment:{hashlib.sha256(canonical.encode('utf-8')).hexdigest()}"
def _item(
self,
claim: ExpenseClaim,
flag: dict[str, Any],
disposition: StandardAdjustmentBackfillDisposition,
reason: str,
*,
original: Decimal | None = None,
target: Decimal | None = None,
saving: Decimal | None = None,
opportunity_id: str | None = None,
) -> StandardAdjustmentBackfillItem:
return StandardAdjustmentBackfillItem(
claim_id=str(claim.id),
claim_no=str(claim.claim_no or claim.id),
item_id=str(flag.get("item_id") or "").strip(),
disposition=disposition,
reason=reason,
calculation_fingerprint=str(flag.get("calculation_fingerprint") or "").strip(),
policy_version=str(flag.get("policy_rule_version") or "").strip(),
currency=str(claim.currency or "").strip().upper(),
original_amount=original,
target_amount=target,
saving_amount=saving,
opportunity_id=opportunity_id,
)
def _system_actor(self, *, run_id: str) -> CurrentUserContext:
return CurrentUserContext(
username=f"savings-backfill:{run_id}"[:120],
name="Savings 历史回填",
role_codes=["finance"],
is_admin=False,
tenant_id=self.tenant_id,
)
@staticmethod
def _snapshot_text(flag: dict[str, Any], key: str) -> str:
value = str(flag[key] or "").strip()
if not value:
raise ValueError(f"{key} is blank")
return value
@classmethod
def _snapshot_money(cls, flag: dict[str, Any], key: str) -> Decimal:
value = cls._money(flag[key])
if value is None:
raise ValueError(f"{key} is not money")
return value
@staticmethod
def _money(value: Any) -> Decimal | None:
if isinstance(value, bool) or value is None or str(value).strip() == "":
return None
try:
parsed = Decimal(str(value))
except (InvalidOperation, ValueError):
return None
if not parsed.is_finite():
return None
return parsed.quantize(Decimal("0.01"))
@staticmethod
def _required_text(value: str, *, field_name: str, max_length: int) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{field_name} must not be empty")
if len(normalized) > max_length:
raise ValueError(f"{field_name} must be at most {max_length} characters")
return normalized
@staticmethod
def _aware(value: datetime, *, field_name: str) -> datetime:
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError(f"{field_name} must include timezone")
return value.astimezone(UTC)
@staticmethod
def _batch_size(value: int) -> int:
normalized = int(value)
if normalized < 1 or normalized > MAX_BATCH_SIZE:
raise ValueError(f"batch_size must be between 1 and {MAX_BATCH_SIZE}")
return normalized
@staticmethod
def _next_cursor(claims: Sequence[ExpenseClaim]) -> StandardAdjustmentBackfillCursor | None:
if not claims:
return None
claim = claims[-1]
return StandardAdjustmentBackfillCursor(created_at=claim.created_at, claim_id=str(claim.id))
@staticmethod
def _count(
items: Sequence[StandardAdjustmentBackfillItem],
disposition: StandardAdjustmentBackfillDisposition,
) -> int:
return sum(item.disposition is disposition for item in items)
@staticmethod
def _reason_counts(items: Sequence[StandardAdjustmentBackfillItem]) -> dict[str, int]:
counts = Counter(item.disposition.value for item in items)
return dict(sorted(counts.items()))
@staticmethod
def _skipped(items: Sequence[StandardAdjustmentBackfillItem]) -> int:
accepted = {
StandardAdjustmentBackfillDisposition.ELIGIBLE,
StandardAdjustmentBackfillDisposition.CREATED,
StandardAdjustmentBackfillDisposition.REPLAYED,
}
return sum(item.disposition not in accepted for item in items)

View File

@@ -0,0 +1,3 @@
AGENT_ASSET_PLATFORM_SCOPE = "platform"
AGENT_ASSET_TENANT_SCOPE = "tenant"
AGENT_ASSET_PLATFORM_TENANT_ID = "platform"

View File

@@ -0,0 +1,70 @@
"""Agent 发布遥测伪名密钥的本地版本化存储。"""
from __future__ import annotations
import base64
import binascii
import os
import re
import secrets
from pathlib import Path
from app.core.config import SERVER_DIR
FINGERPRINT_KEY_DIRECTORY = SERVER_DIR / ".secrets" / "agent-release-telemetry"
ACTIVE_KEY_VERSION_ENV = "AGENT_RELEASE_TELEMETRY_KEY_VERSION"
DEFAULT_ACTIVE_KEY_VERSION = "v1"
KEY_BYTES = 32
_VERSION_PATTERN = re.compile(r"^[a-zA-Z0-9._-]{1,32}$")
def active_agent_release_telemetry_key_version() -> str:
version = str(os.environ.get(ACTIVE_KEY_VERSION_ENV) or DEFAULT_ACTIVE_KEY_VERSION).strip()
if not _VERSION_PATTERN.fullmatch(version):
raise ValueError("Agent 发布遥测指纹密钥版本无效。")
return version
def available_agent_release_telemetry_key_versions() -> list[str]:
active = active_agent_release_telemetry_key_version()
versions = {active}
if FINGERPRINT_KEY_DIRECTORY.exists():
for path in FINGERPRINT_KEY_DIRECTORY.glob("*.key"):
if path.is_file() and not path.is_symlink() and _VERSION_PATTERN.fullmatch(path.stem):
versions.add(path.stem)
return [active, *sorted(versions - {active})]
def get_agent_release_telemetry_key(version: str, *, create: bool) -> bytes:
normalized = str(version or "").strip()
if not _VERSION_PATTERN.fullmatch(normalized):
raise ValueError("Agent 发布遥测指纹密钥版本无效。")
key_path = FINGERPRINT_KEY_DIRECTORY / f"{normalized}.key"
if not key_path.exists():
if not create:
raise ValueError("Agent 发布遥测指纹密钥版本不可用。")
_create_key_atomically(key_path)
if key_path.is_symlink() or not key_path.is_file():
raise ValueError("Agent 发布遥测指纹密钥文件无效。")
os.chmod(key_path, 0o600)
try:
key = base64.urlsafe_b64decode(key_path.read_text(encoding="utf-8").strip())
except (binascii.Error, ValueError, UnicodeError) as error:
raise ValueError("Agent 发布遥测指纹密钥内容无效。") from error
if len(key) != KEY_BYTES:
raise ValueError("Agent 发布遥测指纹密钥长度无效。")
return key
def _create_key_atomically(key_path: Path) -> None:
key_path.parent.mkdir(parents=True, exist_ok=True)
os.chmod(key_path.parent, 0o700)
encoded = base64.urlsafe_b64encode(secrets.token_bytes(KEY_BYTES))
try:
descriptor = os.open(key_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
return
with os.fdopen(descriptor, "wb") as stream:
stream.write(encoded)
stream.flush()
os.fsync(stream.fileno())

View File

@@ -73,6 +73,10 @@ class Settings(BaseSettings):
onlyoffice_backend_url: str = Field(default="", alias="ONLYOFFICE_BACKEND_URL")
onlyoffice_jwt_secret: str = Field(default="", alias="ONLYOFFICE_JWT_SECRET")
hermes_agent_shared_token: str = Field(default="", alias="HERMES_AGENT_SHARED_TOKEN")
financial_connector_hmac_keys_json: str = Field(
default="",
alias="FINANCIAL_CONNECTOR_HMAC_KEYS_JSON",
)
steward_agent_runtime: str = Field(default="langgraph", alias="STEWARD_AGENT_RUNTIME")
log_level: str = Field(default="INFO", alias="LOG_LEVEL")

View File

@@ -6,6 +6,11 @@ from app.models.agent_asset import (
AgentAssetTestRun,
AgentAssetVersion,
)
from app.models.agent_asset_release_telemetry import (
AgentAssetReleaseAuditSample,
AgentAssetReleaseLabel,
AgentAssetReleaseObservation,
)
from app.models.agent_conversation import AgentConversation, AgentConversationMessage
from app.models.agent_feedback import AgentOperationFeedback
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
@@ -19,11 +24,28 @@ 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
from app.models.commercial import (
CommercialCostEvent,
CommercialEntitlement,
TenantCommercialPlan,
TenantSubscription,
UsageMeterEvent,
)
from app.models.commercial_billing import CommercialAdminEvent, CommercialBillingPeriod
from app.models.commercial_runtime import CommercialRuntimeReservation
from app.models.employee import Employee
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
from app.models.employee_change_log import EmployeeChangeLog
from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
from app.models.few_shot_sample import FewShotSample
from app.models.financial_connector import (
FinancialConnectorConfig,
FinancialConnectorConfigEvent,
FinancialConnectorEvent,
FinancialConnectorOperationalEvent,
PaymentReconciliationCase,
PaymentReconciliationEvent,
)
from app.models.financial_record import (
AccountsPayableRecord,
AccountsReceivableRecord,
@@ -33,6 +55,7 @@ from app.models.financial_record import (
from app.models.golden_case import GoldenCase
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
from app.models.hermes_report import HermesRiskReport
from app.models.knowledge_security import KnowledgeOnlyOfficeSession
from app.models.notification_state import NotificationState
from app.models.organization import OrganizationUnit
from app.models.reimbursement import ReimbursementRequest
@@ -42,6 +65,11 @@ from app.models.role import Role
from app.models.system_model_setting import SystemModelSetting
from app.models.system_setting import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret
from app.models.tenant import Tenant, TenantMembership
from app.models.tenant_finance_report import (
TenantFinanceReportConfig,
TenantFinanceReportRun,
)
from app.models.user_session_metric import UserSessionMetric
__all__ = [
@@ -55,6 +83,9 @@ __all__ = [
"AgentAssetRuleFeedback",
"AgentAssetTestRun",
"AgentAssetVersion",
"AgentAssetReleaseAuditSample",
"AgentAssetReleaseLabel",
"AgentAssetReleaseObservation",
"AgentOperationFeedback",
"AgentRun",
"AgentToolCall",
@@ -72,6 +103,11 @@ __all__ = [
"BudgetAllocation",
"BudgetReservation",
"BudgetTransaction",
"CommercialCostEvent",
"CommercialAdminEvent",
"CommercialBillingPeriod",
"CommercialEntitlement",
"CommercialRuntimeReservation",
"Employee",
"ExpenseCase",
"ExpenseCaseLink",
@@ -81,13 +117,20 @@ __all__ = [
"ExpenseClaim",
"FewShotSample",
"ExpenseClaimItem",
"FinancialConnectorConfig",
"FinancialConnectorConfigEvent",
"FinancialConnectorEvent",
"FinancialConnectorOperationalEvent",
"GoldenCase",
"HermesTaskConfig",
"HermesTaskExecutionLog",
"HermesRiskReport",
"KnowledgeOnlyOfficeSession",
"MemoryEntry",
"MemoryEvidenceLink",
"NotificationState",
"PaymentReconciliationCase",
"PaymentReconciliationEvent",
"OrganizationUnit",
"ReimbursementRequest",
"RiskDisposition",
@@ -99,6 +142,13 @@ __all__ = [
"SystemModelSetting",
"SystemSetting",
"SystemSettingSecret",
"Tenant",
"TenantCommercialPlan",
"TenantMembership",
"TenantFinanceReportConfig",
"TenantFinanceReportRun",
"TenantSubscription",
"UsageMeterEvent",
"UserSessionMetric",
"WorkflowOutcome",
]

View File

@@ -238,7 +238,89 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = {
}
),
}
if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0014"] != MIGRATION_OWNED_TABLES:
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0015"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0014"]
| frozenset(
{
"profile_baseline_snapshots",
"savings_opportunities",
"savings_realizations",
"savings_evidence_links",
"savings_events",
}
)
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0016"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0015"]
| frozenset(
{
"tenant_commercial_plans",
"tenant_subscriptions",
"commercial_entitlements",
"usage_meter_events",
"commercial_cost_events",
}
)
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0017"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0016"]
| frozenset(
{
"financial_connector_configs",
"financial_connector_events",
"payment_reconciliation_cases",
"payment_reconciliation_events",
}
)
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0018"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0017"]
| frozenset(
{
"agent_asset_release_observations",
"agent_asset_release_labels",
}
)
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0019"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0018"]
| frozenset({"commercial_runtime_reservations"})
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0020"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0019"]
| frozenset({"financial_connector_config_events"})
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0021"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0020"]
| frozenset({"commercial_admin_events", "commercial_billing_periods"})
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0022"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0021"]
| frozenset({"financial_connector_operational_events"})
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0023"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0022"]
| frozenset({"agent_asset_release_audit_samples"})
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0024"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0023"]
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0025"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0024"]
| frozenset({"tenants"})
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0026"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0025"]
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0027"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0026"]
| frozenset({"knowledge_onlyoffice_sessions"})
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0028"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0027"]
| frozenset({"tenant_finance_report_configs", "tenant_finance_report_runs"})
)
if MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0028"] != MIGRATION_OWNED_TABLES:
raise RuntimeError("latest Alembic revision must own the centralized migration table set")
# 0008 之前这三张表由旧 bootstrap / 风险服务按需创建。迁移前置检查允许
@@ -314,6 +396,20 @@ def _validate_connection(connection: Connection) -> MigrationPreflightState:
"20260716_0012",
"20260716_0013",
"20260716_0014",
"20260716_0015",
"20260716_0016",
"20260716_0017",
"20260716_0018",
"20260716_0019",
"20260716_0020",
"20260716_0021",
"20260716_0022",
"20260716_0023",
"20260717_0024",
"20260717_0025",
"20260717_0026",
"20260717_0027",
"20260717_0028",
}
else frozenset()
)

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