46 Commits

Author SHA1 Message Date
caoxiaozhu
d660a961fb feat(web): AI 工作台附件改为卡片化展示并支持单项移除
- PersonalWorkbenchAiMode 附件区由计数条改为按类型图标/名称/类型标签的卡片列表,支持单项移除(removeAiModeFile),复用 buildFileIdentity 作为 key
- resolveAiComposerFileType 按 pdf/图片/表格/文档/压缩包/文件归类,分别对应图标与色调
- .gitignore 补充忽略 server/storage/receipt_folder/ 运行时票据存储目录
2026-06-21 23:24:16 +08:00
caoxiaozhu
669d22e71f feat(web): 差旅领导意见事件结构化与申请审批信息增强
- applicationApproval 新增按日期/时间/审批角色拆分格式化,buildLeaderApprovalEvents 补充 dateLabel/timeLabel/roleLabel 字段
- TravelRequestDetailView 领导意见事件改为日期+时间+审批人结构化展示,travel-request-detail-view.css 重构对应样式
- travelReimbursementAttachmentModel 微调附件标识,同步更新 application-approval-info、travel-request-detail-leader-approval、attachment-association-confirmation 测试
- 更新公司通信费报销规则表
2026-06-21 23:24:09 +08:00
caoxiaozhu
88e91a5900 feat(ocr): PDF 文本层可用时跳过 worker 调用并补装 poppler-data
- OcrService 提取 PDF 文本层后若有效字符达到阈值,直接构建文档并写入结果缓存,不再触发 OCR worker,仅无文本层时才解析 python_bin/worker_path 调用 worker
- _build_text_layer_document 复用 AggregatedOcrDocument 聚合文本层片段,_has_usable_pdf_text_layer 基于 meaningful_char_count 判定
- docker-compose 与 paddleocr bootstrap 脚本补装 poppler-data,保证 PDF 文本层抽取的中文编码正确
- 新增文本层直取与运行时依赖两项 ocr_service 单测
2026-06-21 23:23:59 +08:00
caoxiaozhu
1986b0d945 style(web): 移除顶栏 AI 快捷操作区并优化差旅领导意见事件样式
- TopBar 移除 AI 模式下的公司切换/AI 模式切换快捷操作块及 showAiModeUtilityActions 计算属性,清理 top-bar.css 对应样式
- TravelRequestDetailView 领导意见事件重构为状态/意见/审批人结构化布局,travel-request-detail-view.css 补充对应样式
- 同步更新 topbar-ai-mode-switch、ai-sidebar-rail-mode、travel-request-detail-leader-approval 测试
2026-06-21 22:56:34 +08:00
caoxiaozhu
24b5b71b0f feat(web): 差旅申请详情进度 viewer 与审批/加载态组件增强
- 新增 requestProgressViewer,申请单在直属领导审批视角下将当前步骤展示为'等待批复',travel-request-detail/app-shell/useRequests 接入
- TravelRequestApprovalDialog 增强审批交互,TableLoadingState 补充表格加载占位,ConfirmDialog 扩展确认对话框能力
- useAppShell/useRequests/AppShellRouteView 配套适配申请详情跳转与会话状态
- 同步更新 requestProgressSteps、travel-request-detail-leader-approval、assistant-session-draft-delete、documents-center-status-filter、app-shell-financial-assistant-entry、request-progress-viewer 等测试
2026-06-21 22:49:58 +08:00
caoxiaozhu
8b3495455b feat(web): AI 文档详情引用解析与查询卡片增强
- 新增 aiDocumentDetailReference,统一解析 #ai-open-document-detail / #ai-open-application-detail 引用,兼容 A/R/D 短格式与 AP-/RE-/AD- 旧格式单号,提供 isBusinessDocumentReference 判定
- aiDocumentQueryModel 文档卡片接入详情引用,按申请单/报销单生成对应 href,HTML 渲染器识别单据记录表格并生成卡片链接
- PersonalWorkbenchAiMode 处理文档详情点击跳转,卡片样式重构为结构化布局并更新背景资源
- expenseApplicationPreview 补充事由字段,同步新增/更新 ai-document-detail-reference、document-query-model、html-renderer、workbench-ai-mode 等测试
- 更新公司通信费报销规则表
2026-06-21 22:49:53 +08:00
caoxiaozhu
3b74a330a3 feat(web): AI 文档查询卡片重构与单号判定统一
- documentClassification 抽出 isApplicationDocumentNo,统一兼容 AP-/APP- 旧格式与 A+8 新格式,aiDocumentQueryModel 复用
- aiDocumentQueryModel 文档卡片改为结构化字段布局(单据类型/金额/申请人/编号/操作),新增查询范围摘要区,渲染走 HTML 信任块
- AppShellRouteView/useAppShell/useRequests/detailAlerts/riskVisibility 等差旅详情模型适配单号判定
- 同步更新 ai-document-query-model/workbench-ai-mode-switch 测试,新增 document-classification 测试
2026-06-20 22:04:37 +08:00
caoxiaozhu
8158716e23 test(server): 适配 A/R/D 紧凑单号格式
- approval_routing/service/user_agent 测试中报销单查询统一兼容 RE- 旧格式与 R+8 新格式,申请单单号断言改为短格式
- generate_claim_no 用例重命名为短前缀校验,正则改为 R[A-HJ-NP-Z2-9]{8}
- 同步更新差旅/交通/通信等财务规则表
2026-06-20 22:04:31 +08:00
caoxiaozhu
0cda750ff0 feat(web): AI 工作台会话与文档卡片渲染增强
- aiConversationHtmlRenderer 识别单据记录类表格并渲染为卡片列表,新增删除申请单详情的禁用占位链接
- aiWorkbenchConversationStore 增加草稿删除后会话链接失效处理,避免点击已删除单据跳转
- aiApplicationPreviewActions 调整提交/草稿调用路径,PersonalWorkbenchAiMode 接入新的会话存储与渲染
- ConfirmDialog/TravelRequestDeleteDialog/useAppShell/AppShellRouteView 配套适配,同步更新相关前端测试
2026-06-20 21:44:16 +08:00
caoxiaozhu
81e990ab72 feat(server): 申请单支持草稿保存并统一删除权限口径
- user_agent_application 新增草稿分支:识别'保存草稿/存草稿/先保存'等意图,复用可编辑记录更新或建草稿,提交前单据重叠仍拦截
- 草稿态返回单号与待提交提示,submit 仅在确认提交分支触发,避免草稿进入审批流
- reimbursements 删除接口文案与判定统一为系统管理员可删、申请人删自有草稿/退回单,申请单判定改用 is_application_claim_no
- 更新财务规则表与 reimbursement 端点测试
2026-06-20 21:44:12 +08:00
caoxiaozhu
47c6a4bb73 refactor(server): 单号规则收紧为 A/R/D+8 位紧凑格式
- DOCUMENT_NUMBER_PREFIXES 改为 A/R/D,新增短格式与旧格式正则并存识别,提取正则加边界锚定避免误匹配
- build_document_number 去掉时间戳段,统一生成 A+token 等紧凑单号,is_application_claim_no 兼容旧 AP-/APP- 前缀
- access_policy/status_registry/reimbursements/expense_claims/budget_support 统一复用 is_application_claim_no 判定申请单
- 同步 document_numbering 单元测试覆盖新旧两种格式
2026-06-20 21:44:06 +08:00
caoxiaozhu
96c2e1099a feat(web): 统一平台管理员判定与 AI 工作台申请预览动作接入
- authUser 抽出 resolveAuthUserAdminFlag,统一 isAdmin 解析(含 superadmin、role_codes、中英文角色名),accessControl 复用同一逻辑
- 登录态、应用外壳路由、系统状态接入统一管理员判定,LoginView 与相关 composable 配套调整
- AI 工作台申请提交改为调用新的 /application-preview-action 接口,草稿保存仍走 orchestrator;预审模型补充重叠冲突提示与阻断判断
- 同步更新 accessControl/api-request/ai 预览动作等前端测试
2026-06-20 14:42:04 +08:00
caoxiaozhu
729d833edb feat(server): 新增申请核对预览快速建单接口与平台管理员判定统一
- reimbursements 新增 POST /application-preview-action,AI 工作台表格核对后直接走 UserAgentService 建单/提交,免去通用 Orchestrator 编排
- 平台管理员判定统一抽取 PLATFORM_ADMIN_IDENTITIES 常量,identity 与 role_codes 均支持 admin/superadmin,含 header 开关
- docker-compose 镜像补装 openssh-server
- 同步更新差旅/交通/通信等财务规则表与 reimbursements 端点测试
2026-06-20 14:41:59 +08:00
caoxiaozhu
304bbe1fd4 feat(web): 工作台 AI 模式报销预审与文档查询模型拆分
- 新增 aiApplicationPrecheckModel/aiDocumentQueryModel/aiApplicationPreviewActions/aiConversationHtmlRenderer 四个独立模型与服务,按职责从主组件拆出
- PersonalWorkbenchAiMode 接入拆分后的预审、文档查询与 HTML 渲染逻辑,配合 markdown 工具增强结构化展示
- 文档中心与归档筛选、风险可见性、申请预览等工具同步适配,补充对应单元测试
- 新增 AI 文档卡片背景资源
2026-06-20 10:17:37 +08:00
caoxiaozhu
3d69f8501f feat(risk): 申请单阶段风险可见性细化与规则表更新
- 申请单阶段将 policy/trip/amount 类风险对申请人可见、可自行修正,画像/审批流程类仍走领导可见
- 平台风险标记与语义推断统一采用该策略,更新对应单元测试
- 风险规则中 city_mismatch 等城市匹配规则移除 expense_application 阶段,仅保留 reimbursement
- 同步更新交通/通信/差旅/出差等财务规则表
2026-06-20 10:17:18 +08:00
caoxiaozhu
4d04f4e7af chore(gitignore): 忽略 .env 防止敏感配置入库
将 .env 从版本库移除(本地文件保留),并补充 .env.local 等本地变体忽略规则,
避免数据库密码、密钥等敏感配置进入 git 历史。
2026-06-18 22:14:53 +08:00
caoxiaozhu
3131112952 style(web): 调整 AI 模式用户消息气泡布局与引用图标
- 用户消息气泡的操作区与时间右对齐
- 引用按钮图标由 mdi-reply 改为 mdi-format-quote-open
2026-06-18 22:13:09 +08:00
caoxiaozhu
a2f67af13e docs: 新增 X-Financial 改进路线图 2026-06-18 22:12:38 +08:00
caoxiaozhu
0cde1f8990 feat(web): 工作台 AI 模式与差旅/风险建议交互优化
- 新增 PersonalWorkbenchAiMode 组件、AI 侧边栏与 orb 机器人视觉资源
- 新增 aiApplicationDraftModel / aiExpenseDraftModel / aiWorkbenchConversationStore
  及业务准入 aiSidebarBusinessAccess,支撑 AI 模式下的申请与报销草稿
- 顶栏、侧边栏、工作台样式重构,适配 AI 模式切换与响应式布局
- 同步 steward plan/off_topic、差旅报销引导流、风险建议卡片等测试
2026-06-18 22:12:24 +08:00
caoxiaozhu
a6674a1e76 feat(steward): off_topic 场景细分与引导回复
- 将业务无关输入细分为 greeting / meaningless / off_business 三类场景
- 新增 StewardOffTopicAgent,用 function calling 生成管家语气引导回复
- steward endpoint 与 user_agent_application 串联 off_topic 引导话术
- 补充 planner 与 user agent 的 off_topic 覆盖测试
2026-06-18 22:12:10 +08:00
caoxiaozhu
127d603e7d feat(ontology): 仅放行财务业务相关问题的信号校验
- 新增 _has_supported_business_signal,在加载目录前拦截非财务问题并抛错
- 同步重构 ontology 服务测试覆盖业务信号判定分支
2026-06-18 22:12:00 +08:00
caoxiaozhu
3f17619e0c fix(auth): 登录目录就绪幂等化与并发控制
- employee/settings/user_session_metrics 的 ensure_*_ready 改为按 bind 缓存 + 锁,
  避免每次登录重复建表与并发场景下的竞态
- auth 登录链路先查员工再降级触发目录就绪,并吞掉查询期 SQLAlchemy 异常
- 默认管理员账号由 superadmin 迁移为 admin,兼容历史账号回填
- 补充登录降级与设置持久化相关测试
2026-06-18 22:11:53 +08:00
caoxiaozhu
59ba76c74a feat(startup): 服务端启动 bootstrap 与缓存预热
- 新增 STARTUP_BOOTSTRAP_ENABLED / STARTUP_CACHE_WARMUP_ENABLED 配置开关
- lifespan 拆分 bootstrap 步骤并后台线程预热缓存,失败可降级继续启动
- server_start.sh / web_start.sh 扩展 SERVER_PORT、启动与调度开关的 env 覆盖
- bootstrap_paddleocr_mobile.sh 改用 python3 并补充 poppler-utils 依赖
- 补充启动 bootstrap 与 env 覆盖优先级测试
2026-06-18 22:11:37 +08:00
caoxiaozhu
35372c6661 feat(rules): 更新差旅与通信费用等财务规则表 2026-06-18 22:11:13 +08:00
caoxiaozhu
38653fa365 chore(storage): 清理用户历史报销票据附件
移除测试期残留的 receipt_folder 附件与预览文件,后续通过归档目录维护。
2026-06-18 22:11:10 +08:00
caoxiaozhu
c28e99b714 chore(gitignore): 忽略 .nezha/ 与 .omo/ 本地工具目录 2026-06-18 22:11:06 +08:00
caoxiaozhu
43432534d8 feat(steward): 前端支持 off_topic 与引导话术
- assistantSessionScope.js:新增 ASSISTANT_SCOPE_ACTION_FILL_COMPOSER 常量
- assistantSuggestedActionPrefill.js:识别 fill_composer 与 payload.fill_text
- stewardPlanModel.js:normalizeStewardPlan 透传 suggestedPrompts;
  buildStewardPlanMessageText / buildStewardSuggestedActions
  新增 off_topic 分支,按钮填充输入框不提交
- useStewardPlanFlow.js:isPendingStewardActionMessage 排除 off_topic
- steward-plan-off-topic.test.mjs:覆盖 normalize/文案/按钮/兼容路径
2026-06-18 14:15:30 +08:00
caoxiaozhu
cce19e4c40 feat(steward): 拦截业务无关输入返回 off_topic 计划
- schemas/steward.py:StewardPlanResponse 新增 suggested_prompts 字段
- steward_planner.py:新增 STEWARD_BUSINESS_SIGNAL_KEYWORDS 与
  _is_business_irrelevant_input 守卫,在 build_plan 入口前置;
  新增 _build_off_topic_plan 构造 plan_status=off_topic 的引导计划
- steward_intent_agent.py:system prompt 追加业务无关约束
- test_steward_planner.py:覆盖 123/你好/纯标点走 off_topic,
  并验证正常业务输入不受守卫影响
2026-06-18 14:15:20 +08:00
caoxiaozhu
b8915a29c0 chore(storage): 归档用户报销票据附件 2026-06-17 14:39:41 +08:00
caoxiaozhu
4199feb681 test: 同步报销审批流与预算分析测试
- 新增预算审批合并、风险标记去重与占位条目校验用例
- 补充预算分析对当前审核人与财务的可见性断言
- 调整单据删除权限测试以匹配 admin 限制
2026-06-17 14:39:26 +08:00
caoxiaozhu
0fac8b615f feat(web): 优化差旅详情、风险建议卡片与文档中心交互
- 拆分阶段风险建议卡片样式到独立文件
- 完善差旅申请审批对话框与详情视图交互
- 调整文档中心列表共享样式与状态筛选
- 同步应用外壳、视图初始化与系统状态 composables
2026-06-17 14:39:12 +08:00
caoxiaozhu
a3e5295915 feat(rules): 更新财务差旅与通信费用规则表 2026-06-17 14:38:51 +08:00
caoxiaozhu
1f4681f486 feat(claim): 重构报销审批流并收敛风险标记
- 直属领导兼任部门 P8 预算审批人时合并预算审批,直接流转至财务审批
- 预算超过警戒值时强制要求预算管理者填写审批意见
- 新增风险标记去重工具,消除各审核阶段重复风险卡片
- 新增工作流修复 Mixin,纠正重复预算审批阶段的历史数据
- 收紧单据删除权限至 admin,放宽预算分析可见范围至当前审核人
- 提交校验放宽已上传票据条目的 OCR 字段缺失并忽略尾部占位条目
2026-06-17 14:38:07 +08:00
caoxiaozhu
09a66c72cb chore: 将 web 端口由 5173 调整为 5273 2026-06-17 14:37:50 +08:00
caoxiaozhu
0d525fa64c chore: 忽略 .codex-temp 工具临时目录 2026-06-17 14:34:36 +08:00
caoxiaozhu
470f343b29 fix(expense): narrow travel route risk indicators 2026-06-17 09:36:24 +08:00
caoxiaozhu
9f7b8b46a3 Refine travel reimbursement steward flow
Align planner, runtime rules, and policy assets so travel guidance
matches the updated reimbursement workflow.
2026-06-15 22:55:18 +08:00
caoxiaozhu
792741709a fix(claim): align risk advice with expense rows 2026-06-15 20:53:48 +08:00
caoxiaozhu
5747e85acf fix(risk): restore upload-time rule center review 2026-06-15 20:20:55 +08:00
Codex
8b952c9a26 refactor(travel): split reimbursement create workflow
完整修改内容:

- 拆分 TravelReimbursementCreateView:提取审核面板纯模型、消息操作、建议动作处理、生命周期 watcher/UI 映射、小财管家运行时、续办流程和运行时文本模型,减少主组件继续堆叠业务分支。
- 调整申请预览链路:新增本地申请意图 gate,完善复杂差旅申请的大模型复核判断、交通方式缺失/候选识别、规则中心交通费用预估合并和申请冲突处理。
- 优化小财管家流程:抽出 steward typewriter 分段策略,避免 Markdown 表格逐字闪烁;补齐跨助手 carry、字段补齐续办、文本确认提交和行程规划推荐动作。
- 调整消息与样式:移除申请预览日期 chip 样式,收敛申请卡片/报销草稿消息的展示与复制、朗读、反馈入口逻辑。
- 更新测试:将源码锚点迁移到新模块,覆盖申请预览、提交确认、小财管家续办、引导流和审核抽屉相关断言。

验证:

- node --check web/src/views/scripts/TravelReimbursementCreateView.js 及新增拆分模块
- npm --prefix web run build
- node --test web/tests/expense-application-fast-preview.test.mjs web/tests/expense-application-submit-rich-confirm.test.mjs web/tests/travel-reimbursement-guided-flow.test.mjs

说明:

- 后端/规则/容器配置/Audit 页面等工作区已有改动未纳入本提交。
- 容器内后端定向 pytest 曾运行 timeout 180s /tmp/x-financial-server-venv/bin/pytest -q <相关后端测试>,180 秒超时且超时前已有失败标记,未作为通过项记录。
- TravelReimbursementCreateView 当前仍超过 800 行,后续仍需继续拆分;本提交先把新增职责模块控制在 800 行内,阻止主类/主模块继续膨胀。
2026-06-13 14:53:23 +00:00
336fee9d93 chore: 忽略 .superpowers 工具缓存目录 2026-06-12 09:43:53 +08:00
caoxiaozhu
25724c354f feat: 同步报销流程与工作台改动 2026-06-09 08:32:00 +00:00
caoxiaozhu
e124e4bbcb feat: 报销审批流重构与管家计划全链路贯通
- 重构报销状态注册表、审批流路由与平台风险标记
- 完善管家意图规划器与模型计划构建器全链路
- 新增 OCR Worker 脚本、数据库会话管理与通知状态
- 优化文档中心、日志视图、预算中心与员工管理交互
- 增强工作台摘要、图标资源与全局主题样式
- 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
2026-06-06 17:19:07 +08:00
caoxiaozhu
f60cebadb8 feat: 小财管家意图规划与报销提交编排增强
- 完善管家意图识别、模型计划构建与规划器调度
- 重构差旅报销提交编排器与管家计划流程前端交互
- 优化报销消息项样式与文档中心视图
- 新增小财管家与附件上传风险前置复核设计文档
- 补充管家规划器与文档中心测试覆盖
2026-06-04 14:25:14 +08:00
caoxiaozhu
1cbf3fee44 feat: 报销预审会话状态管理与工作台交互增强
- 新增差旅报销会话状态管理与对话模型重构
- 增强风险观测服务与运行时聊天上下文作用域
- 优化工作台图标资源、助理意图识别与摘要工具
- 完善报销创建视图样式与差旅详情页标准调整交互
- 补充风险观测、运行时聊天与报销端点测试覆盖
2026-06-04 11:03:29 +08:00
caoxiaozhu
87da5df91b feat: 风险可见性控制与差旅详情页交互优化
- 新增风险可见性工具函数与风险日趋势图表组件
- 优化差旅请求详情页费用模型与视图交互
- 完善顶部导航栏样式与应用壳路由逻辑
- 补充风险可见性、风险看板与差旅详情测试覆盖
2026-06-03 22:15:45 +08:00
416 changed files with 54966 additions and 7596 deletions

51
.env
View File

@@ -1,51 +0,0 @@
APP_NAME=X-Financial
APP_ENV=local
APP_DEBUG=true
API_V1_PREFIX=/api/v1
SETUP_COMPLETED=true
VITE_SETUP_COMPLETED=true
COMPANY_NAME=YGSOFT
COMPANY_CODE=123
ADMIN_EMAIL='admin@admin.com'
VITE_COMPANY_NAME=YGSOFT
VITE_COMPANY_CODE=123
VITE_ADMIN_EMAIL='admin@admin.com'
# Admin login credentials are stored separately under server/.secrets/
WEB_HOST=10.10.10.122
WEB_PORT=5173
VITE_WEB_HOST=10.10.10.122
VITE_WEB_PORT=5173
SERVER_HOST=0.0.0.0
SERVER_PORT=8000
VITE_SERVER_HOST=0.0.0.0
VITE_SERVER_PORT=8000
SERVER_STARTUP_TIMEOUT=300
SERVER_BLOCKING_STARTUP_TIMEOUT=12
VITE_API_BASE_URL=/api/v1
VITE_AUTH_IDLE_TIMEOUT_MINUTES=30
ONLYOFFICE_ENABLED=true
ONLYOFFICE_PUBLIC_URL=http://www.caoxiaozhu.com:8082
ONLYOFFICE_BACKEND_URL=http://main:8000
ONLYOFFICE_JWT_SECRET=change-me-onlyoffice
HERMES_AGENT_SHARED_TOKEN=change-me-hermes
POSTGRES_HOST=10.10.10.189
POSTGRES_PORT=5432
POSTGRES_DB=postgres
POSTGRES_USER=root
POSTGRES_PASSWORD=8811614287327Leo
VITE_POSTGRES_HOST=10.10.10.189
VITE_POSTGRES_PORT=5432
VITE_POSTGRES_DB=postgres
VITE_POSTGRES_USER=root
DATABASE_URL='postgresql+psycopg://root:8811614287327Leo@10.10.10.189:5432/postgres'
SQLALCHEMY_ECHO=false
REDIS_URL=
VITE_REDIS_URL=
CORS_ORIGINS='["http://10.10.10.122:5173"]'

View File

@@ -14,9 +14,9 @@ VITE_ADMIN_EMAIL=
# Admin login credentials are stored separately under server/.secrets/
WEB_HOST=0.0.0.0
WEB_PORT=5173
WEB_PORT=5273
VITE_WEB_HOST=0.0.0.0
VITE_WEB_PORT=5173
VITE_WEB_PORT=5273
SERVER_HOST=0.0.0.0
SERVER_PORT=8000
@@ -48,4 +48,8 @@ SQLALCHEMY_ECHO=false
REDIS_URL=
VITE_REDIS_URL=
CORS_ORIGINS='["http://127.0.0.1:5173","http://localhost:5173","http://0.0.0.0:5173"]'
OCR_DEVICE=
OCR_TIMEOUT_SECONDS=180
OCR_MAX_CONCURRENT_WORKERS=1
CORS_ORIGINS='["http://127.0.0.1:5273","http://localhost:5273","http://0.0.0.0:5273"]'

14
.gitignore vendored
View File

@@ -8,6 +8,8 @@ web/.vite/
.omx/
.claude/
.codex/
.codex-temp/
.superpowers/
*.log
.DS_Store
Thumbs.db
@@ -16,3 +18,15 @@ __pycache__/
server/.venv/
server/.venv-ocr312
server/.secrets/
server/logs/
server/storage/expense_claims/
server/storage/finance_reports/
server/storage/receipt_folder/
test-results/
.codex-remote-attachments/
tmp-*.png
.nezha/
.omo/
.env
.env.local
.env.*.local

8
docker-compose.gpu.yml Normal file
View File

@@ -0,0 +1,8 @@
services:
main:
gpus: all
shm_size: "8gb"
environment:
NVIDIA_VISIBLE_DEVICES: all
NVIDIA_DRIVER_CAPABILITIES: compute,utility
OCR_DEVICE: "${OCR_DEVICE:-gpu:0}"

View File

@@ -0,0 +1,29 @@
services:
main:
depends_on:
postgres:
condition: service_healthy
postgres:
image: pgvector/pgvector:pg17
container_name: x-financial-postgres
restart: unless-stopped
environment:
POSTGRES_DB: "${POSTGRES_DB:-x_financial}"
POSTGRES_USER: "${POSTGRES_USER:-x_financial}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-x_financial}"
ports:
- "${POSTGRES_HOST_PORT:-55432}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
interval: 15s
timeout: 5s
retries: 10
start_period: 30s
networks:
- financial-internal
volumes:
postgres-data:

View File

@@ -20,7 +20,7 @@ services:
QDRANT_URL: "http://qdrant:6333"
LIGHTRAG_WORKSPACE: "x_financial_knowledge"
ports:
- "${WEB_PORT:-5173}:${WEB_PORT:-5173}"
- "${WEB_PORT:-5273}:${WEB_PORT:-5273}"
- "${SERVER_PORT:-8000}:${SERVER_PORT:-8000}"
- "2223:22"
volumes:
@@ -32,7 +32,8 @@ services:
- >
apt-get update &&
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends
python3 python3-pip python3-venv fontconfig fonts-noto-cjk fonts-noto-cjk-extra &&
python3 python3-pip python3-venv fontconfig openssh-server poppler-data &&
if ! fc-match 'Noto Sans CJK SC' | grep -qi 'Noto'; then if ! timeout "${CJK_FONT_INSTALL_TIMEOUT_SECONDS:-45}" sh -lc 'DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends fonts-noto-cjk fonts-noto-cjk-extra'; then printf '%s\n' '[WARN] CJK font installation timed out or failed; continuing startup without blocking the app.'; fi; fi &&
printf '%s\n'
'<?xml version="1.0"?>'
'<!DOCTYPE fontconfig SYSTEM "fonts.dtd">'
@@ -58,7 +59,7 @@ services:
cd /app &&
./start.sh all
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${WEB_PORT:-5173}/ >/dev/null || exit 1"]
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${WEB_PORT:-5273}/ >/dev/null || exit 1"]
interval: 15s
timeout: 5s
retries: 10

View File

@@ -14,7 +14,7 @@ docker compose up -d
Open:
```text
http://<your-linux-host>:5173
http://<your-linux-host>:5273
```
## Container Layout

572
docs/improvement-roadmap.md Normal file
View File

@@ -0,0 +1,572 @@
# X-Financial 改进路线图
> 本文档基于 2026-06-18 对代码库的算法层、业务层、工程层综合评估生成。
> 每项改进都附有文件路径佐证,便于后续定位和追踪。
> 维护规则:状态变更请在对应章节同步更新;新增改进项追加到对应优先级末尾。
## 状态约定
| 标记 | 含义 |
|---|---|
| ⏳ | 待启动 |
| 🔄 | 进行中 |
| ✅ | 已完成 |
| ⏸️ | 暂缓(需说明原因) |
| ❌ | 取消(需说明原因) |
## 优先级矩阵
| 优先级 | 编号 | 标题 | 状态 |
|---|---|---|---|
| 🔴 P0 安全 | B2 | HTTP Header 权限漏洞 | ⏳ |
| 🔴 P0 业务核心 | B1 | 审批流转交/加签/撤回/会签 | ⏳ |
| 🔴 P0 共识 | B10 | 800 行硬约束破防 | ⏳ |
| 🟠 P1 算法 | A1 | 风险评分权重自适应 | ⏳ |
| 🟠 P1 算法 | A4 | LLM 票据分类 + 字段置信度 | ⏳ |
| 🟠 P1 算法 | A7 | LLM 幻觉检测 | ⏳ |
| 🟠 P1 业务 | B6 | 规则覆盖不均衡 | ⏳ |
| 🟡 P2 业务 | B3 | 申请/报销拆表 | ⏳ |
| 🟡 P2 业务 | B4 | 可配置审批矩阵 | ⏳ |
| 🟡 P2 算法 | A2 | 异常检测自适应阈值 | ⏳ |
| 🟢 P3 算法 | A3 | 多模型异常检测集成 | ⏳ |
| 🟢 P3 算法 | A5 | 票据分类持续学习 | ⏳ |
| 🟢 P3 算法 | A6 | Prompt 模板集中管理 | ⏳ |
| 🟢 P3 算法 | A8 | 规则冗余建模 | ⏳ |
| 🟢 P3 算法 | A9 | 行为画像 fairness 保护 | ⏳ |
| 🟢 P3 业务 | B5 | 预算跨期/跨科边界 | ⏳ |
| 🟢 P3 业务 | B7 | 审计日志防篡改 | ⏳ |
| 🟢 P3 业务 | B8 | 审批 SLA 监控 | ⏳ |
| 🟢 P3 业务 | B9 | 支付与凭证对接 | ⏳ |
| 🟢 P3 算法 | A10 | 算法模块 800 行拆分 | ⏳ |
---
## 一、算法层面改进
### A1. 风险评分权重自适应调优 ⏳
**优先级**:🟠 P1
**证据**
- `server/src/app/algorithem/risk_graph/engine.py:457-465`
- `server/src/app/services/risk_rule_scoring.py:16-23`
**当前实现**
```python
risk_score = 0.35*S_rule + 0.25*S_anomaly + 0.20*S_graph + 0.15*S_policy + 0.05*S_history
```
五维权重和六因子权重均为硬编码常量,无法反映规则有效性差异。
**问题**
- 已有 `RiskObservationFeedback` 表收集人工反馈,但反馈数据**未反向更新权重**
- 不同费用类型(差旅/招待/通信)的合理权重差异大,目前一刀切
**改进方向**
- 按费用类型分组的权重向量
- 定期基于反馈数据做 logistic regression / Bayesian 更新
- 权重变更需版本化、可回滚
**验收标准**
- [ ] 权重从配置/数据库读取,不再硬编码
- [ ] 反馈数据能触发权重自动调整
- [ ] 不同费用类型可配置独立权重
- [ ] 调整过程有日志和效果对比
---
### A2. 金额异常检测自适应阈值 ⏳
**优先级**:🟡 P2
**证据**`server/src/app/algorithem/risk_graph/engine.py:221-261`
**当前实现**:固定分档阈值 `1.0x→0, 1.25x→30, 1.5x→55, 2.0x→75, 3.0x→95`
**问题**
- 通信费(小额高频)和差旅(大额低频)的"1.5x"含义完全不同
- peer p75 在新部门/新费用类型时样本稀疏
- 已识别 `peer_baseline_insufficient` 不确定性,但无冷启动方案
**改进方向**
- 改为自适应分位数(基于历史数据动态计算)
-`(费用类型 × 部门层级)` 分组维护基线
- 冷启动:全局基线 + 小样本置信度折扣
**验收标准**
- [ ] 阈值按业务维度分组,不再全局统一
- [ ] 新部门/新费用类型有冷启动策略
- [ ] 基线样本不足时有降级机制
- [ ] 增加单元测试覆盖冷启动场景
---
### A3. 多模型异常检测集成策略 ⏳
**优先级**:🟢 P3
**证据**`server/src/app/algorithem/risk_graph/anomaly_models.py`
**当前实现**5 个模型独立输出(`robust_statistics / isolation_proxy / local_outlier / temporal_jump / periodic_deviation`),无集成。
**问题**
- 多模型同时报警时聚合规则未定义
- 单模型 vs 多模型共识的严重度差异未体现
- 模型间冲突无裁决机制
**改进方向**
- 引入 `AnomalyEnsembler` 集成层
- 输出 `consensus_score` + `model_disagreement_flag`
- 高风险图谱评分区分"单点异常"和"多维共识异常"
**验收标准**
- [ ] 实现集成层并接入 engine.py
- [ ] 集成结果包含共识度指标
- [ ] 单元测试覆盖各种模型组合情况
---
### A4. LLM 票据分类与字段置信度 ⏳
**优先级**:🟠 P1
**证据**
- `server/src/app/services/document_intelligence.py:143-153``_classify_with_model` 当前 `return None`
- `server/src/app/services/document_intelligence.py:20-37`(字段抽取全正则)
**问题**
- 规则层单点支撑,非标准票据格式失效
- 无字段级置信度评分,无法判断哪些抽取值需要人工复核
- LLM 分类合并策略代码已存在但未启用
**改进方向**
1. 启用 LLM 分类层(合并逻辑可直接复用)
2. 字段抽取增加置信度:`{field: {value, confidence, source}}`
3. 低置信度字段(< 0.7)自动标记"需人工核对"
**验收标准**
- [ ] LLM 分类层启用并通过对比测试
- [ ] 每个抽取字段附带置信度评分
- [ ] 低置信度字段触发人工复核标记
- [ ] 提供准确率回归测试集
---
### A5. 票据分类持续学习 ⏳
**优先级**:🟢 P3
**证据**`server/src/app/services/document_intelligence_rules.py:120``score_bias` 硬编码)
**问题**新票种ETC 电子票、滴滴行程单新模板)需开发改代码才能识别。
**改进方向**
- 分类规则做成可配置 + 可学习
- 管理员上传样本自动更新关键词权重
- 基于历史已分类票据做 TF-IDF 训练
**验收标准**
- [ ] 后台提供分类规则管理界面
- [ ] 新票种可通过样本上传识别
- [ ] 历史数据可训练关键词权重
---
### A6. Prompt 模板集中管理 ⏳
**优先级**:🟢 P3
**证据**
- `server/src/app/services/ontology_extraction.py`
- `server/src/app/services/ontology_detection.py`
- `server/src/app/services/risk_rule_generation.py`
- `server/src/app/services/user_agent_response.py`
- `server/src/app/services/user_agent_application.py`
- `server/src/app/services/user_agent_review_core.py`
- `server/src/app/services/knowledge_rag.py:214`(查询重写硬编码在方法内)
**问题**
- Prompt 散落在 12+ 文件,无版本化、无回滚、无 A/B 测试
- 相同意图的 prompt 在不同 service 中重复
**改进方向**
- 建立 `prompts/` 集中目录 + `PromptRegistry`
-`(意图, 版本)` 管理
- 支持灰度发布和效果对比
**验收标准**
- [ ] 所有 prompt 迁移到集中目录
- [ ] 支持版本化与回滚
- [ ] 提供 A/B 测试接口
---
### A7. LLM 幻觉检测与事实校验 ⏳
**优先级**:🟠 P1
**证据**:当前系统缺少 LLM 输出的显式幻觉检测。本体解析有 `confidence` 门禁,但生成的解释文本、规则建议、对话回复无校验。
**问题**
- LLM 可能编造不存在的政策条款、错误金额阈值、虚构审批人
- 风险图谱解释文本幻觉会误导审批人
- 唯一兜底是 `data_quality_gate`,仅管输入数据质量
**改进方向**
- 关键输出(金额、政策条款、规则编号)做 grounded checkLLM 输出后用规则引擎反向校验
- 对话回复中的具体数字、日期强制引用证据片段RAG 引用)
**验收标准**
- [ ] 关键数值字段有反向校验机制
- [ ] 对话回复中的事实声明可追溯到证据
- [ ] 校验失败时有明确降级策略
---
### A8. 规则冗余/相关性建模 ⏳
**优先级**:🟢 P3
**证据**`server/src/app/services/risk_rule_scoring.py`(多规则命中简单求和/max
**问题**:相关规则同时命中时分数被夸大。如 `preapproval_absent``date_outside_trip` 可能高度相关。
**改进方向**:引入规则相关矩阵,对相关规则命中分数做去冗余折扣。
**验收标准**
- [ ] 建立规则相关矩阵
- [ ] 命中聚合时考虑冗余
- [ ] 测试验证去冗余效果
---
### A9. 行为画像 fairness 保护 ⏳
**优先级**:🟢 P3
**证据**`server/src/app/algorithem/employee_behavior_profile.py:345``evaluate_weighted_profile` / `calculate_review_priority_score`
**问题**:行为画像影响审核优先级,若基于受保护属性(性别/年龄)产生系统性偏差,会构成隐性歧视。
**改进方向**
- 增加 fairness audit 接口(按人群分组统计风险分布)
- 评分特征显式排除受保护属性
- 定期输出偏差报告
**验收标准**
- [ ] 评分特征清单明确排除受保护属性
- [ ] 提供 fairness audit API
- [ ] 定期偏差报告生成
---
### A10. 算法模块 800 行拆分 ⏳
**优先级**:🟢 P3与 B10 同源,单独追踪算法模块进度)
**证据**
- `server/src/app/algorithem/employee_behavior_profile_tag_rules.py`: **812 行** 🔴
- `server/src/app/algorithem/risk_graph/engine.py`: **794 行** 🟡 临界
**改进方向**
- `employee_behavior_profile_tag_rules.py` 按标签类别拆分(差旅类 / 招待类 / 办公类)
- `engine.py` 的 5 个评分维度rule/anomaly/graph/policy/history拆为 5 个独立打分器
**验收标准**
- [ ] 所有算法文件 ≤ 800 行
- [ ] 拆分前后行为等价(单元测试通过)
- [ ] 拆分后职责边界清晰
---
## 二、业务层面改进
### B1. 审批流转交/加签/撤回/会签 ⏳
**优先级**:🔴 P0
**证据**
- `server/src/app/services/expense_claim_workflow_constants.py`(仅 11 行固定阶段)
- `server/src/app/services/expense_claim_approval_flow.py:28``approve_claim` 串行硬编码)
- 转交/加签/撤回代码中**不存在**
**问题**
- 费控系统核心能力缺失
- 现实中领导出差无法审批是常态
- 无并行审批(会签),多人审批只能串行
- 审批节点调整需改代码
**改进方向**
- 引入审批矩阵:`费用类型 × 金额区间 × 部门` → 审批节点列表
- 支持节点动作:`{approve, reject, return, transfer, countersign, withdraw, add_approver}`
- 短期优先实现"转交"和"撤回"两个最常用动作
**验收标准**
- [ ] 支持转交(审批人转给他人)
- [ ] 支持撤回(提交人在审批中撤回)
- [ ] 支持加签(临时增加审批节点)
- [ ] 支持会签(多节点并行)
- [ ] 审批矩阵可后台配置
- [ ] 关键操作有审计日志
---
### B2. HTTP Header 权限漏洞修复 ⏳
**优先级**:🔴 P0安全
**证据**`server/src/app/api/deps.py:33-213`,通过 `X-Auth-Username / X-Auth-Role-Codes / X-Auth-Is-Admin` 等请求头识别身份。
**问题**
- **任何人只要在请求头加 `X-Auth-Is-Admin: true` 就能获得管理员权限**
- 没有 token、没有签名、没有任何校验
- 足以让所有费控规则形同虚设
**改进方向**
- 引入真正的身份认证JWT 或 session cookie
- 角色信息从服务端 session/token 获取,**绝不信任客户端传来的角色声明**
- 短期方案前置网关nginx剥离这些头并注入真实身份
**验收标准**
- [ ] 客户端无法通过伪造 Header 越权
- [ ] 所有角色信息来自服务端校验
- [ ] 现有 API 行为兼容(不破坏调用方)
- [ ] 安全测试覆盖权限边界
---
### B3. 申请单与报销单拆表 ⏳
**优先级**:🟡 P2
**证据**
- `server/src/app/models/financial_record.py``ExpenseClaim` 通过 `expense_type` 后缀 + `claim_no` 前缀区分
- `server/src/app/models/reimbursement.py``ReimbursementRequest` 几乎废弃service 仅 54 行 CRUD
**问题**
- 查询复杂度高,每个查询都要带 `expense_type IN (...)` 过滤
- 字段冗余(申请单无发票字段但表里有)
- 业务语义混乱("claim"分不清是申请还是报销)
- 索引难优化
**改进方向**(需决策):
- 方案 A保守保留单表增加 `claim_kind` 字段(`application` / `reimbursement`)显式区分
- 方案 B彻底拆分为 `ExpenseApplication` + `ExpenseReimbursement` 两张表,通过 `application_id` 外键关联
- **涉及数据迁移,需用户确认方案**
**验收标准**
- [ ] 方案决策完成
- [ ] 数据迁移脚本可重入、可回滚
- [ ] 迁移前后数据等价校验
- [ ] 现有 API 行为兼容或平滑升级
---
### B4. 可配置审批矩阵 ⏳
**优先级**:🟡 P2
**证据**`server/src/app/services/expense_claim_approval_routing.py``_APPLICATION_BUDGET_REVIEW_USAGE_THRESHOLD = 90%` 等阈值硬编码)
**问题**:什么金额走什么审批、什么情况要预算管理者介入,全部硬编码。不同公司/部门差异大,无法运维配置。
**改进方向**:建立审批矩阵配置表:
```
approval_matrix(expense_type, amount_range, department_level, risk_level)
→ [approver_roles, parallel_or_serial, sla_hours]
```
管理员后台维护,系统按矩阵动态生成审批流。
**验收标准**
- [ ] 审批矩阵可后台配置
- [ ] 系统按矩阵动态生成审批流
- [ ] 配置变更有版本和审计
- [ ] 矩阵未命中时有合理默认值
---
### B5. 预算管控跨期/跨科边界 ⏳
**优先级**:🟢 P3
**证据**
- `server/src/app/services/budget.py`780 行)
- `server/src/app/services/expense_claim_budget_flow.py`112 行)
- 预算占用/释放/核销/转移已实现,但边界场景验证不足
**潜在漏洞**
- 跨财年结转:去年冻结的预算今年初未释放
- 跨期占用Q1 提交的申请 Q2 才审批,占用的是哪个季度?
- 跨科目调剂:差旅预算不够能否临时挪用办公预算?
- 无对应单元测试
**改进方向**
- 增加预算状态周期性对账任务(每日扫描 orphan reservation
- 跨期策略明确化(默认跟随申请提交期,可配置)
- 补充跨期/跨科目单元测试
**验收标准**
- [ ] 跨期/跨科目边界单元测试覆盖
- [ ] 周期性对账任务上线
- [ ] orphan reservation 自动清理
---
### B6. 规则覆盖不均衡补齐 ⏳
**优先级**:🟠 P1
**证据**`server/rules/risk-rules/` 38 条规则分布:
- 差旅travel13 条
- 预算budget13 条
- 申请application5 条
- 报销reimbursement7 条
- 标准standard5 条
**问题**
- **招待费、市场推广、培训费、福利费、软件服务费几乎没有专门规则**
- 缺少供应商关联方交易、连号发票重复报销、跨年度重复报销检测
- 这些恰是真实费控场景最易出问题的领域
**改进方向**
1. 招待费规则(参与人数缺失、人均超标、同城招待、节假日招待)
2. 供应商风险规则(同一供应商高频、关联方、工商信息异常)
3. 重复报销检测(发票号哈希去重、跨期扫描)
**验收标准**
- [ ] 招待费规则集≥5 条)
- [ ] 供应商风险规则集≥3 条)
- [ ] 重复报销检测规则≥2 条)
- [ ] 每条新规则有对应单元测试
---
### B7. 审计日志防篡改 ⏳
**优先级**:🟢 P3
**证据**
- `server/src/app/models/audit_log.py`
- `server/src/app/services/audit.py`72 行)
- before/after JSON 快照完整,但**无 hash chain 或数字签名**
**问题**:数据库管理员(或有 DB 写权限的人)可静默篡改审计日志。
**改进方向**
- 每条日志附加 `prev_hash + current_hash = sha256(prev_hash + payload)`
- 定期锚定到外部存证(区块链 / 公证处 / WORM 存储)
**验收标准**
- [ ] 审计日志实现 hash chain
- [ ] 篡改可被检测
- [ ] 外部存证机制(至少文档化)
---
### B8. 审批 SLA 与时效监控 ⏳
**优先级**:🟢 P3
**证据**:审批节点无超时提醒代码。
**问题**:单据卡在某领导处一周无人管,系统无感知。
**改进方向**
- 每个审批节点配置 SLA如 24h/48h
- 后台定时任务扫描超时单据
- 自动催办 / 升级到上级 / 转交
**验收标准**
- [ ] SLA 可配置
- [ ] 超时自动催办
- [ ] 超时升级机制
- [ ] SLA 报表可查
---
### B9. 支付与凭证对接 ⏳
**优先级**:🟢 P3业务延伸方向
**证据**:状态机到 `paid` 就结束,无银企直连、无会计凭证生成。
**问题**:报销审批通过后仍需财务人工付款、手工录凭证,未形成完整闭环。
**改进方向**
- 银企直连(用友 / 金蝶 / 远光 API
- 自动生成会计凭证(借:管理费用-差旅,贷:银行存款/应付职工薪酬)
**验收标准**
- [ ] 至少接入一个财务系统
- [ ] 凭证自动生成
- [ ] 支付状态回传
---
### B10. 800 行硬约束拆分(业务模块) ⏳
**优先级**:🔴 P0
**证据**services/ 下 ≥ 800 行的文件,共 **20 个**
| 文件 | 行数 | 超标幅度 |
|---|---|---|
| `services/user_agent_application.py` | 1451 | +81% |
| `services/risk_rule_template_executor.py` | 1164 | +45% |
| `services/expense_claim_draft_flow.py` | 1064 | +33% |
| `services/expense_claims.py` | 1042 | +30% |
| `services/receipt_folder.py` | 1034 | +29% |
| `services/steward_planner.py` | 935 | +17% |
| `api/v1/endpoints/agent_assets.py` | 925 | +16% |
| `services/orchestrator_execution.py` | 900 | +12.5% |
| `services/finance_dashboard.py` | 884 | +10.5% |
| `services/knowledge_rag.py` | 877 | +9.6% |
| `services/settings.py` | 873 | +9.1% |
| `services/agent_assets.py` | 856 | +7% |
| `services/employee.py` | 850 | +6.25% |
| `services/employee_behavior_profile_service.py` | 823 | +2.9% |
| `services/risk_rule_generation.py` | 821 | +2.6% |
| `services/agent_foundation_asset_topup.py` | 809 | +1.1% |
| `services/ontology_extraction.py` | 808 | +1% |
| `services/demo_company_simulation_seed.py` | 805 | +0.6% |
| `services/knowledge.py` | 800 | 临界 |
| 另约 20 个文件在 700-800 行区间 | | 🟡 |
**前端超大文件**
| 文件 | 行数 |
|---|---|
| `web/src/views/scripts/TravelReimbursementCreateView.js` | 4066 🔴🔴 |
| `web/src/views/scripts/TravelRequestDetailView.js` | 2861 🔴🔴 |
| `web/src/views/scripts/useTravelReimbursementSubmitComposer.js` | 2173 🔴🔴 |
| `web/src/composables/useRequests.js` | 1799 🔴 |
| `web/src/views/scripts/travelReimbursementReviewModel.js` | 1662 🔴 |
| 多个 `.vue` 文件 | 800-1130 🔴 |
**改进方向**
- 按 AGENTS.md 既定的拆分原则(编排 / 状态 / 持久化 / 权限 / 文件存储 / OCR / 规则审核 / 响应构建 / 序列化)逐个拆
- 优先 Top 5`user_agent_application` / `risk_rule_template_executor` / `expense_claim_draft_flow` / `expense_claims` / `receipt_folder`
- 每次拆分配套定向测试
**验收标准**
- [ ] 所有类/文件 ≤ 800 行
- [ ] 拆分前后行为等价(测试通过)
- [ ] 拆分后职责边界清晰
- [ ] CI 中加入行数检查(防止回潮)
---
## 三、推进原则
1. **P0 优先**B2安全、B1核心能力、B10共识必须先行。
2. **算法优化在 P0 落地后做**:再准的算法也会被权限漏洞和流程缺失抵消。
3. **小步快跑**:每项改进拆成可独立验证的子任务,配套测试。
4. **不破坏既有协议**:对外 API 尽量稳定,内部实现先拆。
5. **800 行约束**所有改动前后检查受影响类行数CI 加入行数门禁。
---
## 四、变更日志
| 日期 | 变更 | 操作人 |
|---|---|---|
| 2026-06-18 | 路线图初始版本,基于代码库全量评估生成 | Sisyphus |

View File

@@ -0,0 +1,177 @@
# Steward Application Reimbursement State Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a persistent ontology-bound steward state for travel application and travel reimbursement flows.
**Architecture:** Keep the existing steward planning UI and assistant delegation flow. Add a backend state layer that stores `steward_state` in `AgentConversation.state_json`, merges LLM/rule output as patches, and rejects fields outside the ontology registry before downstream services consume them.
**Tech Stack:** FastAPI, SQLAlchemy JSON state, Pydantic schemas, pytest in Docker `x-financial-main:/app`, Vue/Vite frontend.
---
### Task 1: Backend State Contract
**Files:**
- Modify: `server/src/app/schemas/steward.py`
- Create: `server/src/app/services/steward_flow_state.py`
- Test: `server/tests/test_steward_flow_state.py`
- [ ] **Step 1: Write failing tests**
```python
def test_state_merge_keeps_application_and_reimbursement_flows():
service = StewardFlowStateService()
state = service.merge_state(
{},
StewardFlowStatePatch(
active_flow="travel_application",
flow_id="travel_application",
intent="travel_application_create",
fields={"expense_type": "travel", "location": "上海"},
),
)
state = service.merge_state(
state,
StewardFlowStatePatch(
active_flow="travel_reimbursement",
flow_id="travel_reimbursement",
intent="travel_reimbursement_draft",
fields={"amount": "708", "invoice_no": "NO-1"},
),
)
assert state["flows"]["travel_application"]["fields"]["location"] == "上海"
assert state["flows"]["travel_reimbursement"]["fields"]["amount"] == "708"
```
```python
def test_state_merge_filters_non_ontology_fields():
service = StewardFlowStateService()
state = service.merge_state(
{},
StewardFlowStatePatch(
active_flow="travel_application",
flow_id="travel_application",
intent="travel_application_create",
fields={"location": "上海", "invented_field": "x"},
),
)
assert state["flows"]["travel_application"]["fields"] == {"location": "上海"}
```
- [ ] **Step 2: Run red tests**
Run:
```bash
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q server/tests/test_steward_flow_state.py
```
Expected: fail because `steward_flow_state.py` does not exist.
- [ ] **Step 3: Implement minimal state service**
Create `StewardFlowStatePatch`, `StewardFlowStateService.merge_state`, ontology field filtering, and event append logic.
- [ ] **Step 4: Run green tests**
Run the same pytest command and expect pass.
### Task 2: Steward Plan Persistence
**Files:**
- Modify: `server/src/app/schemas/steward.py`
- Modify: `server/src/app/api/v1/endpoints/steward.py`
- Modify: `server/src/app/services/agent_conversations.py`
- Test: `server/tests/test_steward_planner.py`
- [ ] **Step 1: Write failing API/service test**
Add a test proving `/steward/plans` response contains `conversation_id` and `steward_state` when `context_json.session_type = steward`, and the state contains two flows when the input contains one application and one reimbursement task.
- [ ] **Step 2: Run red test**
Run:
```bash
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q server/tests/test_steward_planner.py
```
- [ ] **Step 3: Implement conversation state persistence**
Add `conversation_id` and `steward_state` fields to response schemas, persist state through `AgentConversationService`, and merge planner tasks into `steward_state`.
- [ ] **Step 4: Run green test**
Run the same pytest command and expect pass.
### Task 3: Runtime Decision Reads Persistent State
**Files:**
- Modify: `server/src/app/services/steward_runtime_decision_agent.py`
- Test: `server/tests/test_steward_runtime_decision_agent.py`
- [ ] **Step 1: Write failing test**
Add a test proving runtime decision uses `context_json.conversation_state.steward_state` when `runtime_state` is empty.
- [ ] **Step 2: Implement minimal fallback hydration**
Normalize runtime state by merging request runtime state with persisted steward state.
- [ ] **Step 3: Run green test**
Run:
```bash
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q server/tests/test_steward_runtime_decision_agent.py
```
### Task 4: Frontend State Carry
**Files:**
- Modify: `web/src/views/scripts/stewardPlanModel.js`
- Modify: `web/src/views/scripts/TravelReimbursementCreateView.js`
- Modify: `web/src/views/scripts/useTravelReimbursementSessionState.js`
- [ ] **Step 1: Preserve steward state from backend responses**
Normalize `conversation_id` and `steward_state` from plan/runtime responses into the local session model.
- [ ] **Step 2: Send steward state in later requests**
Include the current `steward_state` under `context_json` for plan and runtime decision calls.
- [ ] **Step 3: Build frontend**
Run:
```bash
docker exec -w /app/web x-financial-main npm run build
```
Expected: build succeeds.
### Task 5: Final Verification
- [ ] **Step 1: Run backend steward tests**
```bash
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q server/tests/test_steward_flow_state.py server/tests/test_steward_planner.py server/tests/test_steward_runtime_decision_agent.py server/tests/test_steward_slot_decision_agent.py
```
- [ ] **Step 2: Run frontend build**
```bash
docker exec -w /app/web x-financial-main npm run build
```
- [ ] **Step 3: Report workspace status**
Run:
```bash
git status --short
```

View File

@@ -1,5 +1,11 @@
# 财务规则表补齐开发记录
## 2026-06-05 口径调整
用户明确要求业务招待费超过 500 元、大额办公用品以及金额超过 2000 元的费用申请审批要求进入财务规则中心。因此新增《公司费用申请审批规则》作为申请前置和审批阈值的财务规则依据;风险规则负责引用该财务规则并执行命中判断。
本次调整不恢复旧的单项《业务招待费报销规则》或《办公用品费报销规则》,而是使用统一规则表维护申请审批阈值,避免规则中心再次出现多个口径型规则表。
## 目标
财务规则中心只维护真正具备制度标准、且需要按职级/职务或明确人均标准执行的规则表。没有实际金额分档的费用类型,不在财务规则中心单独生成 Excel 表;其额度控制进入预算中心,申请前置和材料完整性进入风险规则。

View File

@@ -1,5 +1,9 @@
# 风险规则补齐开发记录
## 2026-06-05 口径调整
业务招待费超过 500 元、办公用品超过 2000 元、通用费用超过 2000 元的申请前置要求,制度依据统一改为财务规则《公司费用申请审批规则》。风险规则继续承担执行判断,但 `finance_rule_code` 统一指向 `expense.preapproval.policy`
## 目标
补齐预算、申请前置、报销偏差、费用标准、材料完整性类风险规则,让后续 demo 数据可以形成“预算-申请-报销-风控”的闭环。

View File

@@ -0,0 +1,424 @@
# 小财管家
## 功能一句话
小财管家是首页统一财务任务入口,负责把用户的自然语言和附件拆解为多个可确认、可追踪、可分派的申请与报销任务,再调用现有申请助手和报销助手完成执行闭环。
## 背景与问题
当前个人工作台已经提供首页输入框,并能通过本体解析把一句话路由到申请、报销、预算或知识等单一会话。这个能力适合单意图,但用户真实表达经常是多任务组合,例如同时包含出差申请、昨日交通费报销、历史出差费用报销以及多张附件。
现有问题:
- 首页输入框当前会收敛为一个 `sessionType`,无法保留多任务计划。
- 申请助手和报销助手已经具备单任务核对能力,但缺少上层任务拆解、归集和跨助手分派。
- 附件上传后主要进入当前会话,缺少面向多任务的自动归集建议。
- 财务动作需要确认后才能入库、绑定或提交,不能让大模型直接执行高风险动作。
- 新增字段必须尊重本体字段,不能因为小财管家新增一套业务字段。
## 目标与非目标
### 目标
- 首页输入框定位为“小财管家”,作为用户默认财务任务入口。
- 用户提交自然语言和附件后,先展示小财管家的任务识别与附件归集过程。
- 支持把一句话拆成多个任务,第一版只验证费用申请和费用报销。
- 支持多附件按费用场景、时间、地点、任务线索形成归集建议。
- 遇到创建申请单、创建报销草稿、附件绑定、提交审批等动作时必须等待用户确认。
- 保留现有申请助手和报销助手能力,小财管家只做上层编排和分派。
- 外层意图识别必须优先使用大模型 function calling 输出结构化任务计划,规则逻辑只作为模型不可用或结构不合法时的兜底。
- 前端展示“意图识别智能体”过程气泡,并用流式状态逐步呈现,不暴露模型内部推理链。
- 所有业务字段先进入本体字段归一化,再进入下游助手、草稿、风险规则和持久化。
### 非目标
- 第一版不做万能智能体,不覆盖预算、审批、知识问答等全部场景。
- 第一版不引入 LangChain 或 LangGraph先复用项目内运行时模型配置和 OpenAI-compatible function calling 契约。
- 第一版不自动提交审批,不绕过用户确认。
- 第一版不新增业务语义字段;只新增任务编排态字段。
- 第一版不重写申请助手、报销助手和现有 Orchestrator。
## 用户与场景
### 目标用户
- 普通员工:在首页一次性描述申请、报销和附件处理诉求。
- 财务人员:查看任务拆解、附件归集和用户确认链路是否可追溯。
- 审批/管理角色:后续可扩展为审批待办和预算提醒编排,但不进入第一版。
### 核心场景
用户在首页输入:
```text
我想要申请7月2日去北京出差辅助北京供电局的税务审核任务并且我要报销昨天的交通费还需要报销6月3日出差去上海的费用
```
系统处理:
1. 小财管家识别到三条候选任务。
2. 将“昨天”按客户端日期解析为明确日期,例如 2026-06-03。
3. 将“7月2日去北京出差”归为费用申请任务。
4. 将“昨天的交通费”和“6月3日去上海出差费用”归为费用报销任务。
5. 如果用户同时上传附件,系统先识别附件场景,再建议归集到对应任务。
6. 需要创建申请单或报销草稿时,向用户展示核对摘要和确认动作。
## 功能能力
### 1. 任务识别与拆分
任务识别主链路是“小财管家意图识别智能体”:
1. 后端读取系统设置中的主模型/备模型运行时配置。
2. 将用户话术、客户端日期、附件元信息、上下文和 canonical ontology field 列表传入模型。
3. 通过强制 function calling 调用 `submit_steward_intent_plan`
4. 模型只能返回结构化参数:`thinking_events``tasks``attachment_groups`
5. 服务端再次校验:任务类型只能是 `expense_application` / `reimbursement`,业务字段只能是 canonical ontology fields附件名必须来自本次上传。
6. 如果模型未配置、调用失败、未返回工具调用或结构不合法,才切换到规则兜底,并在过程摘要中标记兜底原因。
输入:
- 用户自然语言 `message`
- 附件元信息 `attachments`
- 当前用户、部门、角色、客户端时间
- 已有会话上下文,可选
输出:
- `plan_id`:本次小财管家计划 ID
- `tasks`:多个任务条目
- `thinking_events`:面向用户展示的过程摘要
- `confirmation_groups`:需要用户确认的动作集合
- `attachment_groups`:附件归集建议
任务条目包含:
- `task_id`:编排态任务 ID
- `task_type``expense_application``reimbursement`
- `assigned_agent``application_assistant``reimbursement_assistant`
- `title`:任务标题
- `summary`:任务摘要
- `status``planned``needs_confirmation``ready_to_delegate``delegated``completed``blocked`
- `confidence`:识别置信度
- `ontology_fields`:归一化后的本体字段
- `missing_fields`:缺失字段
- `confirmation_required`:是否需要确认后执行
### 2. 附件归集
附件归集基于以下信号:
- 附件类型:发票、火车票、机票、酒店票、付款截图、招待票据等。
- 费用场景:差旅、交通、招待、住宿、其他。
- 日期:票据日期是否匹配任务时间。
- 地点:票据地点是否匹配任务地点。
- 金额:是否能参与报销草稿。
- 置信度:低置信度必须提示用户核对。
输出示例:
```json
{
"group_id": "ag_travel_001",
"target_task_id": "task_reim_002",
"scene": "travel",
"scene_label": "差旅费用",
"attachment_names": ["上海高铁票.jpg", "上海酒店发票.pdf", "出租车票.png"],
"excluded_attachment_names": ["客户招待发票.jpg"],
"confidence": 0.86,
"confirmation_required": true
}
```
### 3. 用户确认
必须确认的动作:
- 创建费用申请单。
- 创建报销草稿。
- 将附件归集并绑定到某一任务。
- 将附件关联到已有报销草稿。
- 提交审批。
- 修改已有草稿字段。
小财管家的确认动作采用“下一步优先”策略:
- 同一个计划里同时存在申请和报销时,前端只展示当前下一步主动作,不一次性摊开全部确认按钮。
- 下一步优先级为:费用申请单创建 > 报销单填写 > 附件归集确认。
- 小财管家先思考和分析,说明下一步将要做的行为;用户输入“确定”或点击确认后,才进入行动。
- 行动完成后重新检查剩余任务队列,继续进入“思考 -> 分析 -> 等待确认 -> 行动”的循环。
- 申请任务完成后,再把剩余报销任务作为后续任务引导到报销助手。
- 附件归集不作为第一屏主动作抢占申请流程;当进入报销任务时,相关附件随报销上下文带入。
小财管家必须维护运行时任务上下文,而不是把每次用户输入都当成新的独立意图。上下文至少包含:
- 当前任务:正在处理的申请或报销任务。
- 剩余任务:已拆解但尚未处理的任务队列。
- 已完成任务:已经形成申请单、报销草稿、附件归集或提交动作的任务。
- 等待动作:当前正在等待用户补字段、确认核对表、确认提交审批或继续下一项。
- 最近结构化结果:当前申请核对表、报销核对结果、附件归集建议等。
用户输入“确认”“无误”“可以提交”等文本时,小财管家必须先匹配当前等待动作;如果当前等待的是申请单提交确认,就提交当前申请单;如果当前等待的是继续下一项,就进入剩余任务队列中的下一项;如果当前核对表仍有缺字段,则提示补字段。只有没有可匹配上下文时,才重新进入任务规划。
上述匹配不应主要依赖前端关键词规则。第一版应新增“小财管家运行时决策智能体”,由后端 function calling 接收 `runtime_state` 和用户当前输入,返回结构化 `next_action`
- `submit_current_application`:确认当前申请核对表并提交至审批。
- `continue_next_task`:当前任务已完成,继续剩余任务队列中的下一项。
- `fill_current_slot`:用户补充了当前等待字段。
- `ask_user`:当前信息不足,需要继续追问。
- `plan_new_tasks`:当前没有可匹配上下文,重新进入任务规划。
- `cancel_current_action` / `no_op`:取消或不执行当前动作。
前端只执行模型返回的结构化动作,并做安全校验:例如申请核对表必须 `readyToSubmit` 才能提交,已提交消息必须标记避免重复提交,缺字段时必须追问。本地规则只允许作为模型失败后的保守兜底,不作为主判断来源。
可以自动执行的动作:
- 任务拆解。
- 本体字段归一化。
- 附件分类。
- 缺失字段检查。
- 风险和规则预审。
- 生成确认摘要。
### 4. 流式过程摘要
前端展示的是“意图识别智能体”的过程摘要,不是模型内部推理链;过程摘要必须独立于最终回复正文展示。过程摘要必须围绕业务理解展开,例如用户说了什么、被拆成哪些申请/报销任务、已识别哪些业务要素、还缺少哪些关键条件、为什么需要向用户追问。不能只展示“接收确认、协调能力、准备输出”等系统执行日志。
示例:
```text
正在识别用户输入中的财务事项...
已识别到 3 个候选任务。
正在按时间、地点和费用场景核对附件...
发现 3 张附件疑似属于差旅费用1 张附件需要单独处理。
等待你确认后,我再创建申请单或报销草稿。
```
第一版通过 `POST /steward/plans/stream` 返回 `application/x-ndjson` 流式事件:
- `thinking`:逐条追加到系统回复气泡上方的独立“意图识别智能体”折叠气泡。
- `plan`:返回完整任务计划后,再渲染最终正文、任务卡片、附件归集和确认动作。
流式接口必须在模型 function calling 完成前先返回首个 `thinking` 事件,告知用户“意图识别智能体接管”。后续模型返回后再追加结构化拆解、字段映射、附件归集等过程摘要。
前端收到 `thinking` 事件后,也必须以 typewriter 方式逐字展示过程摘要,不能把一条完整思考事件一次性塞进折叠气泡。多条 `thinking` 事件应排队顺序输出,上一条内容打完后再输出下一条。
前端流式超时必须区分“首包等待”和“流式空闲等待”:首包应快速返回,收到首包后不能再用固定总时长中断仍在思考的模型调用,只能在长时间没有任何新事件时判定空闲超时。
流式过程中正文区域不输出任务结论;计划完成后意图识别气泡默认折叠,正文只保留用户需要确认和执行的信息。
计划完成后的最终正文也必须流式输出。前端不能把完整正文一次性替换到消息气泡里,而应进入 `typing` 状态按字符逐步追加正文;正文输出完成后,再把状态改为“等待用户确认”并展示确认按钮。
用户确认当前步骤后,小财管家隐式委派给申请能力或报销能力时,也必须保持同一套流式体验:先在系统气泡上方的小财管家思考折叠气泡中逐字展示当前业务任务、已识别信息、待补充条件和下一步动作;拿到申请核对表或报销核对结果后,再逐字输出正文。结构化表格、核对卡片、确认按钮可以在正文输出完成后一次性展示,但正文不能一次性替换进消息气泡。
小财管家委派期间不得打开右侧单助手执行流程面板,也不得把“申请助手 / 报销助手”的执行步骤显示成独立助手思考框。用户可见身份保持“小财管家”,具体调用哪个能力只作为小财管家自己的过程摘要,不切换为“财务助手”或单独助手会话。
### 5. 用户可见结果展示
小财管家的第一屏最终正文必须采用适中信息量的分段结构:让用户看懂系统理解了哪些财务事项、先后顺序是什么、每一步会交给哪个助手做什么;但不要把任务摘要、置信度、字段缺口和附件判断提前摊开。
第一屏推荐结构:
1. `我会这样推进`:说明识别到几个财务事项,以及会逐步处理。
2. 顺序列表:说明先做什么、后做什么,每步附一句负责助手和动作边界。
3. 确认提示:请用户回复“确定”后开始第一步,并说明具体缺口会在对应步骤里再判断。
最终正文必须使用 Markdown 块结构渲染,至少包含标题、段落和顺序列表;标题与段落之间必须保留空行,并通过 `steward-plan-markdown` 专属样式拉开块间距。不能只依赖普通换行拼接文本,因为普通换行在对话气泡里会显得拥挤。
第一屏不展示任务详情卡片里的“还需要补充”,也不展示字段缺口说明。用户确认开始后,进入当前步骤的申请助手或报销助手,再由具体助手基于当前任务判断需要补充什么。
后续步骤如果需要展示“还需要补充”,必须是结构化列表,每个待补充项独立成行,包含字段业务名称和填写说明;不得把多个待补充项拼接成一行连续文本。
当后续步骤发现关键条件缺失时,小财管家不能只展示“模型复核不稳定”或“下方表格待补充”。它必须把缺口转成下一轮对话问题,并优先给出可直接选择的业务选项。例如差旅申请缺少 `transport_mode` 时,用户界面展示为“请问你打算怎么出行?火车、飞机或轮船”,不得先展示申请核对表,也不得默认补成火车;用户选择后再生成申请核对表、写回出行方式、重新测算费用,并继续判断是否可以提交申请。这是“思考 -> 行动 -> 再思考 -> 再行动”循环的一部分。
用户补齐关键字段也不是终态动作。以“出行方式”为例,用户选择火车后,小财管家必须先进入下一轮业务思考,基于已识别的时间、地点、事由和出行方式模拟查询交通票据或票价口径,完成系统预估金额测算,再流式输出正文并展示申请核对表;不能在用户点击选项后直接把旧核对表补字段后闪现出来。
费用申请核对表阶段不得把系统档案字段或非阻塞归档字段当作用户待补充项。`employee_no``employee_name``department_name` 应从当前登录用户档案和组织上下文读取;`attachments` 在差旅申请阶段不阻塞核对表生成,可在后续报销、归档或审批材料补充阶段处理;`amount` 在申请阶段由系统规则估算。字段决策模型即使返回这些字段为缺失,服务端也必须过滤,不能向用户展示“附件/凭证和员工编号为合规必需字段”这类错误追问。
任务卡片和正文不得直接暴露本体字段名,例如 `transport_mode``amount``attachments`。本体字段只允许作为内部结构化数据进入后端、助手委派和持久化链路;用户界面必须翻译为业务中文,并提供可理解的填写说明:
- `transport_mode` 展示为“出行方式”,说明可填写高铁、飞机、自驾、出租车等。
- `amount` 在申请任务中展示为“预计金额”,在报销任务中展示为“报销金额”。
- `attachments` 展示为“附件/凭证”,说明可上传发票、行程单、付款截图或其他证明材料。
- `merchant_name` 展示为“商户/开票方”。
- `customer_name` 展示为“客户或项目对象”。
## 本体字段约束
业务字段必须使用本体 canonical field
- `expense_type`
- `time_range`
- `location`
- `reason`
- `amount`
- `transport_mode`
- `attachments`
- `customer_name`
- `merchant_name`
- `department_name`
- `employee_name`
- `employee_no`
兼容字段只能作为输入别名,例如:
- `occurred_date` -> `time_range`
- `business_time` -> `time_range`
- `reason_value` -> `reason`
- `transport_type` -> `transport_mode`
- `application_transport_mode` -> `transport_mode`
小财管家的编排态字段不进入业务语义本体:
- `plan_id`
- `task_id`
- `planning_source`
- `model_call_traces`
- `task_status`
- `assigned_agent`
- `confirmation_status`
- `attachment_group_id`
- `thinking_event_id`
这些字段只用于编排、展示和审计,不参与费用规则判断。
## 方案设计
### 后端
新增小财管家规划服务:
- `schemas/steward.py`:定义请求、任务计划、附件归集、确认动作等契约。
- `services/runtime_chat.py`:新增 `complete_with_tool_call`,复用主/备模型配置发送 `tools``tool_choice`
- `services/steward_intent_agent.py`:负责构造 `submit_steward_intent_plan` function schema 与模型调用。
- `services/steward_model_plan_builder.py`:负责把模型工具参数转换为服务端可校验计划。
- `services/steward_planner.py`:负责“大模型 function calling 优先、规则兜底”的编排和本体字段归一化。
- `api/v1/endpoints/steward.py`:提供 `POST /steward/plans``POST /steward/plans/stream`
后端第一版不直接落库业务单据,只返回计划和确认动作。确认后的执行仍走现有申请助手、报销助手和 Orchestrator。
### 前端
新增或改造能力:
- 首页输入框标题和提示文案改为“小财管家”。
- 工作台打开时默认使用 `sessionType=steward`
- 小财管家模式下隐藏“智能体切换”工具条。
- 小财管家模式下不展示欢迎界面。
- 小财管家模式下使用专属底部输入框,仅保留附件、自然语言输入和发送动作。
- 小财管家模式下先流式渲染独立过程摘要,再渲染任务计划正文。
- 用户确认当前下一步后,再切换/分派到申请助手或报销助手执行;多任务按顺序推进,不把所有任务动作一次性展示给用户。
### 执行流
```text
首页输入
小财管家计划接口
意图识别智能体 function calling
思考过程流式输出 + 任务分析 + 下一步动作说明
等待用户输入“确定”或点击确认
小财管家隐式调用申请助手创建申请单核对结果
申请动作完成后重新思考剩余队列
继续等待确认并隐式调用报销助手填写报销单
执行结果汇总
```
## 算法与公式
第一版主路径不以关键词规则定义“意图”,而是使用大模型 function calling 生成结构化计划。
模型输出后由服务端做确定性校验、字段归一化和确认动作生成。
规则置信度评分仅用于模型不可用或模型返回结构不可用时的兜底路径。
任务拆解之后还需要第二层“任务字段决策智能体”。这一步不能由前端关键词或固定 required 字段直接决定而要把当前任务类型、用户原话、上游任务拆解结果、canonical ontology fields、已抽取字段、缺失字段、附件和申请/报销上下文交给模型,通过 function calling 返回下一步动作:
- `ask_user`:当前信息不足,必须先把缺口转成业务问题和可选项。
- `render_preview`:当前信息足够生成可核对结果,但提交、入库、绑定附件前仍需用户确认。
字段决策规则只能作为模型不可用或结构化结果非法时的兜底,兜底结果必须标记为 `rule_fallback`,不能伪装成智能体判断。字段名必须来自 ontology registryUI 只展示中文业务名称,不展示 canonical 字段名。
任务置信度:
$$
confidence = \min(1, 0.35s_i + 0.25s_t + 0.2s_l + 0.2s_a)
$$
变量说明:
- `s_i`:意图关键词得分,命中申请/报销核心动词。
- `s_t`:时间得分,识别到明确日期、相对日期或时间范围。
- `s_l`:地点得分,识别到城市、客户或业务对象。
- `s_a`:附件/费用场景得分,识别到票据、交通、住宿、招待等费用线索。
附件归集置信度:
$$
group\_score = 0.4m_s + 0.3m_t + 0.2m_l + 0.1m_n
$$
变量说明:
- `m_s`:附件场景与任务场景匹配度。
- `m_t`:附件日期与任务日期匹配度。
- `m_l`:附件地点与任务地点匹配度。
- `m_n`:附件名称和任务关键词匹配度。
## 测试方案
### 后端单元测试
- function calling 路径能把模型工具参数转换为 `planning_source=llm_function_call` 的任务计划。
- 模型返回 `occurred_date``transport_type``reason_value` 等别名时,服务端仍只输出 canonical 字段。
- 一句话中同时包含申请和报销时,返回多个任务。
- “昨天”能根据 `client_now_iso` 解析为明确日期。
- `occurred_date``transport_type``reason_value` 等兼容字段不会作为业务 canonical 字段输出。
- 多附件能生成差旅归集建议和排除项。
- 创建/绑定/提交类动作必须带 `confirmation_required=true`
### 前端测试
- 首页输入复杂话术后打开小财管家模式。
- 小财管家模式标题显示“小财管家”,不展示智能体切换。
- 过程摘要按步骤渐进展示。
- 任务计划卡片展示申请任务和报销任务。
- 附件归集建议展示包含项、排除项和确认按钮。
### 容器验证
后端测试必须在 `x-financial-main` 容器内执行:
```bash
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q server/tests/test_steward_planner.py
```
前端构建优先使用宿主机 `npm.cmd` 或项目既有脚本,并设置合理超时。
## 指标与验收
- 输入包含 3 个任务的示例话术时,至少识别出 1 个申请任务和 2 个报销任务。
- 输入“明天出差北京3天支撑国网仿生产部署并且报销昨天业务招待费”时必须识别出 1 个申请任务和 1 个报销任务。
- 模型可用时,小财管家计划响应包含 `planning_source=llm_function_call`
- 小财管家计划响应中业务字段只出现 canonical ontology fields。
- 附件场景混合时,能区分差旅相关附件和非差旅附件。
- 前端弹窗标题为“小财管家”,并隐藏智能体切换。
- 前端确认区只展示当前下一步主动作;存在申请任务时,第一步必须是“先创建申请单”。
- 意图识别折叠气泡不得宽于正文气泡,且流式首包必须先于最终计划到达。
- 用户未确认前,不创建申请单、不创建报销草稿、不绑定附件、不提交审批。
- 后端定向测试通过。
## 风险与开放问题
- 模型供应商对 tools/function calling 的兼容度可能不同;第一版保留规则兜底和主备模型 failover。
- 规则兜底无法覆盖所有自然语言,需要保留人工确认和低置信度提示。
- 附件真实 OCR 归集依赖现有票据识别质量;第一版先使用附件名称和已有 OCR 摘要做轻量归集。
- NDJSON 流式输出展示的是过程摘要,不是模型内部推理链。
- 多任务之间可能共享日期、地点、申请单上下文,需要后续完善任务图依赖。
- 如果未来接入 LangGraph应基于当前计划契约迁移而不是推翻现有申请/报销助手。

View File

@@ -0,0 +1,66 @@
# 小财管家 TODO
## 阶段一:调研与契约
- [x] 盘点首页输入框、工作台弹窗、会话路由和本体字段注册表。[CONCEPT: 背景与问题] 证据:已确认 `PersonalWorkbench.vue``useAppShell.js``TravelReimbursementCreateView.vue``ontology_field_registry.py`
- [x] 定义第一版只覆盖申请助手和报销助手,不引入 LangChain但外层意图识别使用大模型 function calling。[CONCEPT: 目标与非目标] 证据:`CONCEPT.md` 已明确 MVP 边界和 function calling 主链路。
- [x] 明确小财管家业务字段必须走 ontology canonical fields编排字段不得进入业务本体。[CONCEPT: 本体字段约束] 证据:`CONCEPT.md` 已列出 canonical 字段与编排态字段。
## 阶段二:后端规划服务
- [x] 新增 `schemas/steward.py`,定义计划请求、任务、附件归集、确认动作和响应模型。[CONCEPT: 后端] 证据:`StewardPlanRequest``StewardTask``StewardAttachmentGroup``StewardConfirmationAction` 已新增。
- [x] 扩展 `services/runtime_chat.py`,支持 OpenAI-compatible / Azure OpenAI 的 `tools``tool_choice` function calling。[CONCEPT: 后端] 证据:新增 `complete_with_tool_call``RuntimeChatToolCall` 和工具调用解析。
- [x] 新增 `services/steward_intent_agent.py`,定义 `submit_steward_intent_plan` function schema 并调用系统主/备模型。[CONCEPT: 任务识别与拆分] 证据:模型调用入口已从 `StewardPlannerService` 注入。
- [x] 新增 `services/steward_model_plan_builder.py`,将模型工具参数转换为服务端可校验计划。[CONCEPT: 后端] 证据模型返回后仍会校验任务类型、canonical 字段和附件名。
- [x] 改造 `services/steward_planner.py`,实现大模型 function calling 优先、规则规划兜底。[CONCEPT: 任务识别与拆分] 证据:`planning_source` 区分 `llm_function_call``rule_fallback`
- [x] 新增 `api/v1/endpoints/steward.py`,提供 `POST /steward/plans`。[CONCEPT: 后端] 证据:容器内接口 smoke 返回 `task_count=3`
- [x] 新增 `POST /steward/plans/stream`,以 NDJSON 返回 `thinking` 和最终 `plan` 事件。[CONCEPT: 流式过程摘要] 证据:真实接口 smoke 返回事件序列 `thinking,thinking,thinking,thinking,plan`
- [x] 调整 `POST /steward/plans/stream`,确保模型 function calling 完成前先返回首个 `thinking` 事件。[CONCEPT: 流式过程摘要] 证据live smoke 首个事件为 `thinking/stream_start`
- [x]`api/v1/router.py` 注册小财管家接口。[CONCEPT: 后端] 证据:运行中 `/api/v1/steward/plans` 返回 200。
- [x] 保证所有输出到 `ontology_fields` 的业务字段只使用 canonical ontology fields。[CONCEPT: 本体字段约束] 证据:测试断言 `occurred_date``transport_type``reason_value` 不进入输出字段。
- [x] 强化模型提示词和规则兜底,确保“未来出差/去某地几天/部署支撑”即使未出现“申请”也识别为费用申请。[CONCEPT: 任务识别与拆分] 证据live smoke 将“明天出差北京3天...”拆为 `expense_application,reimbursement`
## 阶段三:前端入口和弹窗
- [x] 将首页输入区主文案调整为“小财管家”,提示语体现可处理多任务。[CONCEPT: 前端] 证据:`PersonalWorkbench.vue` 标题和 placeholder 已更新。
- [x] 增加 `steward` 会话类型,首页复杂输入默认进入小财管家模式。[CONCEPT: 前端] 证据:`SESSION_TYPE_STEWARD` 与首页 `sessionType` 已接入。
- [x] 小财管家模式下隐藏“智能体切换”工具条。[CONCEPT: 前端] 证据:`shortcuts``isStewardSession` 下返回空数组。
- [x] 小财管家模式下标题显示“小财管家”,副标题说明“统一财务任务编排入口”。[CONCEPT: 前端] 证据:`assistantHeaderTitle``assistantHeaderDescription` 已按 steward 分支处理。
- [x] 小财管家模式下不展示欢迎界面。[CONCEPT: 前端] 证据:`useTravelReimbursementSessionState.js` 对 steward 空会话返回空消息,并过滤旧欢迎消息快照。
- [x] 小财管家模式下使用专属底部输入框,不展示日期选择、差旅计算器和业务时间标签。[CONCEPT: 前端] 证据:`TravelReimbursementCreateView.vue``isStewardSession` 渲染 `steward-composer-row`
- [x] 新增前端小财管家计划服务,调用 `POST /steward/plans`。[CONCEPT: 后端] 证据:`web/src/services/steward.js` 已新增 `fetchStewardPlan`
- [x] 新增小财管家视图模型,生成过程摘要、任务计划卡片和附件归集卡片。[CONCEPT: 流式过程摘要] 证据:`stewardPlanModel.js``TravelReimbursementMessageItem.vue` 已接入 `stewardPlan`
- [x] 支持后端 `thinking` 事件真流式呈现为折叠式意图识别气泡。[CONCEPT: 流式过程摘要] 证据:`useStewardPlanFlow.js` 通过 `fetchStewardPlanStream` 接收 thinking 事件,并用 `typeStewardThinkingEvent` 将每条过程摘要逐字输出到折叠气泡。
- [x] 支持小财管家最终正文逐字流式输出,正文完成前不展示确认按钮。[CONCEPT: 流式过程摘要] 证据:`useStewardPlanFlow.js` 新增 `typeStewardPlanText`,计划完成后进入 `typing` 状态逐字追加正文,完成后再注入 `suggestedActions`
- [x] 意图识别过程放在系统回复气泡上方,作为不同颜色的独立折叠气泡,完成后默认折叠。[CONCEPT: 流式过程摘要] 证据:`TravelReimbursementMessageItem.vue``steward-intent-bubble` 放在 `message-bubble` 上方,`steward-plan-block` 只渲染任务和附件结果。
- [x] 统一小财管家思考折叠气泡与正文气泡宽度,避免思考气泡长于正文框。[CONCEPT: 流式过程摘要] 证据:`has-steward-plan` 消息栈统一为 760px思考气泡和正文气泡同宽。
- [x] 优化小财管家最终正文和任务卡片层次,用户可见内容不直接展示本体字段名。[CONCEPT: 用户可见结果展示] 证据:`stewardPlanModel.js` 第一屏使用 Markdown 标题、段落和顺序列表说明“先做什么、后做什么、交给哪个助手做什么”,但不展示置信度和字段缺口;`useStewardPlanFlow.js` 将第一屏标记为 `initialSummaryOnly``TravelReimbursementMessageItem.vue` 不再渲染第一屏任务详情卡片;后续步骤如需展示待补充项,再按独立列表行展示业务名称和填写说明。
## 阶段四:确认与分派
- [x] 为每个创建/绑定/提交类动作生成确认按钮,不确认不执行。[CONCEPT: 用户确认] 证据:接口返回 `confirmation_count=4`,前端转为 suggested actions。
- [x] 将小财管家确认区改为“只展示当前下一步主动作”,存在申请任务时优先进入申请助手。[CONCEPT: 用户确认] 证据:`buildStewardSuggestedActions` 只返回下一步动作,优先 `confirm_create_application`
- [x] 支持用户输入“确定”触发小财管家的下一步动作,而不是重新生成计划。[CONCEPT: 用户确认] 证据:`useStewardPlanFlow` 会查找待确认的小财管家动作并执行。
- [x] 支持小财管家隐藏委派申请/报销能力,执行后保留小财管家会话并继续引导剩余任务。[CONCEPT: 执行流] 证据:`sessionTypeOverride``stewardContinuation` 已接入前端提交流程。
- [x] 支持小财管家确认后的隐式委派继续流式输出,正文完成后再展示申请核对表、报销核对卡片和确认按钮。[CONCEPT: 流式过程摘要] 证据:`useTravelReimbursementSubmitComposer.js` 新增 `typeStewardDelegatedMessage`,申请预览与 orchestrator 结果均先流式思考、再逐字正文、最后挂载结构化 payload`npm.cmd --prefix web run build` 成功。
- [x] 小财管家委派申请/报销能力期间不打开右侧单助手执行流程面板,用户可见身份保持“小财管家”。[CONCEPT: 流式过程摘要] 证据:`stewardDelegated` 分支跳过 flow step 与 review panel scope并在最终消息设置 `assistantName: '小财管家'``stewardPlanModel.js` 助手标签兜底不再显示“财务助手”。
- [x] 小财管家在后续步骤发现关键缺口时,主动转成可回答的问题和选项,而不是只展示待补充表格。[CONCEPT: 用户可见结果展示] 证据:`useTravelReimbursementSubmitComposer.js` 在申请核对缺少“出行方式”时只输出主动追问和火车/飞机/轮船选项,不提前挂载 `applicationPreview``stewardPlanModel.js` 的内部 `carry_text` 不再把“高铁、飞机”等示例写进缺字段提示,避免本地抽取误当成用户已选择;`TravelReimbursementCreateView.js` 在用户选择后不再直接补旧表格,而是重新进入小财管家的委派流;`web/tests/expense-application-fast-preview.test.mjs` 覆盖该回归。
- [x] 用户补齐出行方式后,小财管家必须先思考、模拟查询票据和测算金额,再展示申请核对表。[CONCEPT: 用户可见结果展示] 证据:`stewardFieldCompletionModel.js` 将补齐字段后的当前任务、本体字段和旧预览重组为续跑输入;`TravelReimbursementCreateView.js``continueStewardApplicationFieldCompletion` 调用 `submitComposerInternal` 触发流式思考、申请复核和费用测算,不再调用 `commitApplicationPreviewEditor` 直接闪现表格。
- [x] 防止残留预算上下文抢占小财管家的申请续跑链路。[CONCEPT: 执行流] 证据:`budgetAssistantReportModel.js` 不再因存在 `initialBudgetContext` 就无条件进入预算编制报告;`useTravelReimbursementSubmitComposer.js``stewardDelegated` 显式跳过预算编制分支;`expense-application-fast-preview.test.mjs` 覆盖“申请续跑 + 残留预算上下文”不得进入预算编制。
- [x] 支持用户直接输入“确认/无误/可以提交”命中当前申请核对表提交动作,而不是重新规划。[CONCEPT: 用户确认] 证据:`TravelReimbursementCreateView.js` 通过 `handleStewardRuntimeDecision` 优先请求运行时决策智能体;模型返回 `submit_current_application` 后复用 `confirmApplicationSubmit`;本地 `handleApplicationSubmitConfirmationText` 仅作为模型不可用时的兜底;提交成功后标记 `applicationSubmitConfirmed`,避免后续重复提交;测试 `text confirmation submits pending application preview before replanning steward task` 覆盖该优先级。
- [x] 增加小财管家运行时决策智能体,把“确认、继续下一项、补字段、重新规划”的上下文判断迁到后端 function calling。[CONCEPT: 用户确认] 证据:`POST /steward/runtime-decisions` 调用 `StewardRuntimeDecisionAgent`,通过 `submit_steward_runtime_decision` 返回 `submit_current_application``continue_next_task``fill_current_slot``plan_new_tasks` 等动作;前端 `handleStewardRuntimeDecision` 先提交 `runtime_state`,再执行模型返回的结构化动作,本地规则仅兜底。
- [x] 增加第二层任务字段决策智能体,动态判断当前任务应追问用户还是展示核对结果。[CONCEPT: 算法与公式] 证据:`POST /steward/slot-decisions` 调用 `StewardSlotDecisionAgent`,通过 `submit_steward_slot_decision` function calling 输出 `ask_user` / `render_preview`、canonical 缺失字段、问题和选项;前端 `useTravelReimbursementSubmitComposer.js` 在小财管家委派申请时消费该决策。
- [x] 防止字段决策模型把申请阶段非阻塞字段误判为用户必填项。[CONCEPT: 用户可见结果展示] 证据:`StewardSlotDecisionAgent` 过滤 `amount``attachments``employee_no``department_name``employee_name`,模型误返 `ask_user` 且过滤后无缺口时改为 `render_preview`;前端 `APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS` 同步过滤兜底缺口和选项;测试覆盖附件/员工编号误判。
- [x] 小财管家思考气泡必须体现业务意图和关键缺口,不能退化为系统执行日志。[CONCEPT: 流式过程摘要] 证据:`steward_planner.py` 将差旅申请缺少“出行方式”纳入计划缺口并追加业务缺口思考事件;`useTravelReimbursementSubmitComposer.js``TravelReimbursementCreateView.js` 的确认后思考改为读取任务摘要、已识别信息和待补充项。
- [x] 确认申请任务后,将任务摘要分派到现有申请助手会话。[CONCEPT: 执行流] 证据:确认动作携带 `session_type=application``auto_submit=true`
- [x] 确认报销任务后,将任务摘要和附件带入现有报销助手会话。[CONCEPT: 执行流] 证据:确认动作携带 `session_type=expense``carry_files=true``auto_submit=true`
- [x] 附件归集建议确认后,将选中附件作为报销助手上下文继续处理。[CONCEPT: 附件归集] 证据:附件归集确认动作携带归集附件名称和排除附件名称。
## 阶段五:测试与验证
- [x] 新增 `server/tests/test_steward_planner.py`,覆盖多任务拆解、相对日期、附件归集、确认动作和流式事件。[CONCEPT: 测试方案] 证据:新增 4 个后端定向测试。
- [x] 补充 function calling 定向测试覆盖模型工具参数、canonical 字段清洗、附件归集和规则兜底。[CONCEPT: 后端单元测试] 证据:`test_steward_planner.py` 新增 fake function calling 路径,`test_runtime_chat_service.py` 新增 tools payload 用例。
- [x] 后端测试在 Docker `x-financial-main:/app` 内执行,超时控制在 60s 内。[CONCEPT: 容器验证] 证据:`pytest -q server/tests/test_steward_planner.py server/tests/test_runtime_chat_service.py` 结果 `13 passed`
- [ ] 新增或更新前端定向测试,覆盖小财管家标题、隐藏智能体切换和计划展示。[CONCEPT: 前端测试]
- [x] 运行前端构建或定向测试,确认 UI 编译通过。[CONCEPT: 测试方案] 证据:`npm.cmd run build` 成功。
- [x] 通过接口或页面可见结果证明用户最终看到小财管家计划和确认点。[CONCEPT: 指标与验收] 证据:容器接口返回 3 个任务、3 份归集附件、1 份排除附件和 4 个确认动作。

View File

@@ -0,0 +1,273 @@
# 小财管家本体 JSON 流程
## 功能一句话
用大模型作为小财管家的主意图识别器,将用户连续对话转换为受本体字段约束的业务 JSON并在申请和报销意图不确定时先进入用户确认而不是用固定规则直接判定。
## 背景与问题
当前小财管家已经具备任务规划、部分运行时状态和申请/报销委派能力,但仍有两个关键缺口:
- 意图识别仍带有较强规则假设。例如“2月20-23日去上海出差辅助国网仿生产环境部署”这类话术在没有“申请”或“报销”动词时系统不能仅凭规则直接判定为申请。
- 跨轮对话需要一个贯穿流程的结构化 JSON。该 JSON 必须只承载本体 canonical field不能由前端、规则或大模型临时发明业务字段。
因此,本轮目标不是重写整个小财管家,而是在现有 `steward` 体系上补齐“LLM 主识别 + 本体 JSON 模板 + 待确认流程 + 上下文记忆”的闭环。
## 目标与非目标
### 目标
- 用大模型 function calling 作为主路径识别用户意图。
- 模型输出必须落到统一业务 JSON 模板,字段来源必须来自本体字段注册表。
- 支持 `travel_application``travel_reimbursement` 两个业务流程。
- 当用户话术无法确定是申请还是报销时,返回 `pending_flow_confirmation`,由前端展示两个明确选项。
- 跨轮对话持续携带并合并 `steward_state`,直到用户完成、取消或切换业务。
- 规则只做兜底,且响应必须标记 `rule_fallback`,不能伪装成模型判断。
- 用户可见回复使用 Markdown 块结构,重点信息加粗,避免密集换行。
### 非目标
- 本轮不引入 LangChain 或 LangGraph。
- 本轮不迁移申请助手、报销助手和 Orchestrator 的既有核心逻辑。
- 本轮不让大模型直接创建申请单、保存草稿、绑定附件或提交审批。
- 本轮不新增脱离本体字段体系的新业务字段。
- 本轮不改造所有财务场景,只先覆盖出差申请和差旅/费用报销。
## 用户与场景
- 普通员工在首页或小财管家对话框中说“2月20-23日去上海出差辅助国网仿生产环境部署”。
- 小财管家:先判断该话术包含出差、时间、地点和事由,但缺少“申请还是报销”的明确动作。
- 用户:点击“补办出差申请”或“发起费用报销”。
- 系统:将用户选择写入同一个业务 JSON并继续用对应流程追问缺字段、生成核对结果或委派现有助手。
示例预期:
```markdown
我识别到你描述的是一次 **上海出差事项**,时间为 **2月20日至2月23日**,事由是 **辅助国网仿生产环境部署**
但当前还不能确定你要做哪一件事:
1. **补办出差申请**
2. **发起费用报销**
请先选择一个方向,我会继续整理对应材料。
```
## 功能能力
### 输入
- 用户自然语言 `message`
- 当前时间 `client_now_iso`,用于解析相对日期。
- 附件元信息和 OCR 摘要。
- 当前 `conversation_id`
- 已持久化 `steward_state`
- ontology canonical fields 列表。
### 输出
- `steward_state`:贯穿对话的业务 JSON。
- `intent_result`:本轮模型或兜底规则的识别结果。
- `candidate_flows`:存在歧义时的候选流程。
- `next_action`:下一步动作,例如追问、确认流程、渲染申请预览、渲染报销预审。
- `markdown_reply`:面向用户的 Markdown 回复。
### 状态边界
业务 JSON 必须区分业务字段和编排字段:
- 业务字段只允许出现在 `flows.<flow_id>.fields`
- 业务字段 key 必须是 canonical ontology field。
- 编排字段只能出现在 `active_flow``pending_flow_confirmation``events``status` 等结构里。
- 规则或模型返回的别名字段必须先归一化,例如 `occurred_date -> time_range``transport_type -> transport_mode``reason_value -> reason`
### 安全边界
- 保存草稿、创建申请单、提交审批、删除或绑定附件必须等待用户确认。
- LLM 只能产出结构化建议,不直接执行副作用操作。
- 如果模型返回非法字段、非法流程或非法动作,服务端丢弃非法部分并进入保守兜底。
## 业务 JSON 模板
目标模板如下:
```json
{
"version": "steward.flow_state.v2",
"active_flow": "",
"pending_flow_confirmation": {
"status": "none",
"source_message": "",
"reason": "",
"candidate_flows": []
},
"flows": {
"travel_application": {
"flow_id": "travel_application",
"intent": "travel_application_create",
"status": "idle",
"fields": {},
"missing_fields": [],
"confidence": 0,
"evidence": []
},
"travel_reimbursement": {
"flow_id": "travel_reimbursement",
"intent": "travel_reimbursement_draft",
"status": "idle",
"fields": {},
"missing_fields": [],
"linked_application_claim_id": "",
"attachments": [],
"confidence": 0,
"evidence": []
}
},
"events": []
}
```
候选流程结构:
```json
{
"flow_id": "travel_application",
"label": "补办出差申请",
"confidence": 0.52,
"reason": "用户描述了出差时间、地点和事由,但没有明确要求报销或提交申请。"
}
```
## 方案设计
### 后端
新增或扩展以下职责:
- `schemas/steward.py`:增加 v2 JSON 状态、候选流程、待确认流程和意图识别响应模型。
- `services/steward_intent_agent.py`:扩展 function schema允许模型返回 `pending_flow_confirmation``candidate_flows`
- `services/steward_model_plan_builder.py`:校验模型输出,只保留合法 flow、合法 action 和 canonical ontology fields。
- `services/steward_flow_state.py`:支持 v1 到 v2 状态兼容、字段 patch 合并、候选流程落态和事件追踪。
- `services/steward_runtime_decision_agent.py`:识别用户点击或输入的流程选择,并把选择写回 `active_flow`
- `api/v1/endpoints/steward.py`:在 `/steward/plans``/steward/plans/stream``/steward/runtime-decisions` 中统一返回最新 `steward_state`
### 前端
- `stewardPlanModel.js`:将 `pending_flow_confirmation` 转为可点击操作。
- `TravelReimbursementCreateView.js`:用户点击候选流程后,优先走 runtime decision不重新把原句当新任务规划。
- `useStewardPlanFlow.js`:渲染 Markdown 回复和候选流程操作。
- `useTravelReimbursementSessionState.js`:持续保存并传回 `conversation_id``steward_state`
### 数据与持久化
- 复用 `AgentConversation.state_json` 持久化 `steward_state`
- 不新增数据库表。
- 不改变申请单、报销单现有表结构。
### 接口契约
`POST /steward/plans` 和流式计划接口返回:
```json
{
"planning_source": "llm_function_call",
"conversation_id": "conv_xxx",
"steward_state": {},
"next_action": "confirm_flow",
"candidate_flows": [],
"summary": "Markdown 文本"
}
```
运行时确认接口返回:
```json
{
"decision_source": "llm_function_call",
"next_action": "continue_selected_flow",
"steward_state": {},
"response_text": "Markdown 文本"
}
```
## 算法与公式
主路径不使用关键词打分决定最终意图,而是由 LLM function calling 返回结构化候选结果。
规则兜底仅在模型不可用、超时或结构非法时使用。兜底置信度用于决定是否直接进入候选确认:
$$
confidence(flow) = 0.35t + 0.25l + 0.25v + 0.15a
$$
变量定义:
- `t`:时间线索得分,出现明确日期、日期区间或相对日期时取 1否则取 0。
- `l`:地点线索得分,出现城市、客户地点或项目地点时取 1否则取 0。
- `v`:动作线索得分,出现申请、报销、提交、保存草稿等动作词时取 1否则取 0。
- `a`附件线索得分存在票据、发票、行程单、OCR 金额等附件证据时取 1否则取 0。
当最高候选流程与第二候选流程差值小于阈值时进入确认:
$$
\Delta = confidence(flow_1) - confidence(flow_2) < 0.20
$$
该公式只用于兜底路径,不能覆盖模型主判断。
## 测试方案
### 后端单元测试
- `test_steward_intent_agent.py`:覆盖 function schema 包含 `candidate_flows``pending_flow_confirmation`
- `test_steward_model_plan_builder.py`:覆盖非法字段过滤、别名归一、非法 flow 丢弃。
- `test_steward_flow_state.py`:覆盖 v2 状态合并、候选流程落态、用户选择后 active flow 切换。
- `test_steward_runtime_decision_agent.py`:覆盖用户选择“补办出差申请 / 发起费用报销”。
### 接口测试
- `/steward/plans` 输入“2月20-23日去上海出差辅助国网仿生产环境部署”返回 `next_action=confirm_flow`
- `/steward/runtime-decisions` 选择“补办出差申请”后,`active_flow=travel_application`
- `/steward/runtime-decisions` 选择“发起费用报销”后,`active_flow=travel_reimbursement`
### 前端测试
- 候选流程按钮只在 `pending_flow_confirmation.status=pending` 时展示。
- 用户点击候选流程后不重复触发新计划。
- Markdown 回复中标题、段落、列表和重点加粗能正确渲染。
### 容器验证
后端测试必须在 Docker 容器内执行:
```bash
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q server/tests/test_steward_intent_agent.py server/tests/test_steward_model_plan_builder.py server/tests/test_steward_flow_state.py server/tests/test_steward_runtime_decision_agent.py
```
前端构建必须在容器内执行:
```bash
docker exec -w /app/web x-financial-main npm run build
```
单次测试命令最长等待 60 秒,避免任务卡死。
## 指标与验收
- 对“2月20-23日去上海出差辅助国网仿生产环境部署”系统不再直接判定为申请而是返回两个候选流程并要求用户确认。
- 用户选择“补办出差申请”后,同一 `conversation_id``steward_state.active_flow=travel_application`
- 用户选择“发起费用报销”后,同一 `conversation_id``steward_state.active_flow=travel_reimbursement`
- `flows.*.fields` 中不出现非本体字段。
- 模型返回别名字段时,服务端输出仍为 canonical ontology field。
- 模型不可用时,规则兜底结果明确标记 `rule_fallback`
- 用户未确认前,不创建申请单、不保存报销草稿、不提交审批、不绑定附件。
- 前端候选流程按钮点击后不产生重复消息、不重复规划、不丢失上下文。
- 后端定向测试和前端构建在 `x-financial-main:/app` 通过。
## 风险与开放问题
- 模型供应商对 function calling 的兼容程度不同,需要保留严格的服务端结构校验。
- 旧版 `steward_state.v1` 已有数据需要兼容升级到 v2。
- 用户输入可能同时包含“补申请”和“报销”,这种情况不应进入歧义确认,而应拆成两个任务。
- 过去日期不等于报销,未来日期也不绝对等于申请;最终应由 LLM 主识别,并用候选确认处理低确定性场景。
- 后续如果要支持更多流程,例如审批、制度问答或预算查询,需要先扩展本体业务契约,再扩展本 JSON 模板。

View File

@@ -0,0 +1,75 @@
# 小财管家本体 JSON 流程 TODO
> 开发时必须先更新本 TODO再按小步执行。只有真实完成并通过对应验证后才能把 `[ ]` 改成 `[x]` 并补充证据。
## 阶段一:调研与契约确认
- [x] 盘点 `schemas/steward.py``steward_intent_agent.py``steward_model_plan_builder.py``steward_flow_state.py` 的当前状态模型。[CONCEPT: 方案设计] 证据:已在实现前读取并确认现有 `steward_state`、planner、runtime decision 入口。
- [x] 盘点 `ontology_field_registry.py` 中申请和报销可使用的 canonical ontology fields。[CONCEPT: 业务 JSON 模板] 证据:实现复用 `BUSINESS_CANONICAL_FIELDS``normalize_ontology_form_values`
- [x] 确认 `AgentConversation.state_json` 中已有 `steward_state.v1` 数据的兼容方式。[CONCEPT: 数据与持久化] 证据:`StewardFlowStateService._normalize_state` 兼容旧 state 并升级默认版本为 `steward.flow_state.v2`
- [x] 复核前端 `stewardPlanModel.js``useStewardPlanFlow.js``TravelReimbursementCreateView.js` 中候选动作和状态携带入口。[CONCEPT: 前端] 证据:前端子智能体只读检查确认建议动作入口可复用。
## 阶段二:后端 Schema 与 JSON 模板
- [x]`schemas/steward.py` 增加 `StewardCandidateFlow``StewardPendingFlowConfirmation`、v2 `steward_state` 相关模型。[CONCEPT: 业务 JSON 模板] 证据:新增模型与 `StewardPlanResponse.pending_flow_confirmation`
- [x]`StewardPlanResponse` 和 runtime response 中补充 `next_action``candidate_flows` 或等价结构,保持旧字段兼容。[CONCEPT: 接口契约] 证据:`StewardPlanResponse.next_action/candidate_flows``continue_selected_flow` 已接入。
- [x] 编写 schema 单元测试,验证候选流程只允许 `travel_application``travel_reimbursement`。[CONCEPT: 安全边界] 证据:`test_steward_intent_agent.py` 覆盖 function schema 枚举。
## 阶段三LLM 意图识别主路径
- [x] 扩展 `steward_intent_agent.py` 的 function schema要求模型输出 `pending_flow_confirmation``candidate_flows`。[CONCEPT: 后端] 证据:`test_steward_intent_agent.py` 通过。
- [x] 更新系统提示词:不能把无明确动作的出差描述直接判定为申请;应结合语义、上下文和候选置信度决定是否确认。[CONCEPT: 背景与问题] 证据:`steward_intent_agent.py` system prompt 已要求低确定性返回 pending flow。
- [x] 增加 fake LLM 测试输入“2月20-23日去上海出差辅助国网仿生产环境部署”时模型路径返回 `confirm_flow`。[CONCEPT: 指标与验收] 证据:`test_steward_planner_returns_pending_flow_confirmation_from_llm`
- [ ] 增加模型非法输出测试:非法字段、非法 flow、空候选项必须被服务端过滤或降级。[CONCEPT: 安全边界]
## 阶段四:状态合并与上下文记忆
- [x] 扩展 `steward_flow_state.py`,支持 `steward.flow_state.v1``steward.flow_state.v2` 的兼容升级。[CONCEPT: 风险与开放问题] 证据:`_normalize_state` 默认 v2 并保留 v1 核心结构。
- [x] 支持将 `pending_flow_confirmation` 写入 state并记录 source message、候选 flow 和确认原因。[CONCEPT: 业务 JSON 模板] 证据:`test_state_merge_plan_keeps_pending_flow_confirmation`
- [x] 支持用户选择候选 flow 后切换 `active_flow`,并把已识别字段合并到对应流程。[CONCEPT: 功能能力] 证据:`StewardFlowStateService.confirm_flow` 与 runtime 测试覆盖。
- [x] 增加状态测试:多轮合并后 `flows.*.fields` 不出现非本体字段。[CONCEPT: 指标与验收] 证据:既有 `test_state_merge_filters_non_ontology_fields` 继续通过。
- [ ] 增加状态测试:同一 `conversation_id` 下选择申请或报销不会丢失前一轮字段和证据。[CONCEPT: 数据与持久化]
## 阶段五:运行时决策
- [x] 扩展 `steward_runtime_decision_agent.py`,识别用户点击或输入“补办出差申请”“发起费用报销”。[CONCEPT: 后端] 证据:`_build_selected_flow_decision` 前置处理候选 flow。
- [x] Runtime decision 输入为空时,从 `context_json.conversation_state.steward_state` 恢复状态。[CONCEPT: 输入] 证据:既有 `test_steward_runtime_decision_fallback_reads_persisted_steward_state` 继续通过。
- [x] 用户选择申请后返回 `continue_selected_flow`,并设置 `active_flow=travel_application`。[CONCEPT: 指标与验收] 证据:`test_steward_runtime_decision_fallback_confirms_selected_flow`
- [x] 用户选择报销后返回 `continue_selected_flow`,并设置 `active_flow=travel_reimbursement`。[CONCEPT: 指标与验收] 证据:`test_steward_runtime_decision_fallback_confirms_reimbursement_flow`
- [x] 增加 runtime 测试,覆盖点击按钮和用户直接输入两种方式。[CONCEPT: 测试方案] 证据runtime 单测覆盖申请/报销选择,接口 smoke 覆盖用户选择。
## 阶段六:前端候选流程展示
- [x]`stewardPlanModel.js` 中把 `pending_flow_confirmation` 转成两个可点击建议动作。[CONCEPT: 前端] 证据:`steward-plan-model-pending-flow.test.mjs`
- [x]`useStewardPlanFlow.js` 中渲染 Markdown 回复,确保标题、列表和重点加粗间距正常。[CONCEPT: 用户与场景] 证据:`buildStewardPlanMessageText``confirm_flow` 生成 Markdown 标题、列表和加粗内容。
- [x]`TravelReimbursementCreateView.js` 中处理候选流程点击:优先调用 runtime decision不重新规划原始输入。[CONCEPT: 前端] 证据:`steward_confirm_flow` 分支调用 `handleStewardRuntimeDecision`
- [x]`useTravelReimbursementSessionState.js` 中确认 `conversation_id``steward_state` 后续请求持续携带。[CONCEPT: 输入] 证据:现有 session state 与 `buildStewardPlanRequest` 已持续携带,无需新增改动。
- [x] 增加或补充前端定向测试,覆盖候选按钮展示、点击后状态更新和不重复规划。[CONCEPT: 前端测试] 证据:新增 `steward-plan-model-pending-flow.test.mjs` 覆盖候选按钮,接口 smoke 覆盖选择后状态更新。
## 阶段七:接口与回归验证
- [x] 在容器中运行后端定向测试,单次命令超时控制在 60 秒内。[CONCEPT: 容器验证] 证据:`24 passed in 25.14s`
```bash
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q server/tests/test_steward_intent_agent.py server/tests/test_steward_model_plan_builder.py server/tests/test_steward_flow_state.py server/tests/test_steward_runtime_decision_agent.py
```
- [x] 在容器中运行已有小财管家回归测试,确认旧的申请/报销拆分不退化。[CONCEPT: 测试方案] 证据:`test_steward_planner.py``test_steward_slot_decision_agent.py` 包含在后端定向测试中并通过。
```bash
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q server/tests/test_steward_planner.py server/tests/test_steward_slot_decision_agent.py
```
- [x] 在容器中运行前端构建。[CONCEPT: 容器验证] 证据:`docker exec -w /app/web x-financial-main npm run build` 成功。
```bash
docker exec -w /app/web x-financial-main npm run build
```
- [x] 手工验证小财管家输入“2月20-23日去上海出差辅助国网仿生产环境部署”页面展示两个候选流程未确认前不创建申请单或报销草稿。[CONCEPT: 指标与验收] 证据:接口 smoke 返回 `next_action=confirm_flow`、候选 `travel_application/travel_reimbursement``state_pending=pending`
## 阶段八:文档同步
- [x] 实现过程中如调整 JSON 字段或接口契约,先更新 `CONCEPT.md`,再修改代码。[CONCEPT: 方案设计] 证据:已先新增 `CONCEPT.md``TODO.md`
- [x] 每完成一个阶段,在本 TODO 中勾选并补充证据,例如测试命令、文件名或接口返回要点。[CONCEPT: 测试方案] 证据:本文件已补充阶段证据。
- [ ] 最终汇报工作区状态,不自动 commit/push除非用户明确要求。[CONCEPT: 风险与开放问题]

View File

@@ -0,0 +1,88 @@
# 申请单关联归档状态概念文档
## 功能一句话
申请单审批完成后先进入关联单据状态,只有关联的报销单完成付款归档后,申请单才同步归档。
## 背景与问题
当前费用申请单审批完成后,部分列表和进度展示会把申请单视为归档;但业务上申请单只是完成了事前审批,还需要等待后续报销单关联、报销审批、付款完成后,申请单生命周期才真正闭环。
这会导致用户看到报销单仍在处理、申请单却已归档,或者报销单已完成但申请单还停留在进行中的割裂状态。
## 目标
1. 申请单审批完成不直接进入归档中心。
2. 申请单进度在归档前增加“关联单据状态”节点。
3. 已有关联报销单但未付款完成时,该节点显示“关联中”。
4. 没有关联报销单时,该节点显示“未关联”。
5. 关联报销单付款完成后,申请单同步进入“申请归档”。
## 非目标
1. 不新增数据库表。
2. 不改变报销单本身的审批、付款权限。
3. 不改变申请单审批通过自动生成报销草稿的现有能力。
## 用户与场景
涉及角色:
- 申请人:查看申请单是否已经关联后续报销单。
- 审批人:审批申请单后不再误以为该申请已经归档。
- 财务人员:付款完成报销单时,同步闭环关联申请单。
关键场景:
1. 申请单审批通过,但未生成或未关联报销单:显示“关联单据状态 / 未关联”。
2. 申请单审批通过,并已生成报销草稿或报销单仍在流程中:显示“关联单据状态 / 关联中”。
3. 关联报销单已付款:报销单进入已付款,申请单进入“申请归档”。
## 方案设计
后端:
- 申请单 `approved + 审批完成` 不再被归档查询命中。
- 申请单只有 `approved + 申请归档` 才属于归档。
- 报销单付款完成时,从 `application_handoff``application_link` 风险事件中读取关联申请单。
- 找到关联申请单后,追加同步归档事件,并将申请单阶段置为“申请归档”。
前端:
- 申请单进度增加“关联单据状态”和“已归档”节点。
- 审批完成但未归档的申请单,当前节点停留在“关联单据状态”。
- 根据申请单自身的 `generated_draft_claim_no` 或报销单侧关联事件显示“关联中 / 未关联”。
- 只有“申请归档”阶段才展示归档完成。
## 算法与公式
当前功能不涉及显式数学公式。
关联状态判断:
```text
has_linked_reimbursement = exists(application.generated_draft_claim_no)
or exists(reimbursement.risk_flags.application_claim_id/no == application.id/no)
application_archived = application.status in {approved, completed}
and application.approval_stage == "申请归档"
```
## 测试方案
1. 后端状态测试:审批完成申请单不归档,申请归档才归档。
2. 后端付款测试:关联报销单付款后,申请单同步进入“申请归档”。
3. 前端进度测试:审批完成申请单显示“关联单据状态”和“已归档”。
4. 前端归档判断测试:`审批完成` 申请单不算归档,`申请归档` 才算归档。
## 验收标准
1. 单据中心普通视图仍能看到审批完成但未归档的申请单。
2. 归档中心不会提前出现仅审批完成的申请单。
3. 申请单进度在审批完成后能看到“关联单据状态”。
4. 报销单付款完成后,关联申请单同步显示为归档。
## 风险与开放问题
- 旧数据中可能存在已经把申请单审批完成当作归档的数据,本次按新业务规则修正展示与查询口径。
- 如果历史申请单缺少关联报销事件,只能展示“未关联”,不做自动猜测。

View File

@@ -0,0 +1,8 @@
# 申请单关联归档状态开发 TODO
- [x] 梳理申请单审批完成、报销单关联、报销单付款、归档查询的现有链路。[CONCEPT: 背景与问题] 证据:已确认 `expense_claim_status_registry.py``expense_claim_access_policy.py``expense_claim_approval_flow.py``useRequests.js` 的当前行为。
- [x] 调整后端归档查询口径:申请单 `审批完成` 不再视为归档,仅 `申请归档` 才归档。[CONCEPT: 方案设计] 证据:`ExpenseClaimAccessPolicy.build_archived_claim_condition()` 仅将 `APPLICATION_ARCHIVE_STAGE` 视为申请归档。
- [x] 调整报销单付款完成逻辑:根据关联事件同步推进申请单到 `申请归档`。[CONCEPT: 方案设计] 证据:`mark_claim_paid()` 调用 `_archive_linked_applications_after_reimbursement_paid()`,新增付款同步测试通过。
- [x] 调整前端申请单进度:增加 `关联单据状态``已归档` 节点,并显示 `关联中/未关联`。[CONCEPT: 方案设计] 证据:`useRequests.js` 新增申请单进度节点和关联状态计算。
- [x] 补充前后端回归测试,覆盖未关联、关联中、已归档三类申请单状态。[CONCEPT: 测试方案] 证据:`requestProgressSteps.test.mjs``document-center-archived-scope.test.mjs``expense-claim-archive.test.mjs``test_expense_claim_service.py` 已覆盖。
- [x] 在容器或前端定向测试中完成验证,并记录命令结果。[CONCEPT: 验收标准] 证据:前端 Node 定向测试、容器内 py_compile、状态/路由/归档/付款同步 pytest、`npm.cmd --prefix web run build` 均通过。

View File

@@ -0,0 +1,131 @@
# 费用申请审批财务规则概念文档
## 功能一句话
在财务规则中心新增《公司费用申请审批规则》,统一维护业务招待、办公用品和通用大额费用的事前申请与审批阈值,并让报销风险规则引用该规则执行。
## 背景与问题
现有系统已经有“业务招待无申请”“办公采购无申请”“大额费用无申请”等风险规则,但制度依据主要以风险规则 JSON 的口径字段存在,财务规则中心缺少一张可被制度管理员查看、编辑和追溯的规则表。
用户明确要求:
- 业务招待费超过 500 元需要申请。
- 大额办公用品需要申请。
- 金额超过 2000 元的费用都需要走审批。
- 这些要求最好形成财务规则,而不是散落在代码或前端提示中。
## 目标与非目标
目标:
- 新增一张财务规则资产《公司费用申请审批规则》。
- 规则资产以 Excel 形式进入 `finance-rules` 规则库,并在规则中心按“财务规则”展示。
- 风险规则引用统一的 `finance_rule_code`,不再使用零散口径 code。
- 报销阶段按结构化金额规则判断,而不是只靠关键词命中。
- 关联有效申请单后不触发“缺少申请”风险。
非目标:
- 本轮不新增数据库字段。
- 本轮不新增非本体业务字段。
- 本轮不改造完整审批流节点,只补充申请前置与风险执行依据。
## 用户与场景
- 报销人:上传或录入业务招待、办公用品、大额费用报销时,系统自动识别是否缺少事前申请。
- 直属领导和财务审核人:审核单据时能看到风险来自财务规则。
- 财务制度管理员:能在规则中心看到并维护《公司费用申请审批规则》。
## 功能能力
### 财务规则表
规则表包含以下行:
- 业务招待费:单次费用金额大于 500 元时,必须先提交费用申请单。
- 办公用品费:单次或批量采购金额大于 2000 元时,必须先提交办公采购或费用申请单。
- 通用大额费用:任意费用金额大于 2000 元时,必须进入审批流程。
### 风险规则执行
- `meal``entertainment` 都视为业务招待费。
- `office` 视为办公用品费。
- `all` 视为通用大额费用。
- 报销阶段没有关联有效申请单时,超过阈值命中高风险。
- 已有关联申请单时,不命中缺少申请风险。
## 方案设计
### 后端
-`agent_asset_spreadsheet.py` 中新增费用申请审批规则 code 与文件名常量。
- 在财务规则同步中新增该资产的 metadata、Excel 工作簿生成和版本快照。
- 在初始化和补齐逻辑中创建该财务规则资产,确保老库和新库都能看到。
- 将三条风险规则改为 `composite_rule_v1`,使用金额阈值和申请单存在性执行。
-`risk_rule_template_executor.py` 中补齐 `application.*` 字段解析,桥接现有 `application_link` / `application_handoff` / `application_detail` 风险上下文。
### 前端
本轮不新增前端页面。规则中心已有财务规则和 JSON 风险规则展示能力,后端资产同步后前端可直接展示。
### 数据与本体
本轮只使用现有本体字段:
- `expense_type`
- `amount`
- `reason`
- `application_claim_id`
- `application_claim_no`
- `application_detail`
不新增非本体字段。
## 算法与公式
业务招待费规则:
$$
hit = expenseType \in \{meal, entertainment\} \land amount > 500 \land \neg hasApplication
$$
办公用品规则:
$$
hit = expenseType = office \land amount > 2000 \land \neg hasApplication
$$
通用大额规则:
$$
hit = amount > 2000 \land \neg hasApplication
$$
其中:
- `amount` 来自 `claim.amount`
- `hasApplication` 来自 `application.id``application.claim_no` 或等价申请单上下文。
## 测试方案
- 单元测试:验证 `application.*` 字段能从已有申请关联上下文解析。
- 规则执行测试:超过 500 元业务招待费且无申请命中风险。
- 规则执行测试:超过 2000 元办公用品费且无申请命中风险。
- 规则执行测试:超过 2000 元通用费用且无申请命中风险。
- 规则执行测试:已关联申请单的超额费用不命中缺少申请风险。
- 资产测试:规则中心种子数据包含《公司费用申请审批规则》,且 `config_json.tag` 为“财务规则”。
## 指标与验收
- 财务规则中心能看到新增规则资产。
- 新增资产 `finance_rule_code` 统一为 `expense.preapproval.policy`
- 三条风险规则均引用该财务规则 code。
- 容器内后端定向测试通过。
- 不新增非本体业务字段。
## 风险与开放问题
- “大额办公用品”的金额阈值按用户同句“大额/超过 2000 都需要审批”落为 2000 元。
- 当前申请单上下文主要存在 `risk_flags_json` 的申请关联 flag 中,本轮先补执行器解析,不新增外键字段。
- 后续如果要支持不同部门或不同职级阈值,可以在同一张财务规则表中扩展分档行。

View File

@@ -0,0 +1,23 @@
# 费用申请审批财务规则 TODO
## 调研与契约
- [x] 盘点现有财务规则资产、风险规则 JSON 与规则同步链路。[CONCEPT: 背景与问题] 证据:确认现有 `finance-rules` 仅差旅和通信两张核心规则表,前置申请规则当前在 `risk-rules` 中。
- [x] 明确本轮不新增非本体业务字段。[CONCEPT: 数据与本体] 证据:规则只使用 `expense_type``amount``reason` 和申请单上下文。
## 后端实现
- [x] 新增《公司费用申请审批规则》财务规则资产常量与 Excel 工作簿内容。[CONCEPT: 财务规则表] 证据:`COMPANY_PREAPPROVAL_RULE_CODE``COMPANY_PREAPPROVAL_RULE_FILENAME``_ensure_company_preapproval_rule_spreadsheet_seed()` 已实现。
- [x] 初始化种子和老库补齐逻辑都能创建该财务规则资产。[CONCEPT: 方案设计] 证据:`agent_foundation_asset_seed.py``agent_foundation_asset_topup.py` 均接入该资产。
- [x] 将大额费用、业务招待、办公用品三条前置申请风险规则改为结构化金额判断。[CONCEPT: 风险规则执行] 证据:三条 `risk.application.*without_preapproval.json` 已改为 `composite_rule_v1`
- [x] 补齐 `application.*` 字段解析,支持从现有关联申请上下文判断是否已有申请。[CONCEPT: 后端] 证据:`risk_rule_template_executor.py` 新增 `_resolve_application_values()`
## 测试与验证
- [x] 新增执行器测试:申请单上下文存在时 `application.id` 可解析。[CONCEPT: 测试方案] 证据:`test_application_context_values_are_available_to_composite_rules` 通过。
- [x] 新增风险规则执行测试:业务招待费超过 500 元且无申请命中。[CONCEPT: 测试方案] 证据:`test_preapproval_amount_rules_hit_without_linked_application` 覆盖 meal。
- [x] 新增风险规则执行测试:办公用品超过 2000 元且无申请命中。[CONCEPT: 测试方案] 证据:`test_preapproval_amount_rules_hit_without_linked_application` 覆盖 office。
- [x] 新增风险规则执行测试:通用费用超过 2000 元且无申请命中。[CONCEPT: 测试方案] 证据:`test_preapproval_amount_rules_hit_without_linked_application` 覆盖 software。
- [x] 新增资产同步测试:财务规则中心包含新增规则资产。[CONCEPT: 指标与验收] 证据:`test_finance_rules_use_risk_rule_scenario_categories` 断言新增财务规则资产和规则文档。
- [x] Docker `x-financial-main` 容器内定向测试通过。[CONCEPT: 指标与验收] 证据:新增与相邻回归共 15 个后端测试通过。
- [x] 重启后端并验证运行时健康状态。[CONCEPT: 指标与验收] 证据:`x-financial-main` 已重启并进入 healthy真实库可查到 `rule.expense.company_preapproval_requirement`

View File

@@ -0,0 +1,86 @@
# 附件上传风险前置复核
## 功能一句话
报销附件上传并完成 OCR 识别后立即执行完整风险复核,提交审批时只做轻量最终校验、预算占用和流程流转。
## 背景与问题
当前报销单提交阶段会同步执行较重的风险检查,包括附件风险汇总、差旅规则、场景规则、规则中心风险、历史行为统计和风险观测写入。用户在点击提交后会等待较长时间,容易误认为页面卡住。
风险的主要依据来自已上传票据、OCR 识别结果、费用明细、关联申请单和员工历史行为。这些数据在附件上传完成后已经基本具备,因此完整风险复核应前移到上传完成阶段。
## 目标与非目标
目标:
- 附件上传成功后自动刷新费用明细、附件风险、差旅/场景/规则中心风险和 AI 预审标识。
- 风险复核结果写回 `claim.risk_flags_json`,并持久化规则中心风险观测。
- 提交阶段不再重复跑完整 `_run_ai_submission_review()`
- 提交阶段只保留草稿完整性校验、预算占用、未处理阻断风险判断、状态流转、审计日志和助手会话清理。
非目标:
- 不新增业务字段。
- 不改变现有风险规则语义。
- 不把提交改成真正的后端异步任务队列。
## 用户与场景
- 报销申请人:上传票据后立即看到风险建议和需补充说明,不必等到提交时才发现问题。
- 直属领导和财务人员:收到单据时可看到提交前已生成的风险提示和用户处理结果。
- 系统管理员:风险观测仍可进入后台统计。
## 功能能力
上传完成后:
- 根据 OCR 结果回填费用明细类型、日期、金额、事由等已有字段。
- 刷新附件级 `attachment_analysis` 风险。
- 执行报销级风险复核,并生成 `ai_pre_review` 状态。
- 对规则中心命中的风险写入 `risk_observations`
提交审批时:
- 如果存在高风险且用户未处理,继续阻止提交或要求说明/按职级测算。
- 如果风险已处理,只做预算和流程流转。
- 不再重复生成一套提交阶段风险。
## 方案设计
后端:
-`ExpenseClaimService.upload_claim_item_attachment()`OCR、附件分析和 `_sync_claim_from_items()` 完成后,调用上传后风险复核 helper。
- 新增 helper 复用现有 `_run_ai_submission_review()``_replace_ai_pre_review_flag()`,但保持单据状态为草稿。
- 提交阶段读取既有风险结果,只做最终阻断风险判断,不重复调用 `_run_ai_submission_review()`
前端:
- 继续使用当前附件识别中的状态条。
- 上传完成后通过接口返回的 `claim_risk_flags` 更新 AI 建议区和风险标识。
- 提交时只显示轻量后台提交流程提示。
## 算法与公式
当前功能不涉及新的显式数学公式。风险评分和风险等级沿用现有规则中心、附件分析、差旅政策和风险观测逻辑。
## 测试方案
- 后端单元测试:附件上传后写入 `ai_pre_review``submission_review` 风险。
- 后端单元测试:提交阶段不再调用完整 `_run_ai_submission_review()`
- 后端单元测试:上传后规则中心风险可写入 `risk_observations`
- 前端静态回归:提交确认仍为后台提交,不恢复阻塞弹窗。
- 构建验证:`npm.cmd --prefix web run build`
## 指标与验收
- 上传附件后,接口响应的 `claim_risk_flags` 包含最新复核结果。
- 提交接口耗时不再包含完整风险复核耗时。
- 提交后审批人仍能看到已前置生成的风险提示。
- 后端和前端相关回归测试通过。
## 风险与开放问题
- 如果用户上传后又修改费用明细,现有 `update_claim_item()` 需要继续刷新附件风险和报销级风险。
- 如果用户没有上传附件直接提交,提交阶段仍需要保留兜底风险复核或阻断提示。
- 未来可进一步把上传后复核做成真正后台任务,但本次先保持同步接口返回最新风险结果。

View File

@@ -0,0 +1,28 @@
# 附件上传风险前置复核 TODO
## 调研与契约
- [x] 盘点附件上传、预审、提交链路,确认完整风险复核当前在提交阶段重复执行。[CONCEPT: 背景与问题]
- [x] 明确上传后复核 helper 的输入输出契约,不新增业务字段。[CONCEPT: 方案设计] 证据:新增 `_refresh_claim_pre_review_flags()` 复用现有风险字段。
## 后端实现
- [x] 在附件上传完成后触发报销级风险复核,并保持单据状态为草稿。[CONCEPT: 功能能力] 证据:`upload_claim_item_attachment()` 调用 `_refresh_claim_pre_review_flags()`
- [x] 上传后风险复核写回 `ai_pre_review``submission_review` 风险结果。[CONCEPT: 功能能力] 证据:`test_upload_attachment_refreshes_claim_pre_review` 通过。
- [x] 规则中心风险在上传后写入 `risk_observations`,避免提交阶段集中写入。[CONCEPT: 方案设计] 证据:上传后复核复用 `_run_ai_submission_review()`,平台风险仍调用 `RiskObservationService.upsert_platform_risk_flags()`
- [x] 提交阶段改为读取既有风险结果,只做最终校验、预算占用和流转。[CONCEPT: 目标与非目标] 证据:`submit_claim()` 仅在缺少 `ai_pre_review` 时兜底复核。
- [x] 保留“无附件直接提交”的兜底检查,避免绕过风险复核。[CONCEPT: 风险与开放问题] 证据:`test_submit_claim_runs_ai_review_and_routes_to_direct_manager` 通过。
## 前端实现
- [x] 确认上传完成后 UI 使用接口返回的 `claim_risk_flags` 刷新 AI 建议与行风险标识。[CONCEPT: 前端] 证据:`travel-request-detail-risk-advice.test.mjs` 通过。
- [x] 确认提交阶段不恢复阻塞弹窗,只显示轻量后台提交提示。[CONCEPT: 前端] 证据:`travel-request-detail-submit-confirm.test.mjs` 通过。
## 测试与验证
- [x] 后端测试:附件上传后自动生成预审风险结果。[CONCEPT: 测试方案] 证据:`test_upload_attachment_refreshes_claim_pre_review` 通过。
- [x] 后端测试:提交阶段不重复调用完整风险复核。[CONCEPT: 测试方案] 证据:`test_submit_claim_reuses_upload_pre_review_without_rerunning_review` 通过。
- [x] 后端测试:风险观测仍被持久化。[CONCEPT: 测试方案] 证据:`test_risk_observation_storage_ready_is_cached_per_bind` 通过。
- [x] 前端回归测试通过。[CONCEPT: 测试方案] 证据54 个详情页风险/提交测试通过。
- [x] `npm.cmd --prefix web run build` 通过。[CONCEPT: 测试方案] 证据:前端生产构建通过,仅保留既有 Rollup 注释与 chunk size 警告。
- [x] Docker `x-financial-main` 容器内后端定向测试通过。[CONCEPT: 测试方案] 证据:核心上传前置复核、提交复用预审、申请/报销风险回归测试通过。

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

View File

@@ -0,0 +1,724 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>X-Financial Expense 启动页素材系统</title>
<style>
:root {
--bg: #edf2f0;
--ink: #0f172a;
--muted: #64748b;
--surface: #ffffff;
--line: #d8e1e8;
--primary: #006b5e;
--primary-2: #0f766e;
--primary-dark: #061416;
--mint: #e1f6f1;
--gold: #f5a524;
--gold-soft: #fff2cf;
--blue: #2563eb;
--blue-soft: #e7efff;
--paper: #f8fafc;
--shadow: 0 24px 70px rgba(15, 23, 42, 0.14);
}
* { box-sizing: border-box; }
body {
margin: 0;
color: var(--ink);
background:
linear-gradient(90deg, rgba(15, 23, 42, 0.045) 1px, transparent 1px),
linear-gradient(rgba(15, 23, 42, 0.045) 1px, transparent 1px),
var(--bg);
background-size: 28px 28px;
font-family: "IBM Plex Sans", "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", sans-serif;
letter-spacing: 0;
}
.wrap {
max-width: 1500px;
margin: 0 auto;
padding: 34px 30px 70px;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 22px;
margin-bottom: 24px;
}
.panel {
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 8px;
background: rgba(255,255,255,0.86);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
}
.hero-main {
min-height: 260px;
padding: 30px;
display: grid;
align-content: center;
}
.kicker {
width: fit-content;
min-height: 28px;
padding: 5px 10px;
border-radius: 999px;
border: 1px solid rgba(0, 107, 94, 0.18);
color: var(--primary);
background: var(--mint);
font-size: 12px;
font-weight: 900;
}
h1 {
max-width: 760px;
margin: 14px 0 10px;
font-size: 36px;
line-height: 1.12;
letter-spacing: 0;
}
.hero p,
.section p {
max-width: 860px;
margin: 0;
color: var(--muted);
font-size: 14px;
line-height: 1.62;
font-weight: 650;
}
.hero-side {
padding: 18px;
display: grid;
gap: 10px;
align-content: center;
}
.checkline {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
gap: 10px;
align-items: center;
min-height: 58px;
padding: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
}
.checkline strong {
display: block;
font-size: 13px;
margin-bottom: 2px;
}
.checkline span {
display: block;
color: var(--muted);
font-size: 12px;
line-height: 1.35;
font-weight: 650;
}
.icon {
width: 34px;
height: 34px;
border-radius: 8px;
display: grid;
place-items: center;
color: var(--primary);
background: var(--mint);
}
.icon.gold { color: #8a5a00; background: var(--gold-soft); }
.icon.blue { color: var(--blue); background: var(--blue-soft); }
.grid {
display: grid;
grid-template-columns: 420px minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.section {
padding: 20px;
margin-bottom: 24px;
}
.section h2 {
margin: 0 0 6px;
font-size: 20px;
letter-spacing: 0;
}
.phone {
width: 316px;
height: 684px;
margin: 0 auto;
padding: 9px;
border-radius: 34px;
background: #101827;
box-shadow: var(--shadow);
}
.screen {
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
border-radius: 27px;
color: #fff;
background: var(--primary-dark);
}
.status {
display: flex;
justify-content: space-between;
align-items: center;
height: 30px;
padding: 0 16px;
font-size: 11px;
font-weight: 900;
}
.sys { display: flex; gap: 5px; align-items: center; }
.sig, .wifi, .bat { display: inline-block; border: 1.7px solid currentColor; }
.sig { width: 12px; height: 10px; border-top: 0; border-left: 0; transform: skew(-8deg); }
.wifi { width: 13px; height: 8px; border-bottom: 0; border-left-color: transparent; border-right-color: transparent; border-radius: 14px 14px 0 0; }
.bat { width: 18px; height: 10px; border-radius: 2px; background: linear-gradient(90deg, currentColor 68%, transparent 68%); }
.splash {
position: relative;
height: calc(100% - 30px);
padding: 48px 16px 22px;
display: flex;
flex-direction: column;
isolation: isolate;
}
.splash::before {
content: "";
position: absolute;
inset: -34px -18px auto;
height: 320px;
z-index: -1;
background:
radial-gradient(circle at 28% 18%, rgba(245, 165, 36, 0.18), transparent 26%),
linear-gradient(135deg, rgba(255,255,255,0.12), transparent 34%),
linear-gradient(90deg, rgba(255,255,255,0.07) 1px, transparent 1px),
linear-gradient(rgba(255,255,255,0.07) 1px, transparent 1px);
background-size: auto, auto, 24px 24px, 24px 24px;
}
.topline {
display: flex;
justify-content: space-between;
color: rgba(255,255,255,0.68);
font-size: 10px;
font-weight: 900;
letter-spacing: 0.08em;
}
.lockup {
display: grid;
justify-items: center;
gap: 15px;
margin-top: 42px;
text-align: center;
}
.brand-mark {
position: relative;
display: grid;
place-items: center;
width: 74px;
height: 74px;
border-radius: 22px;
background: linear-gradient(145deg, #0b3b36, #00806f);
box-shadow: 0 24px 58px rgba(0,0,0,0.36), 0 0 0 1px rgba(255,255,255,0.16) inset;
}
.brand-mark::before,
.brand-mark::after {
content: "";
position: absolute;
width: 20px;
height: 42px;
border-radius: 999px;
background: rgba(255,255,255,0.92);
transform: rotate(28deg);
}
.brand-mark::before { left: 20px; top: 15px; }
.brand-mark::after { right: 20px; bottom: 15px; opacity: 0.72; }
.splash-name {
font-size: 25px;
line-height: 1.05;
font-weight: 950;
}
.splash-tag {
max-width: 230px;
margin-top: 8px;
color: rgba(255,255,255,0.68);
font-size: 12px;
line-height: 1.48;
font-weight: 750;
}
.scene {
position: relative;
height: 252px;
margin-top: 34px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.13);
overflow: hidden;
background:
linear-gradient(90deg, rgba(255,255,255,0.07) 1px, transparent 1px),
linear-gradient(rgba(255,255,255,0.07) 1px, transparent 1px),
linear-gradient(145deg, #062425, #0a5f56 58%, #103a36);
background-size: 22px 22px, 22px 22px, auto;
box-shadow: 0 22px 50px rgba(0,0,0,0.34);
}
.device {
position: absolute;
left: 18px;
top: 28px;
width: 128px;
height: 188px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.2);
background: rgba(3, 21, 24, 0.72);
box-shadow: 0 18px 36px rgba(0,0,0,0.28);
}
.device span {
display: block;
height: 8px;
margin: 8px 14px;
border-radius: 999px;
background: rgba(255,255,255,0.2);
}
.device span:first-child {
width: 72%;
margin-top: 26px;
background: rgba(255,255,255,0.78);
}
.amount {
position: absolute;
left: 14px;
right: 14px;
bottom: 18px;
padding: 11px;
border-radius: 8px;
background: rgba(255,255,255,0.08);
font-size: 19px;
font-weight: 950;
}
.amount small {
display: block;
margin-bottom: 3px;
color: rgba(255,255,255,0.52);
font-size: 9px;
font-weight: 900;
letter-spacing: 0.05em;
}
.asset-card {
position: absolute;
right: 15px;
top: 42px;
width: 156px;
padding: 13px;
border-radius: 8px;
background: rgba(255,255,255,0.94);
color: var(--ink);
box-shadow: 0 18px 42px rgba(0,0,0,0.28);
}
.asset-card.secondary {
top: 132px;
right: 34px;
width: 174px;
}
.chip {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 3px 9px;
border-radius: 999px;
color: #8a5a00;
background: var(--gold-soft);
font-size: 11px;
font-weight: 950;
}
.asset-card h3 {
margin: 8px 0 4px;
font-size: 15px;
line-height: 1.18;
}
.asset-card p {
margin: 0;
color: var(--muted);
font-size: 11px;
line-height: 1.4;
font-weight: 750;
}
.float-pill {
position: absolute;
left: 18px;
bottom: 18px;
min-height: 32px;
padding: 7px 10px;
border-radius: 999px;
color: #d7fff7;
background: rgba(0, 91, 79, 0.75);
border: 1px solid rgba(255,255,255,0.12);
font-size: 11px;
font-weight: 950;
}
.splash-footer {
margin-top: auto;
display: grid;
gap: 11px;
text-align: center;
}
.progress {
width: 118px;
height: 4px;
margin: 0 auto;
border-radius: 999px;
background: rgba(255,255,255,0.14);
overflow: hidden;
}
.progress::after {
content: "";
display: block;
width: 66%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #dffcf4, var(--gold));
}
.small-copy {
color: rgba(255,255,255,0.68);
font-size: 12px;
font-weight: 750;
}
.asset-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.asset {
min-height: 138px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
display: grid;
align-content: space-between;
gap: 12px;
}
.asset.dark {
color: #fff;
background: var(--primary-dark);
border-color: rgba(255,255,255,0.16);
}
.asset-title {
font-size: 13px;
font-weight: 950;
}
.asset-meta {
color: var(--muted);
font-size: 11px;
line-height: 1.4;
font-weight: 700;
}
.asset.dark .asset-meta { color: rgba(255,255,255,0.62); }
.app-icons {
display: flex;
gap: 14px;
align-items: end;
margin-top: 16px;
}
.app-icon {
position: relative;
border-radius: 22%;
background:
linear-gradient(145deg, #0b3b36, #00806f);
box-shadow: 0 16px 38px rgba(0, 91, 79, 0.26);
}
.app-icon::before,
.app-icon::after {
content: "";
position: absolute;
border-radius: 999px;
background: rgba(255,255,255,0.92);
transform: rotate(28deg);
}
.app-icon::before { left: 27%; top: 20%; width: 22%; height: 56%; }
.app-icon::after { right: 27%; bottom: 20%; width: 22%; height: 56%; opacity: 0.72; }
.app-icon.lg { width: 96px; height: 96px; }
.app-icon.md { width: 72px; height: 72px; }
.app-icon.sm { width: 48px; height: 48px; }
.spec-list {
display: grid;
gap: 10px;
margin-top: 16px;
}
.spec {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
font-size: 13px;
}
.spec strong {
min-width: 0;
font-weight: 950;
overflow-wrap: anywhere;
}
.spec span {
min-width: 0;
color: var(--muted);
line-height: 1.45;
font-weight: 650;
overflow-wrap: anywhere;
}
.motion {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.frame {
padding: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
}
.frame strong {
display: block;
margin-bottom: 6px;
font-size: 13px;
}
.frame span {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
font-weight: 650;
}
.mini-scene {
height: 72px;
margin-bottom: 10px;
border-radius: 8px;
background:
linear-gradient(90deg, rgba(255,255,255,0.08) 1px, transparent 1px),
linear-gradient(rgba(255,255,255,0.08) 1px, transparent 1px),
linear-gradient(145deg, #062425, #0a5f56);
background-size: 18px 18px, 18px 18px, auto;
}
.color-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
margin-top: 16px;
}
.swatch {
min-height: 76px;
padding: 10px;
border-radius: 8px;
border: 1px solid rgba(15,23,42,0.1);
display: grid;
align-content: end;
color: #fff;
font-size: 11px;
font-weight: 900;
}
.swatch.light { color: var(--ink); }
@media (max-width: 1180px) {
.hero,
.grid { grid-template-columns: 1fr; }
.phone { margin-left: 0; }
}
@media (max-width: 760px) {
.wrap { padding: 22px 12px 40px; }
h1 { font-size: 28px; }
.asset-grid,
.motion,
.color-row { grid-template-columns: 1fr; }
.spec { grid-template-columns: 1fr; }
.phone { width: min(316px, 100%); }
}
</style>
</head>
<body>
<main class="wrap">
<section class="hero">
<div class="panel hero-main">
<span class="kicker">Splash asset system</span>
<h1>X-Financial Expense 启动页素材系统</h1>
<p>这页专门给 Android 开机页使用,不直接复用业务页面卡片。素材分成品牌锁定、深色背景、票据场景、启动动效和 Android 落地规格,后续开发 Compose 或原生启动页时按这里取值。</p>
</div>
<aside class="panel hero-side">
<div class="checkline"><div class="icon">01</div><div><strong>启动页先看品牌</strong><span>Logo、产品名和安全工作台定位占主导不放登录表单。</span></div></div>
<div class="checkline"><div class="icon gold">02</div><div><strong>业务只做隐喻</strong><span>票据、审批、金额作为启动素材出现,不承载真实操作。</span></div></div>
<div class="checkline"><div class="icon blue">03</div><div><strong>可直接落 Android</strong><span>提供颜色、图标尺寸、动效节奏和 SplashScreen API 参数。</span></div></div>
</aside>
</section>
<section class="grid">
<div class="panel section">
<h2>启动页预览</h2>
<p>用于 9:16 Android 手机首屏,登录态检查完成后跳转登录页或首页。</p>
<div style="height:16px"></div>
<div class="phone">
<div class="screen">
<div class="status"><span>9:41</span><span class="sys"><i class="sig"></i><i class="wifi"></i><i class="bat"></i></span></div>
<div class="splash">
<div class="topline"><span>SECURE EXPENSE</span><span>ANDROID</span></div>
<div class="lockup">
<div class="brand-mark"></div>
<div>
<div class="splash-name">X-Financial<br/>Expense</div>
<div class="splash-tag">出差申请、票据采集、报销提交、移动审批</div>
</div>
</div>
<div class="scene">
<div class="device">
<span></span><span></span><span></span>
<div class="amount"><small>本月待处理</small>¥ 3,280</div>
</div>
<div class="asset-card">
<span class="chip">AI 识别</span>
<h3>票据已归类</h3>
<p>5 张票据 · 2 项待补</p>
</div>
<div class="asset-card secondary">
<h3>审批流已同步</h3>
<p>财务复核中 · 待提醒</p>
</div>
<div class="float-pill">拍照上传</div>
</div>
<div class="splash-footer">
<div class="progress"></div>
<div class="small-copy">正在加载安全工作台</div>
</div>
</div>
</div>
</div>
</div>
<div>
<section class="panel section">
<h2>品牌与 App 图标</h2>
<p>App 图标保留双斜票据形态,避免直接使用文字。启动页 Logo 使用 74dp 等比缩放,图标安全区不低于 12dp。</p>
<div class="app-icons">
<div class="app-icon lg"></div>
<div class="app-icon md"></div>
<div class="app-icon sm"></div>
</div>
<div class="asset-grid">
<div class="asset"><div class="brand-mark" style="width:58px;height:58px;border-radius:17px"></div><div><div class="asset-title">启动 Logo</div><div class="asset-meta">74dp / 58dp / 44dp 三档,深色背景使用。</div></div></div>
<div class="asset dark"><div class="app-icon md"></div><div><div class="asset-title">Launcher Icon</div><div class="asset-meta">前景图形居中,保留 Android 自适应图标裁切空间。</div></div></div>
<div class="asset"><div class="chip">AI 识别</div><div><div class="asset-title">业务状态 Chip</div><div class="asset-meta">仅作为视觉线索,不做主操作入口。</div></div></div>
</div>
</section>
<section class="panel section">
<h2>颜色与背景</h2>
<p>启动页走深色金融安全感。绿色做品牌,金色只用于识别和进度强调,蓝色用于信息状态。</p>
<div class="color-row">
<div class="swatch" style="background:#061416">#061416<br/>启动底色</div>
<div class="swatch" style="background:#006b5e">#006B5E<br/>品牌主色</div>
<div class="swatch" style="background:#0f766e">#0F766E<br/>场景渐变</div>
<div class="swatch" style="background:#f5a524;color:#251500">#F5A524<br/>识别强调</div>
<div class="swatch light" style="background:#f8fafc">#F8FAFC<br/>票据纸面</div>
</div>
</section>
<section class="panel section">
<h2>素材拆分</h2>
<p>开发落地时建议拆成 5 个可复用素材层Compose 中可分别写成 Composable。</p>
<div class="asset-grid">
<div class="asset dark"><div class="mini-scene"></div><div><div class="asset-title">背景层</div><div class="asset-meta">深色底、细网格、顶部柔光,静态即可。</div></div></div>
<div class="asset"><div class="asset-card" style="position:static;width:auto;box-shadow:none;border:1px solid var(--line)"><span class="chip">AI 识别</span><h3>票据已归类</h3><p>5 张票据 · 2 项待补</p></div><div><div class="asset-title">票据层</div><div class="asset-meta">表达拍照上传和 OCR不显示真实敏感票据信息。</div></div></div>
<div class="asset"><div class="float-pill" style="position:static;color:#006b5e;background:var(--mint);border:0;width:max-content">拍照上传</div><div><div class="asset-title">状态层</div><div class="asset-meta">启动时轻提示900ms 后淡出。</div></div></div>
</div>
</section>
<section class="panel section">
<h2>启动动效节奏</h2>
<p>控制在 900ms 左右,不阻塞用户进入登录或首页。系统检测慢时只延长进度条,不重复播放动画。</p>
<div class="motion">
<div class="frame"><div class="mini-scene"></div><strong>0ms</strong><span>深色底和网格先出现,避免白屏。</span></div>
<div class="frame"><div class="mini-scene"></div><strong>160ms</strong><span>Logo scale 0.92 到 1透明度进入。</span></div>
<div class="frame"><div class="mini-scene"></div><strong>320ms</strong><span>产品名和定位文案淡入。</span></div>
<div class="frame"><div class="mini-scene"></div><strong>520ms</strong><span>票据场景上移 8dp进度条启动。</span></div>
</div>
</section>
<section class="panel section">
<h2>Android 落地规格</h2>
<p>先走 Android 原生启动,再进入 Compose 首页。冷启动用系统 SplashScreen业务素材用于第一个 Activity 的过渡页。</p>
<div class="spec-list">
<div class="spec"><strong>windowSplashScreenBackground</strong><span>#061416。系统启动页避免白屏和业务过渡页底色一致。</span></div>
<div class="spec"><strong>windowSplashScreenAnimatedIcon</strong><span>只放 App 图标前景,不放产品文字,防止小屏裁切。</span></div>
<div class="spec"><strong>过渡页时长</strong><span>登录态检查完成即跳转;正常 600-900ms异常最长 1800ms 后进入登录页。</span></div>
<div class="spec"><strong>Compose 拆分</strong><span>SplashBackground、BrandLockup、ExpenseScene、LoadingRail 四个组件。</span></div>
<div class="spec"><strong>无障碍</strong><span>启动页只设置应用名称 contentDescription业务装饰图不参与朗读。</span></div>
</div>
</section>
</div>
</section>
</main>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

39
remove_bg.py Normal file
View File

@@ -0,0 +1,39 @@
import sys
from PIL import Image
def remove_white_bg(input_path, output_path, threshold=235):
img = Image.open(input_path).convert("RGBA")
data = img.getdata()
new_data = []
for item in data:
r, g, b, a = item
avg = (r + g + b) / 3.0
# If it's very close to white, make it transparent
if avg > threshold and min(r,g,b) > threshold - 10:
# Feathering the alpha channel
# 255 = fully transparent
# threshold = fully opaque
factor = (avg - threshold) / (255 - threshold)
alpha = int(255 * (1 - factor))
# Clamp alpha
alpha = max(0, min(255, alpha))
# We keep the pixel white to avoid dark fringes, but lower its opacity
new_data.append((255, 255, 255, alpha))
else:
new_data.append(item)
img.putdata(new_data)
# Optional: crop the image to its bounding box
bbox = img.getbbox()
if bbox:
img = img.crop(bbox)
img.save(output_path, "PNG")
if __name__ == "__main__":
remove_white_bg(sys.argv[1], sys.argv[2])

45
remove_bg_fast.ps1 Normal file
View File

@@ -0,0 +1,45 @@
$code = @"
using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
public class ImageProcessor {
public static void RemoveWhiteBg(string inputFile, string outputFile, int threshold) {
Bitmap bmp = new Bitmap(inputFile);
Rectangle rect = new Rectangle(0, 0, bmp.Width, bmp.Height);
BitmapData bmpData = bmp.LockBits(rect, ImageLockMode.ReadWrite, PixelFormat.Format32bppArgb);
int bytes = Math.Abs(bmpData.Stride) * bmp.Height;
byte[] rgbValues = new byte[bytes];
Marshal.Copy(bmpData.Scan0, rgbValues, 0, bytes);
for (int i = 0; i < rgbValues.Length; i += 4) {
byte b = rgbValues[i];
byte g = rgbValues[i + 1];
byte r = rgbValues[i + 2];
byte a = rgbValues[i + 3];
float avg = (r + g + b) / 3f;
if (avg > threshold && r > threshold - 10 && g > threshold - 10 && b > threshold - 10) {
float factor = (avg - threshold) / (255f - threshold);
int alpha = (int)(255 * (1 - factor));
if (alpha < 0) alpha = 0;
if (alpha > 255) alpha = 255;
rgbValues[i] = 255;
rgbValues[i + 1] = 255;
rgbValues[i + 2] = 255;
rgbValues[i + 3] = (byte)alpha;
}
}
Marshal.Copy(rgbValues, 0, bmpData.Scan0, bytes);
bmp.UnlockBits(bmpData);
bmp.Save(outputFile, ImageFormat.Png);
bmp.Dispose();
}
}
"@
Add-Type -TypeDefinition $code -ReferencedAssemblies System.Drawing
[ImageProcessor]::RemoveWhiteBg("d:\Code\Project\X-Financial\web\src\assets\images\raw_documents.png", "d:\Code\Project\X-Financial\web\src\assets\images\hero-financial-decor.png", 235)
Write-Host "Done"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,17 +1,19 @@
{
"schema_version": "2.0",
"rule_code": "risk.application.large_expense_without_preapproval",
"name": "大额费用未事前申请",
"description": "达到财务制度中大额标准的费用,未找到有效事前申请即进入报销。",
"name": "通用大额费用无前置申请",
"description": "非业务招待、非办公用品的通用费用超过 2000 元且缺少关联费用申请。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "申请前置",
"ontology_signal": "application_required",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "finance.preapproval.policy",
"finance_rule_sheet": "费用申请前置规则",
"template_key": "composite_rule_v1",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [
"reimbursement"
],
@@ -58,13 +60,19 @@
},
{
"key": "item.item_reason",
"label": "明细说明",
"label": "明细事由",
"type": "text",
"source": "item"
},
{
"key": "application.id",
"label": "申请单",
"label": "申请单ID",
"type": "text",
"source": "application"
},
{
"key": "application.claim_no",
"label": "申请单号",
"type": "text",
"source": "application"
},
@@ -95,7 +103,8 @@
]
},
"params": {
"template_key": "keyword_match_v1",
"template_key": "composite_rule_v1",
"semantic_type": "preapproval_required_amount_threshold",
"field_keys": [
"claim.amount",
"claim.expense_type",
@@ -103,31 +112,99 @@
"claim.reason",
"item.item_reason",
"application.id",
"application.claim_no",
"application.status",
"application.approved_amount",
"application.expense_type",
"application.department_name"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
"conditions": [
{
"id": "amount_exceeds_preapproval_threshold",
"operator": "numeric_compare",
"left_fields": [
"claim.amount"
],
"threshold": 2000,
"compare": "gt"
},
{
"id": "application_present",
"operator": "exists_any",
"fields": [
"application.id",
"application.claim_no"
]
},
{
"id": "not_specific_preapproval_type",
"operator": "not_contains_any",
"fields": [
"claim.expense_type"
],
"keywords": [
"meal",
"entertainment",
"office",
"业务招待",
"招待",
"办公用品",
"办公"
]
}
],
"keywords": [
"大额费用",
"未申请",
"先申请后报销"
],
"condition_summary": "金额达到大额阈值且缺少已通过申请单时触发。",
"finance_rule_code": "finance.preapproval.policy",
"finance_rule_sheet": "费用申请前置规则",
"hit_logic": {
"all": [
"amount_exceeds_preapproval_threshold",
{
"not": "application_present"
},
"not_specific_preapproval_type"
]
},
"formula": "amount > threshold AND NOT hasApplication",
"condition_summary": "非业务招待、非办公用品的通用费用超过 2000 元且未关联费用申请时触发。",
"message_template": "通用大额费用超过 2000 元但未找到关联费用申请,请补充前置申请或审批说明。",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true
"budget_required": true,
"threshold_amount": 2000,
"rule_ir": {
"facts": [
{
"id": "A",
"label": "报销金额",
"fields": [
"claim.amount"
]
},
{
"id": "B",
"label": "关联申请",
"fields": [
"application.id",
"application.claim_no"
]
}
],
"hit_logic": "A > threshold AND NOT EXISTS(B)"
},
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
},
"outcomes": {
"pass": {
@@ -141,25 +218,43 @@
}
},
"metadata": {
"owner": "风控与审计部",
"owner": "财务制度管理组",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则",
"created_at": "2026-05-31T00:10:41.805274+00:00",
"source_ref": "公司费用申请审批规则",
"created_at": "2026-06-05T00:00:00+08:00",
"created_by": "system",
"risk_score": 86,
"risk_level": "high",
"rule_title": "大额费用未事前申请",
"finance_rule_code": "finance.preapproval.policy",
"finance_rule_sheet": "费用申请前置规则",
"rule_title": "通用大额费用无前置申请",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true
"budget_required": true,
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
},
"severity": "high",
"risk_score": 86,
"risk_level": "high"
"risk_level": "high",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
}

View File

@@ -1,22 +1,25 @@
{
"schema_version": "2.0",
"rule_code": "risk.application.meal_high_value_without_preapproval",
"name": "大额业务招待申请",
"description": "业务招待金额或人均金额超过制度阈值但未事前审批。",
"name": "业务招待高金额无前置申请",
"description": "业务招待费超过 500 元且缺少关联费用申请或审批记录。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "申请前置",
"ontology_signal": "application_required",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "expense.application.policy",
"finance_rule_sheet": "费用申请前置规则",
"template_key": "composite_rule_v1",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"meal"
"meal",
"entertainment"
],
"budget_required": true,
"applies_to": {
@@ -24,7 +27,8 @@
"expense"
],
"expense_types": [
"meal"
"meal",
"entertainment"
],
"business_stages": [
"reimbursement"
@@ -58,13 +62,19 @@
},
{
"key": "item.item_reason",
"label": "明细说明",
"label": "明细事由",
"type": "text",
"source": "item"
},
{
"key": "application.id",
"label": "申请单",
"label": "申请单ID",
"type": "text",
"source": "application"
},
{
"key": "application.claim_no",
"label": "申请单号",
"type": "text",
"source": "application"
},
@@ -91,17 +101,12 @@
"label": "申请部门",
"type": "text",
"source": "application"
},
{
"key": "material.attendee_list_uploaded",
"label": "参与人清单已上传",
"type": "boolean",
"source": "material"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"template_key": "composite_rule_v1",
"semantic_type": "preapproval_required_amount_threshold",
"field_keys": [
"claim.amount",
"claim.expense_type",
@@ -109,32 +114,83 @@
"claim.reason",
"item.item_reason",
"application.id",
"application.claim_no",
"application.status",
"application.approved_amount",
"application.expense_type",
"application.department_name",
"material.attendee_list_uploaded"
"application.department_name"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
"conditions": [
{
"id": "amount_exceeds_preapproval_threshold",
"operator": "numeric_compare",
"left_fields": [
"claim.amount"
],
"threshold": 500,
"compare": "gt"
},
{
"id": "application_present",
"operator": "exists_any",
"fields": [
"application.id",
"application.claim_no"
]
}
],
"keywords": [
"业务招待",
"人均超标",
"未申请"
],
"condition_summary": "业务招待金额超过申请阈值且没有通过申请时触发。",
"finance_rule_code": "expense.application.policy",
"finance_rule_sheet": "费用申请前置规则",
"hit_logic": {
"all": [
"amount_exceeds_preapproval_threshold",
{
"not": "application_present"
}
]
},
"formula": "amount > threshold AND NOT hasApplication",
"condition_summary": "业务招待费超过 500 元且未关联已审批费用申请时触发。",
"message_template": "业务招待费超过 500 元但未找到关联费用申请,请补充前置申请或审批说明。",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"meal"
"meal",
"entertainment"
],
"budget_required": true
"budget_required": true,
"threshold_amount": 500,
"rule_ir": {
"facts": [
{
"id": "A",
"label": "报销金额",
"fields": [
"claim.amount"
]
},
{
"id": "B",
"label": "关联申请",
"fields": [
"application.id",
"application.claim_no"
]
}
],
"hit_logic": "A > threshold AND NOT EXISTS(B)"
},
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
},
"outcomes": {
"pass": {
@@ -144,29 +200,48 @@
"fail": {
"severity": "high",
"action": "manual_review",
"risk_score": 84
"risk_score": 88
}
},
"metadata": {
"owner": "风控与审计部",
"owner": "财务制度管理组",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则",
"created_at": "2026-05-31T00:10:41.818641+00:00",
"source_ref": "公司费用申请审批规则",
"created_at": "2026-06-05T00:00:00+08:00",
"created_by": "system",
"risk_score": 84,
"risk_score": 88,
"risk_level": "high",
"rule_title": "大额业务招待申请",
"finance_rule_code": "expense.application.policy",
"finance_rule_sheet": "费用申请前置规则",
"rule_title": "业务招待高金额无前置申请",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"meal"
"meal",
"entertainment"
],
"budget_required": true
"budget_required": true,
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
},
"severity": "high",
"risk_score": 84,
"risk_level": "high"
"risk_score": 88,
"risk_level": "high",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
}

View File

@@ -1,17 +1,19 @@
{
"schema_version": "2.0",
"rule_code": "risk.application.office_bulk_without_purchase",
"name": "办公用品大额采购未申请",
"description": "批量办公用品或设备采购达到阈值但未走采购申请。",
"name": "办公用品批量采购无前置申请",
"description": "办公用品或办公采购费用超过 2000 元且缺少关联费用申请或采购审批。",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "申请前置",
"ontology_signal": "application_required",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "expense.application.policy",
"finance_rule_sheet": "费用申请前置规则",
"template_key": "composite_rule_v1",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [
"reimbursement"
],
@@ -58,13 +60,19 @@
},
{
"key": "item.item_reason",
"label": "明细说明",
"label": "明细事由",
"type": "text",
"source": "item"
},
{
"key": "application.id",
"label": "申请单",
"label": "申请单ID",
"type": "text",
"source": "application"
},
{
"key": "application.claim_no",
"label": "申请单号",
"type": "text",
"source": "application"
},
@@ -95,7 +103,8 @@
]
},
"params": {
"template_key": "keyword_match_v1",
"template_key": "composite_rule_v1",
"semantic_type": "preapproval_required_amount_threshold",
"field_keys": [
"claim.amount",
"claim.expense_type",
@@ -103,31 +112,82 @@
"claim.reason",
"item.item_reason",
"application.id",
"application.claim_no",
"application.status",
"application.approved_amount",
"application.expense_type",
"application.department_name"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
"conditions": [
{
"id": "amount_exceeds_preapproval_threshold",
"operator": "numeric_compare",
"left_fields": [
"claim.amount"
],
"threshold": 2000,
"compare": "gt"
},
{
"id": "application_present",
"operator": "exists_any",
"fields": [
"application.id",
"application.claim_no"
]
}
],
"keywords": [
"办公采购",
"大额办公用品",
"采购申请"
],
"condition_summary": "办公用品单次金额达到采购阈值且缺少采购申请时触发。",
"finance_rule_code": "expense.application.policy",
"finance_rule_sheet": "费用申请前置规则",
"hit_logic": {
"all": [
"amount_exceeds_preapproval_threshold",
{
"not": "application_present"
}
]
},
"formula": "amount > threshold AND NOT hasApplication",
"condition_summary": "办公用品费用超过 2000 元且未关联费用申请或采购审批时触发。",
"message_template": "办公用品费用超过 2000 元但未找到关联费用申请,请补充采购申请或审批说明。",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"office"
],
"budget_required": true
"budget_required": true,
"threshold_amount": 2000,
"rule_ir": {
"facts": [
{
"id": "A",
"label": "报销金额",
"fields": [
"claim.amount"
]
},
{
"id": "B",
"label": "关联申请",
"fields": [
"application.id",
"application.claim_no"
]
}
],
"hit_logic": "A > threshold AND NOT EXISTS(B)"
},
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
},
"outcomes": {
"pass": {
@@ -135,31 +195,49 @@
"action": "continue"
},
"fail": {
"severity": "medium",
"severity": "high",
"action": "manual_review",
"risk_score": 78
"risk_score": 84
}
},
"metadata": {
"owner": "风控与审计部",
"owner": "财务制度管理组",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则",
"created_at": "2026-05-31T00:10:41.811910+00:00",
"source_ref": "公司费用申请审批规则",
"created_at": "2026-06-05T00:00:00+08:00",
"created_by": "system",
"risk_score": 78,
"risk_level": "medium",
"rule_title": "办公用品大额采购未申请",
"finance_rule_code": "expense.application.policy",
"finance_rule_sheet": "费用申请前置规则",
"risk_score": 84,
"risk_level": "high",
"rule_title": "办公用品批量采购无前置申请",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"office"
],
"budget_required": true
"budget_required": true,
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
},
"severity": "medium",
"risk_score": 78,
"risk_level": "medium"
"severity": "high",
"risk_score": 84,
"risk_level": "high",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
}

View File

@@ -10,8 +10,8 @@
"ontology_signal": "application_required",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"business_stage": [
"reimbursement"
],
@@ -119,15 +119,55 @@
"未申请"
],
"condition_summary": "差旅金额达到大额阈值且缺少有效出差申请时触发。",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"travel"
],
"budget_required": true
"budget_required": true,
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
},
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
},
{
"code": "rule.expense.company_travel_transport_estimate",
"sheet": "交通费用预估表",
"name": "交通费用预估表",
"component": "transport_estimate"
},
{
"code": "rule.expense.company_travel_allowance_reimbursement",
"sheet": "出差补助标准",
"name": "出差补助报销标准",
"component": "allowance"
}
]
},
"outcomes": {
"pass": {
@@ -149,17 +189,97 @@
"risk_score": 82,
"risk_level": "high",
"rule_title": "大额差旅未申请",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"travel"
],
"budget_required": true
"budget_required": true,
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
},
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
},
{
"code": "rule.expense.company_travel_transport_estimate",
"sheet": "交通费用预估表",
"name": "交通费用预估表",
"component": "transport_estimate"
},
{
"code": "rule.expense.company_travel_allowance_reimbursement",
"sheet": "出差补助标准",
"name": "出差补助报销标准",
"component": "allowance"
}
]
},
"severity": "high",
"risk_score": 82,
"risk_level": "high"
"risk_level": "high",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
},
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
},
{
"code": "rule.expense.company_travel_transport_estimate",
"sheet": "交通费用预估表",
"name": "交通费用预估表",
"component": "transport_estimate"
},
{
"code": "rule.expense.company_travel_allowance_reimbursement",
"sheet": "出差补助标准",
"name": "出差补助报销标准",
"component": "allowance"
}
]
}

View File

@@ -10,9 +10,8 @@
"ontology_signal": "travel_city_mismatch",
"evaluator": "template_rule",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
@@ -27,7 +26,6 @@
"travel"
],
"business_stages": [
"expense_application",
"reimbursement"
]
},
@@ -105,7 +103,31 @@
"项目现场"
],
"condition_summary": "票据城市未覆盖申报目的地,或路线出现无法由本次票据起终点和申报目的地解释的额外城市且无合理说明。",
"message_template": "差旅票据城市与申报目的地不一致,请补充多地出差、改签或异地住宿说明。"
"message_template": "差旅票据城市与申报目的地不一致,请补充多地出差、改签或异地住宿说明。",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"outcomes": {
"pass": {
@@ -121,16 +143,15 @@
"metadata": {
"owner": "admin",
"stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
"source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准",
"created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin",
"risk_score": 90,
"risk_level": "high",
"rule_title": "差旅目的地与票据城市不一致高风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
@@ -146,7 +167,29 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"severity": "high",
"risk_score": 90,
@@ -160,5 +203,27 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
}

View File

@@ -10,9 +10,8 @@
"ontology_signal": "travel_date_outside_trip_window",
"evaluator": "template_rule",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
@@ -27,7 +26,6 @@
"travel"
],
"business_stages": [
"expense_application",
"reimbursement"
]
},
@@ -102,7 +100,37 @@
],
"hit_logic": "ticket_date_outside_trip",
"condition_summary": "任一票据/明细日期早于出差开始日前 1 天或晚于结束日后 1 天。",
"message_template": "票据日期超出申报差旅行程,请补充改签/延期说明或更正行程日期。"
"message_template": "票据日期超出申报差旅行程,请补充改签/延期说明或更正行程日期。",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
},
{
"code": "rule.expense.company_travel_allowance_reimbursement",
"sheet": "出差补助标准",
"name": "出差补助报销标准",
"component": "allowance"
}
]
},
"outcomes": {
"pass": {
@@ -118,16 +146,15 @@
"metadata": {
"owner": "admin",
"stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
"source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准、出差补助标准",
"created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin",
"risk_score": 88,
"risk_level": "high",
"rule_title": "票据日期超出差旅行程高风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
@@ -143,7 +170,35 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
},
{
"code": "rule.expense.company_travel_allowance_reimbursement",
"sheet": "出差补助标准",
"name": "出差补助报销标准",
"component": "allowance"
}
]
},
"severity": "high",
"risk_score": 88,
@@ -157,5 +212,33 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
},
{
"code": "rule.expense.company_travel_allowance_reimbursement",
"sheet": "出差补助标准",
"name": "出差补助报销标准",
"component": "allowance"
}
]
}

View File

@@ -10,9 +10,8 @@
"ontology_signal": "travel_personal_purpose",
"evaluator": "template_rule",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
@@ -27,7 +26,6 @@
"travel"
],
"business_stages": [
"expense_application",
"reimbursement"
]
},
@@ -76,7 +74,37 @@
],
"condition_summary": "差旅事由或票据文本命中个人旅游/私人目的关键词。",
"message_template": "识别到个人旅游或非公务目的表达,请确认是否属于公司差旅范围。",
"template_key": "keyword_match_v1"
"template_key": "keyword_match_v1",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
},
{
"code": "rule.expense.company_travel_allowance_reimbursement",
"sheet": "出差补助标准",
"name": "出差补助报销标准",
"component": "allowance"
}
]
},
"outcomes": {
"pass": {
@@ -92,16 +120,15 @@
"metadata": {
"owner": "admin",
"stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
"source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准、出差补助标准",
"created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin",
"risk_score": 86,
"risk_level": "high",
"rule_title": "个人旅游或非公务目的高风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
@@ -117,7 +144,35 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
},
{
"code": "rule.expense.company_travel_allowance_reimbursement",
"sheet": "出差补助标准",
"name": "出差补助报销标准",
"component": "allowance"
}
]
},
"severity": "high",
"risk_score": 86,
@@ -131,5 +186,33 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
},
{
"code": "rule.expense.company_travel_allowance_reimbursement",
"sheet": "出差补助标准",
"name": "出差补助报销标准",
"component": "allowance"
}
]
}

View File

@@ -9,10 +9,9 @@
"risk_category": "差旅费-申请审批",
"ontology_signal": "travel_preapproval_absent",
"evaluator": "template_rule",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
@@ -27,7 +26,6 @@
"travel"
],
"business_stages": [
"expense_application",
"reimbursement"
]
},
@@ -76,7 +74,19 @@
],
"condition_summary": "差旅申请/报销文本命中未申请、未审批或事后补申请关键词。",
"message_template": "识别到差旅未事前申请或事后补申请迹象,请补齐已审批的差旅申请后再提交。",
"template_key": "keyword_match_v1"
"template_key": "keyword_match_v1",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
},
"outcomes": {
"pass": {
@@ -92,16 +102,15 @@
"metadata": {
"owner": "admin",
"stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
"source_ref": "拆分基础规则:费用申请审批规则",
"created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin",
"risk_score": 92,
"risk_level": "high",
"rule_title": "差旅未申请或事后补申请高风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
@@ -117,7 +126,17 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
},
"severity": "high",
"risk_score": 92,
@@ -131,5 +150,15 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
}

View File

@@ -9,8 +9,8 @@
"risk_category": "差旅费-申请信息",
"ontology_signal": "travel_application_fields_missing",
"evaluator": "template_rule",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"business_stage": [
"expense_application"
],
@@ -80,7 +80,49 @@
],
"condition_summary": "差旅申请缺少事由、地点、起止时间或预计金额。",
"message_template": "差旅申请基础信息不完整,请补充地点、事由、起止时间和预计金额。",
"template_key": "field_required_v1"
"template_key": "field_required_v1",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
},
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
},
{
"code": "rule.expense.company_travel_transport_estimate",
"sheet": "交通费用预估表",
"name": "交通费用预估表",
"component": "transport_estimate"
},
{
"code": "rule.expense.company_travel_allowance_reimbursement",
"sheet": "出差补助标准",
"name": "出差补助报销标准",
"component": "allowance"
}
]
},
"outcomes": {
"pass": {
@@ -96,14 +138,14 @@
"metadata": {
"owner": "admin",
"stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
"source_ref": "拆分基础规则:费用申请审批规则、差旅住宿费标准、地区淡旺季映射表、交通工具等级标准、交通费用预估表、出差补助标准",
"created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin",
"risk_score": 42,
"risk_level": "low",
"rule_title": "差旅申请基础信息不完整低风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"business_stage": [
"expense_application"
],
@@ -120,7 +162,47 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
},
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
},
{
"code": "rule.expense.company_travel_transport_estimate",
"sheet": "交通费用预估表",
"name": "交通费用预估表",
"component": "transport_estimate"
},
{
"code": "rule.expense.company_travel_allowance_reimbursement",
"sheet": "出差补助标准",
"name": "出差补助报销标准",
"component": "allowance"
}
]
},
"severity": "low",
"risk_score": 42,
@@ -134,5 +216,45 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
},
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
},
{
"code": "rule.expense.company_travel_transport_estimate",
"sheet": "交通费用预估表",
"name": "交通费用预估表",
"component": "transport_estimate"
},
{
"code": "rule.expense.company_travel_allowance_reimbursement",
"sheet": "出差补助标准",
"name": "出差补助报销标准",
"component": "allowance"
}
]
}

View File

@@ -10,9 +10,8 @@
"ontology_signal": "travel_attachment_ocr_missing",
"evaluator": "template_rule",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
@@ -27,7 +26,6 @@
"travel"
],
"business_stages": [
"expense_application",
"reimbursement"
]
},
@@ -50,7 +48,31 @@
],
"condition_summary": "差旅附件缺少可读取 OCR 文本。",
"message_template": "差旅附件暂未识别到有效票据信息,请重新上传清晰附件或人工补录。",
"template_key": "field_required_v1"
"template_key": "field_required_v1",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"outcomes": {
"pass": {
@@ -66,16 +88,15 @@
"metadata": {
"owner": "admin",
"stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
"source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准",
"created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin",
"risk_score": 38,
"risk_level": "low",
"rule_title": "差旅附件无法识别低风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
@@ -91,7 +112,29 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"severity": "low",
"risk_score": 38,
@@ -105,5 +148,27 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
}

View File

@@ -9,10 +9,9 @@
"risk_category": "差旅费-市内交通",
"ontology_signal": "travel_local_transport_detail_missing",
"evaluator": "template_rule",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_code": "rule.expense.company_travel_transport_class",
"finance_rule_sheet": "交通工具等级标准",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
@@ -27,7 +26,6 @@
"travel"
],
"business_stages": [
"expense_application",
"reimbursement"
]
},
@@ -102,7 +100,19 @@
]
},
"condition_summary": "存在市内交通关键词,但文本中缺少起点、终点或路线说明。",
"message_template": "市内交通路线说明不足,请补充起点、终点或业务地点。"
"message_template": "市内交通路线说明不足,请补充起点、终点或业务地点。",
"finance_rule_code": "rule.expense.company_travel_transport_class",
"finance_rule_sheet": "交通工具等级标准",
"basic_rule_code": "rule.expense.company_travel_transport_class",
"basic_rule_sheet": "交通工具等级标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"outcomes": {
"pass": {
@@ -118,16 +128,15 @@
"metadata": {
"owner": "admin",
"stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
"source_ref": "拆分基础规则:交通工具等级标准",
"created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin",
"risk_score": 36,
"risk_level": "low",
"rule_title": "市内交通路线说明不足低风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_code": "rule.expense.company_travel_transport_class",
"finance_rule_sheet": "交通工具等级标准",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
@@ -143,7 +152,17 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_transport_class",
"basic_rule_sheet": "交通工具等级标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"severity": "low",
"risk_score": 36,
@@ -157,5 +176,15 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_transport_class",
"basic_rule_sheet": "交通工具等级标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
}

View File

@@ -10,9 +10,8 @@
"ontology_signal": "travel_vague_ticket_content",
"evaluator": "vague_goods_description",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
@@ -27,7 +26,6 @@
"travel"
],
"business_stages": [
"expense_application",
"reimbursement"
]
},
@@ -49,7 +47,31 @@
},
"params": {
"condition_summary": "票据未识别为明确的酒店、交通等差旅票据,且商品或服务名称过于笼统,无法直接对应差旅事项。",
"message_template": "差旅票据服务内容较笼统,请补充明细清单或业务说明。"
"message_template": "差旅票据服务内容较笼统,请补充明细清单或业务说明。",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"outcomes": {
"pass": {
@@ -65,16 +87,15 @@
"metadata": {
"owner": "admin",
"stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
"source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准",
"created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin",
"risk_score": 34,
"risk_level": "low",
"rule_title": "差旅票据服务内容笼统低风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
@@ -90,7 +111,29 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"severity": "low",
"risk_score": 34,
@@ -103,5 +146,27 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
}

View File

@@ -10,9 +10,8 @@
"ontology_signal": "travel_duplicate_ticket",
"evaluator": "duplicate_invoice",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
@@ -27,7 +26,6 @@
"travel"
],
"business_stages": [
"expense_application",
"reimbursement"
]
},
@@ -49,7 +47,31 @@
},
"params": {
"condition_summary": "票据号码在当前单据或历史报销中重复出现。",
"message_template": "发现疑似重复票据,请核对是否已经报销或重复上传。"
"message_template": "发现疑似重复票据,请核对是否已经报销或重复上传。",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"outcomes": {
"pass": {
@@ -65,16 +87,15 @@
"metadata": {
"owner": "admin",
"stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
"source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准",
"created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin",
"risk_score": 75,
"risk_level": "medium",
"rule_title": "差旅票据重复中风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
@@ -90,7 +111,29 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"severity": "medium",
"risk_score": 75,
@@ -103,5 +146,27 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
}

View File

@@ -10,9 +10,8 @@
"ontology_signal": "travel_multi_city_without_reason",
"evaluator": "multi_city_reason_required",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
@@ -27,7 +26,6 @@
"travel"
],
"business_stages": [
"expense_application",
"reimbursement"
]
},
@@ -67,7 +65,31 @@
},
"params": {
"condition_summary": "差旅行程涉及 3 个及以上城市,且事由未包含中转、多地、改签、绕行等说明。",
"message_template": "识别到多城市差旅行程,请补充中转、多地拜访或改签原因。"
"message_template": "识别到多城市差旅行程,请补充中转、多地拜访或改签原因。",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"outcomes": {
"pass": {
@@ -83,16 +105,15 @@
"metadata": {
"owner": "admin",
"stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
"source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准",
"created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin",
"risk_score": 72,
"risk_level": "medium",
"rule_title": "多城市行程缺少说明中风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
@@ -108,7 +129,29 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"severity": "medium",
"risk_score": 72,
@@ -121,5 +164,27 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
}

View File

@@ -9,10 +9,9 @@
"risk_category": "差旅费-事由完整性",
"ontology_signal": "travel_reason_too_brief",
"evaluator": "reason_too_brief",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
@@ -27,7 +26,6 @@
"travel"
],
"business_stages": [
"expense_application",
"reimbursement"
]
},
@@ -50,7 +48,19 @@
"params": {
"min_reason_length": 10,
"condition_summary": "合并申请/报销事由后有效字符少于 10 个。",
"message_template": "差旅事由描述过短,请补充项目、客户、地点和出差目的。"
"message_template": "差旅事由描述过短,请补充项目、客户、地点和出差目的。",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
},
"outcomes": {
"pass": {
@@ -66,16 +76,15 @@
"metadata": {
"owner": "admin",
"stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
"source_ref": "拆分基础规则:费用申请审批规则",
"created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin",
"risk_score": 68,
"risk_level": "medium",
"rule_title": "差旅事由过短中风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
@@ -91,7 +100,17 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
},
"severity": "medium",
"risk_score": 68,
@@ -104,5 +123,15 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
}

View File

@@ -10,9 +10,8 @@
"ontology_signal": "travel_invoice_title_mismatch",
"evaluator": "identity_consistency",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
@@ -27,7 +26,6 @@
"travel"
],
"business_stages": [
"expense_application",
"reimbursement"
]
},
@@ -60,7 +58,31 @@
"远光软件"
],
"condition_summary": "票据抬头/购买方不包含报销人姓名,也不包含公司抬头关键词。",
"message_template": "票据抬头或乘车人与报销人不一致,请补充代订、同行或公司抬头说明。"
"message_template": "票据抬头或乘车人与报销人不一致,请补充代订、同行或公司抬头说明。",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"outcomes": {
"pass": {
@@ -76,16 +98,15 @@
"metadata": {
"owner": "admin",
"stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
"source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准",
"created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin",
"risk_score": 64,
"risk_level": "medium",
"rule_title": "差旅票据抬头不一致中风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
],
"expense_types": [
@@ -101,7 +122,29 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"severity": "medium",
"risk_score": 64,
@@ -114,5 +157,27 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
}

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OCR_VENV_DIR="${OCR_VENV_DIR:-${ROOT_DIR}/.venv-ocr312}"
PYTHON_BIN="${PYTHON_BIN:-python3.12}"
PADDLEPADDLE_GPU_VERSION="${PADDLEPADDLE_GPU_VERSION:-3.3.0}"
PADDLEOCR_VERSION="${PADDLEOCR_VERSION:-3.6.0}"
PADDLE_GPU_INDEX_URL="${PADDLE_GPU_INDEX_URL:-https://www.paddlepaddle.org.cn/packages/stable/cu126/}"
if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then
echo "python3.12 不存在,请先安装 Python 3.12。" >&2
exit 1
fi
apt-get update
apt-get install -y --no-install-recommends libgl1 libglib2.0-0 poppler-utils poppler-data
rm -rf "${OCR_VENV_DIR}"
"${PYTHON_BIN}" -m venv "${OCR_VENV_DIR}"
"${OCR_VENV_DIR}/bin/pip" install --upgrade pip
"${OCR_VENV_DIR}/bin/pip" install \
"paddlepaddle-gpu==${PADDLEPADDLE_GPU_VERSION}" \
-i "${PADDLE_GPU_INDEX_URL}"
"${OCR_VENV_DIR}/bin/pip" install "paddleocr==${PADDLEOCR_VERSION}"
"${OCR_VENV_DIR}/bin/python" - <<'PY'
import paddle
print("PaddlePaddle:", paddle.__version__)
print("CUDA compiled:", paddle.is_compiled_with_cuda())
print("CUDA device count:", paddle.device.cuda.device_count())
paddle.utils.run_check()
PY
echo "PaddleOCR GPU runtime ${PADDLEOCR_VERSION} 已安装到 ${OCR_VENV_DIR}"

View File

@@ -3,18 +3,20 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OCR_VENV_DIR="${ROOT_DIR}/.venv-ocr312"
PYTHON_BIN="${PYTHON_BIN:-python3.12}"
PYTHON_BIN="${PYTHON_BIN:-python3}"
PADDLEPADDLE_VERSION="${PADDLEPADDLE_VERSION:-3.2.2}"
PADDLEOCR_VERSION="${PADDLEOCR_VERSION:-3.6.0}"
if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then
echo "python3.12 不存在,请先安装 Python 3.12。" >&2
echo "${PYTHON_BIN} 不存在,请先安装 Python 3。" >&2
exit 1
fi
apt-get update
apt-get install -y libgl1 libglib2.0-0
apt-get install -y --no-install-recommends libgl1 libglib2.0-0 poppler-utils poppler-data
"${PYTHON_BIN}" -m venv "${OCR_VENV_DIR}"
"${OCR_VENV_DIR}/bin/pip" install --upgrade pip
"${OCR_VENV_DIR}/bin/pip" install "paddlepaddle==3.2.0" "paddleocr==3.5.0"
"${OCR_VENV_DIR}/bin/pip" install "paddlepaddle==${PADDLEPADDLE_VERSION}" "paddleocr==${PADDLEOCR_VERSION}"
echo "PaddleOCR mobile runtime 已安装到 ${OCR_VENV_DIR}"
echo "PaddleOCR mobile runtime ${PADDLEOCR_VERSION} / PaddlePaddle ${PADDLEPADDLE_VERSION} 已安装到 ${OCR_VENV_DIR}"

View File

@@ -21,6 +21,8 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--lang", default="ch")
parser.add_argument("--text-detection-model", default="PP-OCRv5_mobile_det")
parser.add_argument("--text-recognition-model", default="PP-OCRv5_mobile_rec")
parser.add_argument("--device", default=os.environ.get("OCR_DEVICE", ""))
parser.add_argument("--enable-mkldnn", action="store_true")
return parser.parse_args()
@@ -99,14 +101,20 @@ def build_document(input_path: str, results: list[Any]) -> dict[str, Any]:
def main() -> int:
args = parse_args()
ocr = PaddleOCR(
text_detection_model_name=args.text_detection_model,
text_recognition_model_name=args.text_recognition_model,
use_doc_orientation_classify=False,
use_doc_unwarping=False,
use_textline_orientation=False,
lang=args.lang,
)
ocr_options = {
"text_detection_model_name": args.text_detection_model,
"text_recognition_model_name": args.text_recognition_model,
"use_doc_orientation_classify": False,
"use_doc_unwarping": False,
"use_textline_orientation": False,
"lang": args.lang,
# PaddlePaddle 3.3.x CPU oneDNN can fail on PP-OCRv5 static inference.
"enable_mkldnn": args.enable_mkldnn,
}
configured_device = str(args.device or "").strip()
if configured_device:
ocr_options["device"] = configured_device
ocr = PaddleOCR(**ocr_options)
documents = []
for input_path in args.inputs:

View File

@@ -88,8 +88,11 @@ if [ ! -f "$ROOT_ENV_FILE" ]; then
fi
ENV_OVERRIDE_SERVER_HOST_SET=false
ENV_OVERRIDE_SERVER_PORT_SET=false
ENV_OVERRIDE_POSTGRES_HOST_SET=false
ENV_OVERRIDE_DATABASE_URL_SET=false
ENV_OVERRIDE_STARTUP_BOOTSTRAP_ENABLED_SET=false
ENV_OVERRIDE_BACKGROUND_SCHEDULERS_ENABLED_SET=false
ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=false
ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET=false
ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET=false
@@ -107,6 +110,11 @@ if [ "${SERVER_HOST+x}" = x ]; then
ENV_OVERRIDE_SERVER_HOST="$SERVER_HOST"
fi
if [ "${SERVER_PORT+x}" = x ]; then
ENV_OVERRIDE_SERVER_PORT_SET=true
ENV_OVERRIDE_SERVER_PORT="$SERVER_PORT"
fi
if [ "${POSTGRES_HOST+x}" = x ]; then
ENV_OVERRIDE_POSTGRES_HOST_SET=true
ENV_OVERRIDE_POSTGRES_HOST="$POSTGRES_HOST"
@@ -117,6 +125,16 @@ if [ "${DATABASE_URL+x}" = x ]; then
ENV_OVERRIDE_DATABASE_URL="$DATABASE_URL"
fi
if [ "${STARTUP_BOOTSTRAP_ENABLED+x}" = x ]; then
ENV_OVERRIDE_STARTUP_BOOTSTRAP_ENABLED_SET=true
ENV_OVERRIDE_STARTUP_BOOTSTRAP_ENABLED="$STARTUP_BOOTSTRAP_ENABLED"
fi
if [ "${BACKGROUND_SCHEDULERS_ENABLED+x}" = x ]; then
ENV_OVERRIDE_BACKGROUND_SCHEDULERS_ENABLED_SET=true
ENV_OVERRIDE_BACKGROUND_SCHEDULERS_ENABLED="$BACKGROUND_SCHEDULERS_ENABLED"
fi
if [ "$PREFER_ENV_FILE_FOR_ONLYOFFICE" != true ] && [ "${ONLYOFFICE_ENABLED+x}" = x ]; then
ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=true
ENV_OVERRIDE_ONLYOFFICE_ENABLED="$ONLYOFFICE_ENABLED"
@@ -145,6 +163,10 @@ if [ "$ENV_OVERRIDE_SERVER_HOST_SET" = true ]; then
SERVER_HOST="$ENV_OVERRIDE_SERVER_HOST"
fi
if [ "$ENV_OVERRIDE_SERVER_PORT_SET" = true ]; then
SERVER_PORT="$ENV_OVERRIDE_SERVER_PORT"
fi
if [ "$ENV_OVERRIDE_POSTGRES_HOST_SET" = true ]; then
POSTGRES_HOST="$ENV_OVERRIDE_POSTGRES_HOST"
fi
@@ -153,6 +175,14 @@ if [ "$ENV_OVERRIDE_DATABASE_URL_SET" = true ]; then
DATABASE_URL="$ENV_OVERRIDE_DATABASE_URL"
fi
if [ "$ENV_OVERRIDE_STARTUP_BOOTSTRAP_ENABLED_SET" = true ]; then
STARTUP_BOOTSTRAP_ENABLED="$ENV_OVERRIDE_STARTUP_BOOTSTRAP_ENABLED"
fi
if [ "$ENV_OVERRIDE_BACKGROUND_SCHEDULERS_ENABLED_SET" = true ]; then
BACKGROUND_SCHEDULERS_ENABLED="$ENV_OVERRIDE_BACKGROUND_SCHEDULERS_ENABLED"
fi
if [ "$ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET" = true ]; then
ONLYOFFICE_ENABLED="$ENV_OVERRIDE_ONLYOFFICE_ENABLED"
fi
@@ -188,6 +218,8 @@ if is_container; then
fi
SERVER_RELOAD="${SERVER_RELOAD:-$DEFAULT_SERVER_RELOAD}"
SERVER_WORKERS="${SERVER_WORKERS:-${WEB_CONCURRENCY:-1}}"
export SERVER_WORKERS
needs_windows_python() {
is_msys || is_wsl
@@ -355,6 +387,12 @@ start_server() {
exec "$PYTHON_BIN" -m uvicorn app.main:app --reload --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT"
fi
if [ "$SERVER_WORKERS" -gt 1 ] 2>/dev/null; then
BACKGROUND_SCHEDULERS_ENABLED="${BACKGROUND_SCHEDULERS_ENABLED:-false}"
export BACKGROUND_SCHEDULERS_ENABLED
exec "$PYTHON_BIN" -m uvicorn app.main:app --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT" --workers "$SERVER_WORKERS"
fi
exec "$PYTHON_BIN" -m uvicorn app.main:app --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT"
}

View File

@@ -8,6 +8,10 @@ from sqlalchemy.orm import Session
from app.db.session import get_session_factory
PLATFORM_ADMIN_IDENTITIES = {"admin", "superadmin"}
ADMIN_HEADER_TRUE_VALUES = {"1", "true", "yes", "on"}
def get_db() -> Generator[Session, None, None]:
db = get_session_factory()()
try:
@@ -124,14 +128,15 @@ def _resolve_platform_admin_flag(
role_codes: list[str],
header_value: str | None,
) -> bool:
if str(header_value or "").strip().lower() in {"1", "true", "yes", "on"}:
if str(header_value or "").strip().lower() in ADMIN_HEADER_TRUE_VALUES:
return True
identities = {
str(username or "").strip().lower(),
str(name or "").strip().lower(),
}
return "admin" in identities or "admin" in {_normalize_role_code(item) for item in role_codes}
normalized_role_codes = {_normalize_role_code(item) for item in role_codes}
return bool(identities & PLATFORM_ADMIN_IDENTITIES) or bool(normalized_role_codes & PLATFORM_ADMIN_IDENTITIES)
def require_admin_user(

View File

@@ -4,6 +4,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from sqlalchemy.orm import Session
from starlette.concurrency import run_in_threadpool
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.common import ErrorResponse
@@ -50,7 +51,7 @@ async def recognize_ocr_documents(
upload.content_type,
)
)
result = OcrService(db).recognize_files(payload)
result = await run_in_threadpool(lambda: OcrService(db).recognize_files(payload))
return ReceiptFolderService().persist_ocr_batch(
files=payload,
result=result,

View File

@@ -10,13 +10,17 @@ from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.api.pagination import PageNumber, PageSize, page_payload, wants_page
from app.schemas.budget import BudgetClaimAnalysisRead
from app.schemas.common import ErrorResponse, PaginatedResponse
from app.schemas.ontology import OntologyParseResult, OntologyPermission
from app.schemas.reimbursement import (
ExpenseClaimAttachmentActionResponse,
ExpenseApplicationPreviewActionPayload,
ExpenseApplicationPreviewActionResponse,
ExpenseApplicationPreviewActionResult,
ExpenseClaimActionResponse,
ExpenseClaimAttachmentRead,
ExpenseClaimApprovalPayload,
ExpenseClaimItemCreate,
ExpenseClaimAttachmentActionResponse,
ExpenseClaimAttachmentRead,
ExpenseClaimItemActionResponse,
ExpenseClaimItemCreate,
ExpenseClaimItemUpdate,
ExpenseClaimRead,
ExpenseClaimReturnPayload,
@@ -27,10 +31,13 @@ from app.schemas.reimbursement import (
TravelReimbursementCalculatorRequest,
TravelReimbursementCalculatorResponse,
)
from app.schemas.user_agent import UserAgentRequest
from app.services.budget import BudgetService
from app.services.document_numbering import is_application_claim_no
from app.services.expense_claims import ExpenseClaimService
from app.services.reimbursement import ReimbursementService
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
from app.services.user_agent import UserAgentService
router = APIRouter()
DbSession = Annotated[Session, Depends(get_db)]
@@ -88,6 +95,93 @@ def calculate_travel_reimbursement(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
def _build_application_preview_action_context(
payload: ExpenseApplicationPreviewActionPayload,
current_user: CurrentUserContext,
) -> dict[str, object]:
context_json = dict(payload.context_json or {})
context_json.setdefault("session_type", "application")
context_json.setdefault("entry_source", "workbench_ai_inline")
context_json.setdefault("document_type", "expense_application")
context_json.setdefault("application_stage", "expense_application")
context_json.setdefault("role_codes", current_user.role_codes)
context_json.setdefault("is_admin", current_user.is_admin)
context_json.setdefault("username", current_user.username)
context_json.setdefault("name", current_user.name)
context_json.setdefault("department_name", current_user.department_name)
context_json.setdefault("position", current_user.position)
context_json.setdefault("grade", current_user.grade)
context_json.setdefault("employee_no", current_user.employee_no)
context_json.setdefault("manager_name", current_user.manager_name)
return context_json
@router.post(
"/application-preview-action",
response_model=ExpenseApplicationPreviewActionResponse,
summary="按申请核对预览快速保存或提交申请单",
description=(
"用于 AI 工作台已完成表格核对后的轻量建单/提交流程,"
"避免重复进入通用 Orchestrator 编排。"
),
)
def run_application_preview_action(
payload: ExpenseApplicationPreviewActionPayload,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseApplicationPreviewActionResponse:
context_json = _build_application_preview_action_context(payload, current_user)
run_id = f"application-preview-action:{payload.conversation_id or current_user.username}"
request = UserAgentRequest(
run_id=run_id,
user_id=payload.user_id or current_user.username or current_user.name,
message=payload.message,
ontology=OntologyParseResult(
scenario="expense",
intent="operate",
permission=OntologyPermission(
level="approval_required",
allowed=True,
reason="application preview fast action",
),
confidence=1.0,
run_id=run_id,
),
context_json=context_json,
tool_payload={},
selected_capability_codes=[],
degraded=False,
requires_confirmation=False,
)
try:
user_agent_response = UserAgentService(db)._build_expense_application_response(
request,
risk_flags=[],
)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
return ExpenseApplicationPreviewActionResponse(
status="succeeded",
conversation_id=payload.conversation_id,
result=ExpenseApplicationPreviewActionResult(
message=user_agent_response.answer,
answer=user_agent_response.answer,
suggested_actions=[
action.model_dump(mode="json")
for action in user_agent_response.suggested_actions
],
risk_flags=user_agent_response.risk_flags,
requires_confirmation=user_agent_response.requires_confirmation,
draft_payload=(
user_agent_response.draft_payload.model_dump(mode="json")
if user_agent_response.draft_payload is not None
else None
),
),
)
@router.get(
"/claims",
response_model=list[ExpenseClaimRead] | PaginatedResponse[ExpenseClaimRead],
@@ -187,13 +281,13 @@ def get_expense_claim_budget_analysis(
current_user: CurrentUser,
) -> BudgetClaimAnalysisRead:
service = ExpenseClaimService(db)
if not service.can_view_budget_analysis(current_user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有预算监控员或高级财务人员可以查看预算分析。")
claim = service.get_claim(claim_id, current_user)
if claim is None:
if not service.can_view_budget_analysis(current_user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有当前审核人、该部门预算监控员或高级财务人员可以查看预算分析。")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
if not service.can_view_budget_analysis(current_user, claim):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有该部门 P8 预算监控员或高级财务人员可以查看预算分析。")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有当前审核人、该部门预算监控员或高级财务人员可以查看预算分析。")
return BudgetService(db).analyze_claim_budget(claim)
@@ -741,7 +835,7 @@ def pay_expense_claim(
"/claims/{claim_id}",
response_model=ExpenseClaimActionResponse,
summary="删除报销单",
description="申请人可删除自己的草稿、待补充或退回单据(含申请单和报销单);高级财务人员可删除可见的非归档报销单;已归档单据仅高级管理员可删除,财务人员没有删除权限",
description="申请人可删除自己的草稿、待补充或退回单据(含申请单和报销单);系统管理员可删除单据;已归档单据仅系统管理员可删除。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
@@ -765,7 +859,11 @@ def delete_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
claim_no = str(claim.claim_no or "").strip()
expense_type = str(claim.expense_type or "").strip().lower()
document_label = "申请单" if claim_no.upper().startswith(("AP-", "APP-")) or expense_type.endswith("_application") else "报销单"
document_label = (
"申请单"
if is_application_claim_no(claim_no) or expense_type.endswith("_application")
else "报销单"
)
return ExpenseClaimActionResponse(
message=f"{claim.claim_no} {document_label}已删除。",
claim_id=claim.id,

View File

@@ -0,0 +1,437 @@
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncIterator
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.models.financial_record import ExpenseClaim
from app.schemas.common import ErrorResponse
from app.schemas.steward import (
StewardPlanRequest,
StewardPlanResponse,
StewardRuntimeDecisionRequest,
StewardRuntimeDecisionResponse,
StewardSlotDecisionRequest,
StewardSlotDecisionResponse,
StewardThinkingEvent,
)
from app.services.agent_conversations import AgentConversationService
from app.services.expense_claim_draft_flow import APPROVED_APPLICATION_LINK_STATUSES
from app.services.expense_claims import ExpenseClaimService
from app.services.runtime_chat import RuntimeChatService
from app.services.steward_flow_state import StewardFlowStateService
from app.services.steward_intent_agent import StewardIntentAgent
from app.services.steward_off_topic_agent import StewardOffTopicAgent
from app.services.steward_planner import StewardPlannerService
from app.services.steward_runtime_decision_agent import StewardRuntimeDecisionAgent
from app.services.steward_slot_decision_agent import StewardSlotDecisionAgent
router = APIRouter(prefix="/steward")
DbSession = Annotated[Session, Depends(get_db)]
@router.post(
"/plans",
response_model=StewardPlanResponse,
summary="生成小财管家任务计划",
description="把首页自然语言和附件元信息拆解为可确认、可追踪、可分派的财务任务计划。",
responses={
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "请求缺少任务描述,无法生成小财管家计划。",
}
},
)
def create_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StewardPlanResponse:
try:
planner = _build_steward_planner(db)
hydrated_payload = _hydrate_required_application_gate(db, payload, planner)
plan = planner.build_plan(hydrated_payload)
return _attach_conversation_state(db, hydrated_payload, plan)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@router.post(
"/slot-decisions",
response_model=StewardSlotDecisionResponse,
summary="判断小财管家当前任务字段缺口",
description="结合当前任务、本体字段和用户上下文,使用 function calling 判断下一步应先追问用户还是展示核对结果。",
)
def create_steward_slot_decision(
payload: StewardSlotDecisionRequest,
db: DbSession,
) -> StewardSlotDecisionResponse:
return StewardSlotDecisionAgent(RuntimeChatService(db)).decide(payload)
@router.post(
"/runtime-decisions",
response_model=StewardRuntimeDecisionResponse,
summary="判断小财管家运行时下一步动作",
description="结合任务队列、当前结构化结果和用户输入,使用 function calling 判断应提交当前单据、继续下一任务、补字段或重新规划。",
)
def create_steward_runtime_decision(
payload: StewardRuntimeDecisionRequest,
db: DbSession,
) -> StewardRuntimeDecisionResponse:
hydrated_payload = _hydrate_runtime_decision_payload(db, payload)
decision = StewardRuntimeDecisionAgent(RuntimeChatService(db)).decide(hydrated_payload)
return _attach_runtime_conversation_state(db, hydrated_payload, decision)
@router.post(
"/plans/stream",
summary="流式生成小财管家任务计划",
description="以 NDJSON 逐条返回小财管家的过程摘要事件,最后返回完整任务计划。",
)
async def stream_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StreamingResponse:
return StreamingResponse(
_iter_steward_plan_events(payload, _build_steward_planner(db), db),
media_type="application/x-ndjson",
)
async def _iter_steward_plan_events(
payload: StewardPlanRequest,
planner: StewardPlannerService,
db: Session,
) -> AsyncIterator[str]:
yield _encode_stream_event(
"thinking",
StewardThinkingEvent(
event_id="intent_agent_stream_start",
stage="stream_start",
title="读取用户输入",
content="我先识别申请/报销边界;如果是历史差旅描述,会先查询可关联申请单再决定流程。",
status="running",
).model_dump(mode="json"),
)
await asyncio.sleep(0)
try:
hydrated_payload = _hydrate_required_application_gate(db, payload, planner)
plan = planner.build_plan(hydrated_payload)
plan = _attach_conversation_state(db, hydrated_payload, plan)
except ValueError as exc:
yield _encode_stream_event("error", {"message": str(exc)})
return
for event in plan.thinking_events:
yield _encode_stream_event("thinking", event.model_dump(mode="json"))
await asyncio.sleep(0.6)
yield _encode_stream_event("plan", plan.model_dump(mode="json"))
def _encode_stream_event(event: str, data: dict[str, Any]) -> str:
return json.dumps({"event": event, "data": data}, ensure_ascii=False) + "\n"
def _build_steward_planner(db: Session) -> StewardPlannerService:
runtime_chat = RuntimeChatService(db)
return StewardPlannerService(
intent_agent=StewardIntentAgent(runtime_chat),
off_topic_agent=StewardOffTopicAgent(runtime_chat),
)
def _hydrate_required_application_gate(
db: Session,
payload: StewardPlanRequest,
planner: StewardPlannerService,
) -> StewardPlanRequest:
context_json = dict(payload.context_json or {})
required_gate = context_json.get("required_application_gate")
if isinstance(required_gate, dict):
travel_gate = required_gate.get("travel")
if isinstance(travel_gate, dict) and travel_gate.get("checked") is True:
return payload
message = planner._clean_text(payload.message)
base_date = planner._resolve_base_date(payload.client_now_iso, context_json)
if not planner._looks_like_ambiguous_travel_flow(message, base_date, payload):
return payload
candidates = _query_required_application_gate_candidates(db, payload, context_json)
next_required_gate = dict(required_gate) if isinstance(required_gate, dict) else {}
next_required_gate["travel"] = {
"checked": True,
"candidate_count": len(candidates),
"candidates": candidates[:5],
}
return payload.model_copy(
update={
"context_json": {
**context_json,
"required_application_gate": next_required_gate,
}
}
)
def _query_required_application_gate_candidates(
db: Session,
payload: StewardPlanRequest,
context_json: dict[str, Any],
) -> list[dict[str, Any]]:
identities = _resolve_required_application_gate_identities(payload, context_json)
stmt = (
select(ExpenseClaim)
.order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.updated_at.desc())
.limit(200)
)
candidates: list[dict[str, Any]] = []
for claim in db.scalars(stmt).all():
if not ExpenseClaimService._is_expense_application_claim(claim):
continue
if str(claim.status or "").strip().lower() not in APPROVED_APPLICATION_LINK_STATUSES:
continue
if identities and not _claim_matches_required_application_identity(claim, identities):
continue
if not _claim_matches_required_travel_application(claim, payload.message):
continue
candidates.append(_serialize_required_application_gate_candidate(claim))
return candidates
def _resolve_required_application_gate_identities(
payload: StewardPlanRequest,
context_json: dict[str, Any],
) -> set[str]:
raw_values = [
payload.user_id,
context_json.get("user_id"),
context_json.get("username"),
context_json.get("name"),
context_json.get("employee_id"),
context_json.get("employee_no"),
context_json.get("employee_name"),
]
identities: set[str] = set()
for value in raw_values:
normalized = _normalize_required_application_identity(value)
if normalized:
identities.add(normalized)
return identities
def _normalize_required_application_identity(value: Any) -> str:
return str(value or "").strip().casefold()
def _claim_matches_required_application_identity(claim: ExpenseClaim, identities: set[str]) -> bool:
claim_identities = {
_normalize_required_application_identity(claim.employee_id),
_normalize_required_application_identity(claim.employee_name),
}
claim_identities.discard("")
return bool(claim_identities.intersection(identities))
def _claim_matches_required_travel_application(claim: ExpenseClaim, message: str) -> bool:
expense_type = str(claim.expense_type or "").strip().casefold()
if any(token in expense_type for token in ("travel", "trip", "差旅", "出差")):
return True
claim_text = "".join(
[
str(claim.reason or ""),
str(claim.location or ""),
str(claim.claim_no or ""),
]
)
if "差旅" in claim_text or "出差" in claim_text:
return True
compact_message = str(message or "").replace(" ", "")
location = str(claim.location or "").strip()
return bool(location and location in compact_message and "出差" in compact_message)
def _serialize_required_application_gate_candidate(claim: ExpenseClaim) -> dict[str, Any]:
business_time = _resolve_required_application_business_time(claim)
status_label = _resolve_required_application_status_label(claim.status)
return {
"id": str(claim.id or "").strip(),
"claim_no": str(claim.claim_no or "").strip(),
"reason": str(claim.reason or "").strip(),
"location": str(claim.location or "").strip(),
"business_time": business_time,
"status_label": status_label,
"application_claim_id": str(claim.id or "").strip(),
"application_claim_no": str(claim.claim_no or "").strip(),
"application_reason": str(claim.reason or "").strip(),
"application_location": str(claim.location or "").strip(),
"application_business_time": business_time,
"application_status_label": status_label,
}
def _resolve_required_application_business_time(claim: ExpenseClaim) -> str:
for flag in list(claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
for source in (
flag,
flag.get("application_detail"),
flag.get("applicationDetail"),
flag.get("review_form_values"),
flag.get("reviewFormValues"),
):
if not isinstance(source, dict):
continue
value = (
source.get("application_business_time")
or source.get("applicationBusinessTime")
or source.get("business_time")
or source.get("businessTime")
)
if str(value or "").strip():
return str(value).strip()
if claim.occurred_at is not None:
return claim.occurred_at.date().isoformat()
return ""
def _resolve_required_application_status_label(status: Any) -> str:
normalized = str(status or "").strip().lower()
return {
"approved": "已审批",
"completed": "已完成",
}.get(normalized, normalized)
def _attach_conversation_state(
db: Session,
payload: StewardPlanRequest,
plan: StewardPlanResponse,
) -> 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,
source="user_message",
context_json=context_json,
)
current_state = _resolve_current_steward_state(conversation.state_json, context_json)
steward_state = StewardFlowStateService().merge_plan(current_state, plan)
conversation = conversation_service.update_state(
conversation_id=conversation.conversation_id,
run_id=None,
scenario="steward",
intent="plan",
context_json={
**context_json,
"steward_state": steward_state,
},
) or conversation
conversation_service.append_message(
conversation_id=conversation.conversation_id,
role="user",
content=payload.message,
message_json={"source": "steward_plan_request"},
)
conversation_service.append_message(
conversation_id=conversation.conversation_id,
role="assistant",
content=plan.summary,
message_json={
"source": "steward_plan_response",
"plan_id": plan.plan_id,
"steward_state": steward_state,
},
)
return plan.model_copy(
update={
"conversation_id": conversation.conversation_id,
"steward_state": steward_state,
}
)
def _attach_runtime_conversation_state(
db: Session,
payload: StewardRuntimeDecisionRequest,
decision: StewardRuntimeDecisionResponse,
) -> StewardRuntimeDecisionResponse:
steward_state = decision.steward_state
if not isinstance(steward_state, dict) or not steward_state:
return decision
context_json = dict(payload.context_json or {})
conversation_id = _resolve_conversation_id(context_json)
if not conversation_id:
return decision
conversation_service = AgentConversationService(db)
conversation_service.update_state(
conversation_id=conversation_id,
run_id=None,
scenario="steward",
intent="runtime_decision",
context_json={
**context_json,
"steward_state": steward_state,
},
)
return decision
def _hydrate_runtime_decision_payload(
db: Session,
payload: StewardRuntimeDecisionRequest,
) -> StewardRuntimeDecisionRequest:
context_json = dict(payload.context_json or {})
runtime_state = dict(payload.runtime_state or {})
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
runtime_state["steward_state"] = stored_state
conversation_state = dict(context_json.get("conversation_state") or {})
conversation_state["steward_state"] = stored_state
context_json["conversation_state"] = conversation_state
return payload.model_copy(
update={
"runtime_state": runtime_state,
"context_json": context_json,
}
)
def _resolve_conversation_id(context_json: dict[str, Any]) -> str | None:
return str(
context_json.get("conversation_id")
or context_json.get("conversationId")
or ""
).strip() or None
def _resolve_current_steward_state(
conversation_state: dict[str, Any] | None,
context_json: dict[str, Any],
) -> dict[str, Any]:
state_json = conversation_state if isinstance(conversation_state, dict) else {}
stored_state = state_json.get("steward_state")
if isinstance(stored_state, dict) and stored_state:
return stored_state
incoming_state = context_json.get("steward_state") or context_json.get("stewardState")
return incoming_state if isinstance(incoming_state, dict) else {}

View File

@@ -22,6 +22,7 @@ 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.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
router = APIRouter()
@@ -47,4 +48,5 @@ 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(settings_router, tags=["settings"])
router.include_router(steward_router, tags=["steward"])
router.include_router(system_logs_router, tags=["system-logs"])

View File

@@ -19,41 +19,52 @@ ONLYOFFICE_FIELD_NAMES = {
_settings_cache: Settings | None = None
_settings_cache_signature: tuple[tuple[str, bool, int | None, int | None], ...] | None = None
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=DEFAULT_ENV_FILES,
env_file_encoding="utf-8",
extra="ignore",
)
app_name: str = Field(default="X-Financial Server", alias="APP_NAME")
app_env: str = Field(default="local", alias="APP_ENV")
app_debug: bool = Field(default=True, alias="APP_DEBUG")
setup_completed: bool = Field(default=False, alias="SETUP_COMPLETED")
company_name: str = Field(default="", alias="COMPANY_NAME")
company_code: str = Field(default="", alias="COMPANY_CODE")
admin_email: str = Field(default="", alias="ADMIN_EMAIL")
web_host: str = Field(default="0.0.0.0", alias="WEB_HOST")
web_port: int = Field(default=5173, alias="WEB_PORT")
app_host: str = Field(default="0.0.0.0", alias="SERVER_HOST")
app_port: int = Field(default=8000, alias="SERVER_PORT")
api_v1_prefix: str = Field(default="/api/v1", alias="API_V1_PREFIX")
postgres_host: str = Field(default="127.0.0.1", alias="POSTGRES_HOST")
postgres_port: int = Field(default=5432, alias="POSTGRES_PORT")
postgres_db: str = Field(default="x_financial", alias="POSTGRES_DB")
postgres_user: str = Field(default="postgres", alias="POSTGRES_USER")
postgres_password: str = Field(default="postgres", alias="POSTGRES_PASSWORD")
database_url: str | None = Field(default=None, alias="DATABASE_URL")
sqlalchemy_echo: bool = Field(default=False, alias="SQLALCHEMY_ECHO")
redis_url: str | None = Field(default=None, alias="REDIS_URL")
cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS")
app_name: str = Field(default="X-Financial Server", alias="APP_NAME")
app_env: str = Field(default="local", alias="APP_ENV")
app_debug: bool = Field(default=True, alias="APP_DEBUG")
setup_completed: bool = Field(default=False, alias="SETUP_COMPLETED")
company_name: str = Field(default="", alias="COMPANY_NAME")
company_code: str = Field(default="", alias="COMPANY_CODE")
admin_email: str = Field(default="", alias="ADMIN_EMAIL")
web_host: str = Field(default="0.0.0.0", alias="WEB_HOST")
web_port: int = Field(default=5273, alias="WEB_PORT")
app_host: str = Field(default="0.0.0.0", alias="SERVER_HOST")
app_port: int = Field(default=8000, alias="SERVER_PORT")
server_workers: int = Field(default=1, alias="SERVER_WORKERS")
web_concurrency: int | None = Field(default=None, alias="WEB_CONCURRENCY")
startup_bootstrap_enabled: bool = Field(default=True, alias="STARTUP_BOOTSTRAP_ENABLED")
startup_cache_warmup_enabled: bool = Field(default=False, alias="STARTUP_CACHE_WARMUP_ENABLED")
background_schedulers_enabled: bool = Field(
default=True,
alias="BACKGROUND_SCHEDULERS_ENABLED",
)
api_v1_prefix: str = Field(default="/api/v1", alias="API_V1_PREFIX")
postgres_host: str = Field(default="127.0.0.1", alias="POSTGRES_HOST")
postgres_port: int = Field(default=5432, alias="POSTGRES_PORT")
postgres_db: str = Field(default="x_financial", alias="POSTGRES_DB")
postgres_user: str = Field(default="postgres", alias="POSTGRES_USER")
postgres_password: str = Field(default="postgres", alias="POSTGRES_PASSWORD")
database_url: str | None = Field(default=None, alias="DATABASE_URL")
sqlalchemy_echo: bool = Field(default=False, alias="SQLALCHEMY_ECHO")
sqlalchemy_pool_size: int = Field(default=10, alias="SQLALCHEMY_POOL_SIZE")
sqlalchemy_max_overflow: int = Field(default=20, alias="SQLALCHEMY_MAX_OVERFLOW")
sqlalchemy_pool_timeout: int = Field(default=30, alias="SQLALCHEMY_POOL_TIMEOUT")
redis_url: str | None = Field(default=None, alias="REDIS_URL")
cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS")
vite_api_base_url: str = Field(
default="http://127.0.0.1:8000/api/v1", alias="VITE_API_BASE_URL"
)
@@ -68,8 +79,10 @@ class Settings(BaseSettings):
log_file_enabled: bool = Field(default=True, alias="LOG_FILE_ENABLED")
storage_root_dir: str = Field(default="storage", alias="STORAGE_ROOT_DIR")
ocr_python_bin: str = Field(default="", alias="OCR_PYTHON_BIN")
ocr_device: str = Field(default="", alias="OCR_DEVICE")
ocr_timeout_seconds: int = Field(default=180, alias="OCR_TIMEOUT_SECONDS")
ocr_max_file_size_mb: int = Field(default=20, alias="OCR_MAX_FILE_SIZE_MB")
ocr_max_concurrent_workers: int = Field(default=1, alias="OCR_MAX_CONCURRENT_WORKERS")
ocr_language: str = Field(default="ch", alias="OCR_LANGUAGE")
seed_demo_financial_records: bool = Field(
default=False,
@@ -88,7 +101,7 @@ class Settings(BaseSettings):
def resolved_database_url(self) -> str:
if self.database_url:
return self.database_url
return (
f"postgresql+psycopg://{self.postgres_user}:{self.postgres_password}"
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"

View File

@@ -18,11 +18,20 @@ def configure_session_factory() -> None:
if _engine is not None:
_engine.dispose()
_engine = create_engine(
settings.resolved_database_url,
echo=settings.sqlalchemy_echo,
pool_pre_ping=True,
)
engine_kwargs = {
"echo": settings.sqlalchemy_echo,
"pool_pre_ping": True,
}
if not settings.resolved_database_url.startswith("sqlite"):
engine_kwargs.update(
{
"pool_size": max(1, int(settings.sqlalchemy_pool_size or 10)),
"max_overflow": max(0, int(settings.sqlalchemy_max_overflow or 20)),
"pool_timeout": max(1, int(settings.sqlalchemy_pool_timeout or 30)),
}
)
_engine = create_engine(settings.resolved_database_url, **engine_kwargs)
_session_factory = sessionmaker(bind=_engine, autoflush=False, autocommit=False)

View File

@@ -2,6 +2,8 @@ from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from logging import Logger
import threading
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -10,11 +12,13 @@ from app.api.router import api_router
from app.core.config import get_settings
from app.core.logging import get_logger, setup_logging
from app.core.openapi import API_DESCRIPTION, OPENAPI_TAGS
from app.db.session import get_session_factory
from app.middleware.logging import AccessLogMiddleware
from app.schemas.common import RootStatusRead
from app.services.agent_foundation import prepare_agent_foundation
from app.services.digital_employee_reminder_scheduler import digital_employee_reminder_scheduler
from app.services.employee import prepare_employee_directory
from app.services.employee import EmployeeService
from app.services.employee_profile_scheduler import employee_profile_scheduler
from app.services.finance_dashboard_scheduler import finance_dashboard_scheduler
from app.services.finance_report_scheduler import finance_report_scheduler
@@ -23,6 +27,61 @@ from app.services.knowledge import prepare_knowledge_library
from app.services.knowledge_index_tasks import knowledge_index_task_manager
from app.services.knowledge_rag import shutdown_knowledge_rag_runtime
from app.services.knowledge_scheduler import knowledge_index_scheduler
from app.services.settings import SettingsService
from app.services.user_session_metrics import UserSessionMetricService
def _effective_server_workers(settings: object) -> int:
server_workers = getattr(settings, "server_workers", None)
web_concurrency = getattr(settings, "web_concurrency", None)
workers = web_concurrency if int(server_workers or 1) <= 1 and web_concurrency else server_workers
try:
return max(1, int(workers or 1))
except (TypeError, ValueError):
return 1
def _should_start_background_schedulers(settings: object) -> bool:
if not bool(getattr(settings, "background_schedulers_enabled", True)):
return False
return _effective_server_workers(settings) <= 1
def _run_startup_bootstrap(logger: Logger) -> None:
steps = (
("employee_directory", prepare_employee_directory),
("agent_foundation", prepare_agent_foundation),
("knowledge_library", prepare_knowledge_library),
("hermes_skills", sync_repository_hermes_skills),
)
for name, step in steps:
try:
step()
except Exception:
logger.exception("Startup bootstrap step failed; continuing degraded name=%s", name)
def _warm_startup_caches(logger: Logger) -> None:
try:
session_factory = get_session_factory()
with session_factory() as db:
SettingsService(db).ensure_settings_ready()
EmployeeService(db).ensure_directory_ready()
UserSessionMetricService(db).ensure_storage_ready()
logger.info("Startup cache warmup complete")
except Exception:
logger.exception("Startup cache warmup failed; continuing without warm cache")
def _start_cache_warmup_thread(logger: Logger) -> None:
thread = threading.Thread(
target=_warm_startup_caches,
args=(logger,),
name="x-financial-startup-cache-warmup",
daemon=True,
)
thread.start()
@asynccontextmanager
@@ -30,15 +89,27 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings = get_settings()
logger = get_logger("app.main")
prepare_employee_directory()
prepare_agent_foundation()
prepare_knowledge_library()
sync_repository_hermes_skills()
knowledge_index_scheduler.start()
finance_dashboard_scheduler.start()
employee_profile_scheduler.start()
digital_employee_reminder_scheduler.start()
finance_report_scheduler.start()
if settings.startup_bootstrap_enabled:
_run_startup_bootstrap(logger)
else:
logger.warning("Startup bootstrap skipped because STARTUP_BOOTSTRAP_ENABLED=false")
if settings.startup_cache_warmup_enabled:
_start_cache_warmup_thread(logger)
schedulers_started = _should_start_background_schedulers(settings)
if schedulers_started:
knowledge_index_scheduler.start()
finance_dashboard_scheduler.start()
employee_profile_scheduler.start()
digital_employee_reminder_scheduler.start()
finance_report_scheduler.start()
else:
logger.warning(
"Background schedulers skipped - workers=%s enabled=%s",
_effective_server_workers(settings),
settings.background_schedulers_enabled,
)
logger.info(
"Server ready - host=%s port=%s prefix=%s",
settings.app_host,
@@ -46,11 +117,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings.api_v1_prefix,
)
yield
finance_report_scheduler.shutdown()
digital_employee_reminder_scheduler.shutdown()
employee_profile_scheduler.shutdown()
finance_dashboard_scheduler.shutdown()
knowledge_index_scheduler.shutdown()
if schedulers_started:
finance_report_scheduler.shutdown()
digital_employee_reminder_scheduler.shutdown()
employee_profile_scheduler.shutdown()
finance_dashboard_scheduler.shutdown()
knowledge_index_scheduler.shutdown()
knowledge_index_task_manager.shutdown()
shutdown_knowledge_rag_runtime()

View File

@@ -68,8 +68,16 @@ class ExpenseClaim(Base):
return None
if self.employee.manager is not None and self.employee.manager.name:
return str(self.employee.manager.name).strip() or None
if self.employee.organization_unit is not None and self.employee.organization_unit.manager_name:
return str(self.employee.organization_unit.manager_name).strip() or None
return None
@property
def finance_owner_name(self) -> str | None:
if self.employee is None or not self.employee.finance_owner_name:
return None
return str(self.employee.finance_owner_name).strip() or None
@property
def role_labels(self) -> list[str]:
if self.employee is None or not self.employee.roles:

View File

@@ -20,7 +20,7 @@ class SystemSetting(Base):
copyright_text: Mapped[str] = mapped_column(String(255), default="")
theme_skin: Mapped[str] = mapped_column(String(64), default="sky")
admin_account: Mapped[str] = mapped_column(String(120), default="superadmin")
admin_account: Mapped[str] = mapped_column(String(120), default="admin")
admin_email: Mapped[str] = mapped_column(String(255), default="")
session_timeout: Mapped[int] = mapped_column(Integer, default=30)
conversation_retention_days: Mapped[int] = mapped_column(Integer, default=3)

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -28,6 +30,74 @@ class AgentRunRepository:
stmt = stmt.order_by(AgentRun.started_at.desc()).limit(limit)
return list(self.db.scalars(stmt).all())
def list_light(
self,
*,
agent: str | None = None,
status: str | None = None,
source: str | None = None,
limit: int = 20,
) -> list[dict[str, Any]]:
stmt = select(
AgentRun.id.label("id"),
AgentRun.run_id.label("run_id"),
AgentRun.agent.label("agent"),
AgentRun.source.label("source"),
AgentRun.user_id.label("user_id"),
AgentRun.task_id.label("task_id"),
AgentRun.permission_level.label("permission_level"),
AgentRun.status.label("status"),
AgentRun.result_summary.label("result_summary"),
AgentRun.error_message.label("error_message"),
AgentRun.started_at.label("started_at"),
AgentRun.finished_at.label("finished_at"),
AgentRun.route_json["job_type"].as_string().label("route_job_type"),
AgentRun.route_json["task_type"].as_string().label("route_task_type"),
AgentRun.route_json["task_code"].as_string().label("route_task_code"),
AgentRun.route_json["task_name"].as_string().label("route_task_name"),
AgentRun.route_json["task_title"].as_string().label("route_task_title"),
AgentRun.route_json["asset_name"].as_string().label("route_asset_name"),
AgentRun.route_json["selected_agent"].as_string().label("route_selected_agent"),
AgentRun.route_json["phase"].as_string().label("route_phase"),
AgentRun.route_json["stage"].as_string().label("route_stage"),
AgentRun.route_json["report_type"].as_string().label("route_report_type"),
AgentRun.route_json["snapshot_key"].as_string().label("route_snapshot_key"),
AgentRun.route_json["folder"].as_string().label("route_folder"),
AgentRun.route_json["heartbeat_at"].as_string().label("route_heartbeat_at"),
AgentRun.route_json["progress"].label("route_progress"),
AgentRun.ontology_json["scenario"].as_string().label("ontology_scenario"),
AgentRun.ontology_json["intent"].as_string().label("ontology_intent"),
AgentRun.ontology_json["parse_strategy"].as_string().label("ontology_parse_strategy"),
)
if agent:
stmt = stmt.where(AgentRun.agent == agent)
if status:
stmt = stmt.where(AgentRun.status == status)
if source:
stmt = stmt.where(AgentRun.source == source)
stmt = stmt.order_by(AgentRun.started_at.desc()).limit(limit)
return [dict(item) for item in self.db.execute(stmt).mappings().all()]
def list_light_tool_calls(self, run_ids: list[str]) -> list[dict[str, Any]]:
if not run_ids:
return []
stmt = (
select(
AgentToolCall.id.label("id"),
AgentToolCall.run_id.label("run_id"),
AgentToolCall.tool_type.label("tool_type"),
AgentToolCall.tool_name.label("tool_name"),
AgentToolCall.status.label("status"),
AgentToolCall.duration_ms.label("duration_ms"),
AgentToolCall.error_message.label("error_message"),
AgentToolCall.created_at.label("created_at"),
)
.where(AgentToolCall.run_id.in_(run_ids))
.order_by(AgentToolCall.created_at.asc())
)
return [dict(item) for item in self.db.execute(stmt).mappings().all()]
def get_by_run_id(self, run_id: str) -> AgentRun | None:
stmt = select(AgentRun).where(AgentRun.run_id == run_id)
return self.db.scalar(stmt)

View File

@@ -28,7 +28,7 @@ class NotificationStatePatch(BaseModel):
class NotificationStateBatchPatch(BaseModel):
states: list[NotificationStatePatch] = Field(default_factory=list, max_length=100)
states: list[NotificationStatePatch] = Field(default_factory=list, max_length=500)
class NotificationStateRead(BaseModel):

View File

@@ -4,7 +4,9 @@ from datetime import date, datetime
from decimal import Decimal
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.services.expense_claim_budget_risk_flags import dedupe_budget_risk_flags
class ReimbursementCreate(BaseModel):
@@ -126,6 +128,7 @@ class ExpenseClaimStandardAdjustmentRisk(BaseModel):
item_id: str | None = Field(default=None, max_length=120)
title: str | None = Field(default=None, max_length=120)
risk: str | None = Field(default=None, max_length=500)
application_days: int | None = Field(default=None, ge=1, le=365)
original_amount: Decimal | None = None
reimbursable_amount: Decimal | None = None
@@ -146,6 +149,8 @@ class ExpenseClaimRead(BaseModel):
employee_position: str | None = None
employee_grade: str | None = None
manager_name: str | None = None
finance_owner_name: str | None = None
finance_approver_name: str | None = None
budget_approver_name: str | None = None
budget_approver_grade: str | None = None
budget_approver_role_code: str | None = None
@@ -166,6 +171,13 @@ class ExpenseClaimRead(BaseModel):
updated_at: datetime
items: list[ExpenseClaimItemRead] = Field(default_factory=list)
@field_validator("risk_flags_json", mode="before")
@classmethod
def dedupe_budget_risk_flags_for_read(cls, value: Any) -> list[Any]:
if isinstance(value, list):
return dedupe_budget_risk_flags(value)
return []
class ExpenseClaimActionResponse(BaseModel):
message: str
@@ -173,6 +185,29 @@ class ExpenseClaimActionResponse(BaseModel):
status: str | None = None
class ExpenseApplicationPreviewActionPayload(BaseModel):
source: str = Field(default="user_message", max_length=80)
user_id: str | None = Field(default=None, max_length=120)
conversation_id: str | None = Field(default=None, max_length=120)
message: str = Field(min_length=1, max_length=4000)
context_json: dict[str, Any] = Field(default_factory=dict)
class ExpenseApplicationPreviewActionResult(BaseModel):
message: str
answer: str
suggested_actions: list[dict[str, Any]] = Field(default_factory=list)
risk_flags: list[str] = Field(default_factory=list)
requires_confirmation: bool = False
draft_payload: dict[str, Any] | None = None
class ExpenseApplicationPreviewActionResponse(BaseModel):
status: str = "succeeded"
conversation_id: str | None = None
result: ExpenseApplicationPreviewActionResult
class ExpenseClaimReturnPayload(BaseModel):
reason: str | None = Field(default=None, max_length=500)
reason_codes: list[str] = Field(default_factory=list, max_length=10)
@@ -186,6 +221,9 @@ class TravelReimbursementCalculatorRequest(BaseModel):
days: int = Field(ge=1, le=365)
location: str = Field(min_length=1, max_length=120)
grade: str | None = Field(default=None, max_length=30)
transport_mode: str | None = Field(default=None, max_length=30)
origin_location: str | None = Field(default=None, max_length=120)
travel_date: date | None = None
class TravelReimbursementCalculatorResponse(BaseModel):
@@ -203,6 +241,17 @@ class TravelReimbursementCalculatorResponse(BaseModel):
basic_allowance_rate: Decimal
total_allowance_rate: Decimal
allowance_amount: Decimal
transport_mode: str = ""
transport_origin: str = ""
transport_destination: str = ""
transport_estimated_amount: Decimal = Decimal("0.00")
transport_estimate_basis: str = ""
transport_estimate_confidence: str = ""
transport_estimate_source: str = ""
transport_estimate_rule_code: str = ""
transport_estimate_rule_name: str = ""
transport_estimate_rule_version: str = ""
travel_date: date | None = None
total_amount: Decimal
rule_name: str
rule_version: str

View File

@@ -0,0 +1,195 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
StewardTaskType = Literal["expense_application", "reimbursement"]
StewardAssignedAgent = Literal["application_assistant", "reimbursement_assistant"]
StewardPlanningSource = Literal["llm_function_call", "rule_fallback"]
StewardPlanNextAction = Literal["confirm_flow", "confirm_task", "delegate_task", "none"]
StewardSlotDecisionSource = Literal["llm_function_call", "rule_fallback"]
StewardSlotNextAction = Literal["ask_user", "render_preview"]
StewardRuntimeDecisionSource = Literal["llm_function_call", "rule_fallback"]
StewardRuntimeNextAction = Literal[
"plan_new_tasks",
"continue_selected_flow",
"submit_current_application",
"continue_next_task",
"fill_current_slot",
"ask_user",
"cancel_current_action",
"no_op",
]
StewardTaskStatus = Literal[
"planned",
"needs_confirmation",
"ready_to_delegate",
"delegated",
"completed",
"blocked",
]
StewardConfirmationStatus = Literal["pending", "confirmed", "rejected"]
StewardFlowId = Literal["travel_application", "travel_reimbursement"]
StewardPendingFlowStatus = Literal["none", "pending", "confirmed", "rejected"]
class StewardAttachmentInput(BaseModel):
name: str = Field(description="附件原始文件名。")
media_type: str = Field(default="", description="附件 MIME 类型。")
ocr_summary: str = Field(default="", description="可选 OCR 摘要。")
ocr_fields: dict[str, Any] = Field(default_factory=dict, description="可选 OCR 结构化字段。")
class StewardPlanRequest(BaseModel):
message: str = Field(description="用户在首页输入的自然语言任务。")
user_id: str | None = Field(default=None, description="当前用户 ID。")
client_now_iso: str | None = Field(default=None, description="客户端当前时间 ISO 字符串。")
attachments: list[StewardAttachmentInput] = Field(default_factory=list, description="随本次输入上传的附件。")
context_json: dict[str, Any] = Field(default_factory=dict, description="调用方上下文。")
class StewardThinkingEvent(BaseModel):
event_id: str = Field(description="过程摘要事件 ID。")
stage: str = Field(description="阶段编码。")
title: str = Field(description="面向用户展示的阶段标题。")
content: str = Field(description="面向用户展示的过程摘要。")
status: str = Field(default="completed", description="事件状态。")
class StewardTask(BaseModel):
task_id: str = Field(description="小财管家任务 ID。")
task_type: StewardTaskType = Field(description="任务类型。")
assigned_agent: StewardAssignedAgent = Field(description="建议分派的下游助手。")
title: str = Field(description="任务标题。")
summary: str = Field(description="任务摘要。")
status: StewardTaskStatus = Field(default="needs_confirmation", description="任务状态。")
confidence: float = Field(default=0.0, ge=0.0, le=1.0, description="识别置信度。")
ontology_fields: dict[str, str] = Field(default_factory=dict, description="归一化后的业务本体字段。")
missing_fields: list[str] = Field(default_factory=list, description="仍缺失的本体字段。")
confirmation_required: bool = Field(default=True, description="执行前是否需要用户确认。")
class StewardAttachmentGroup(BaseModel):
group_id: str = Field(description="附件归集组 ID。")
target_task_id: str | None = Field(default=None, description="建议归属的任务 ID。")
scene: str = Field(description="归集场景编码。")
scene_label: str = Field(description="归集场景展示名。")
attachment_names: list[str] = Field(default_factory=list, description="建议纳入的附件名称。")
excluded_attachment_names: list[str] = Field(default_factory=list, description="建议排除或单独处理的附件名称。")
confidence: float = Field(default=0.0, ge=0.0, le=1.0, description="归集置信度。")
rationale: str = Field(default="", description="归集依据。")
confirmation_required: bool = Field(default=True, description="归集前是否需要用户确认。")
class StewardConfirmationAction(BaseModel):
confirmation_id: str = Field(description="确认动作 ID。")
action_type: str = Field(description="确认动作类型。")
label: str = Field(description="确认按钮文案。")
description: str = Field(default="", description="确认动作说明。")
target_task_id: str | None = Field(default=None, description="关联任务 ID。")
attachment_group_id: str | None = Field(default=None, description="关联附件归集组 ID。")
status: StewardConfirmationStatus = Field(default="pending", description="确认状态。")
payload: dict[str, Any] = Field(default_factory=dict, description="确认后继续执行所需载荷。")
class StewardCandidateFlow(BaseModel):
flow_id: StewardFlowId = Field(description="候选业务流程。")
label: str = Field(description="用户可见候选流程名称。")
confidence: float = Field(default=0.0, ge=0.0, le=1.0, description="候选流程置信度。")
reason: str = Field(default="", description="候选流程依据。")
ontology_fields: dict[str, str] = Field(default_factory=dict, description="候选流程可继承的 canonical ontology 字段。")
missing_fields: list[str] = Field(default_factory=list, description="候选流程仍缺失的 canonical ontology 字段。")
class StewardPendingFlowConfirmation(BaseModel):
status: StewardPendingFlowStatus = Field(default="none", description="候选流程确认状态。")
source_message: str = Field(default="", description="触发候选流程确认的用户原始输入。")
reason: str = Field(default="", description="需要确认流程方向的原因。")
candidate_flows: list[StewardCandidateFlow] = Field(default_factory=list, description="候选业务流程。")
class StewardPlanResponse(BaseModel):
plan_id: str = Field(description="小财管家计划 ID。")
plan_status: str = Field(default="needs_confirmation", description="计划状态。")
planning_source: StewardPlanningSource = Field(default="rule_fallback", description="计划生成来源。")
next_action: StewardPlanNextAction = Field(default="confirm_task", description="计划完成后的下一步动作。")
conversation_id: str = Field(default="", description="持久化会话 ID。")
steward_state: dict[str, Any] = Field(default_factory=dict, description="小财管家跨轮业务状态。")
summary: str = Field(description="计划摘要。")
thinking_events: list[StewardThinkingEvent] = Field(default_factory=list, description="过程摘要事件。")
tasks: list[StewardTask] = Field(default_factory=list, description="拆解后的任务。")
attachment_groups: list[StewardAttachmentGroup] = Field(default_factory=list, description="附件归集建议。")
confirmation_groups: list[StewardConfirmationAction] = Field(default_factory=list, description="等待用户确认的动作。")
pending_flow_confirmation: StewardPendingFlowConfirmation = Field(
default_factory=StewardPendingFlowConfirmation,
description="申请/报销流程不明确时等待用户确认的候选流程。",
)
candidate_flows: list[StewardCandidateFlow] = Field(default_factory=list, description="等待用户确认的候选流程快捷列表。")
model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。")
suggested_prompts: list[str] = Field(
default_factory=list,
description="当 plan_status 为 off_topic 等场景时,给用户的推荐话术示例。",
)
class StewardSlotOption(BaseModel):
label: str = Field(description="用户可见选项文案。")
value: str = Field(description="写回本体字段的选项值。")
field_key: str = Field(description="对应 canonical ontology field。")
description: str = Field(default="", description="选项说明。")
class StewardSlotDecisionRequest(BaseModel):
task_type: StewardTaskType = Field(description="当前小财管家正在推进的任务类型。")
user_message: str = Field(description="用户原始话术或小财管家携带的任务上下文。")
ontology_fields: dict[str, str] = Field(default_factory=dict, description="当前已抽取的 canonical ontology 字段。")
missing_fields: list[str] = Field(default_factory=list, description="上游意图识别给出的 canonical 缺失字段。")
task_context: dict[str, Any] = Field(default_factory=dict, description="当前任务、附件、申请预览等上下文。")
class StewardSlotDecisionResponse(BaseModel):
decision_source: StewardSlotDecisionSource = Field(default="rule_fallback", description="字段决策来源。")
next_action: StewardSlotNextAction = Field(description="下一步应追问用户还是展示核对结果。")
required_fields: list[str] = Field(default_factory=list, description="模型认为当前业务需要的 canonical 字段。")
missing_fields: list[str] = Field(default_factory=list, description="当前仍缺失的 canonical 字段。")
question: str = Field(default="", description="需要追问时展示给用户的问题。")
options: list[StewardSlotOption] = Field(default_factory=list, description="可直接选择的补充选项。")
rationale: str = Field(default="", description="面向用户的简短判断依据,不暴露推理链。")
model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。")
class StewardRuntimeDecisionRequest(BaseModel):
user_message: str = Field(description="用户当前输入。")
session_type: str = Field(default="steward", description="当前前端会话类型。")
runtime_state: dict[str, Any] = Field(default_factory=dict, description="小财管家运行时上下文。")
context_json: dict[str, Any] = Field(default_factory=dict, description="调用方补充上下文。")
class StewardRuntimeDecisionResponse(BaseModel):
decision_source: StewardRuntimeDecisionSource = Field(default="rule_fallback", description="运行时决策来源。")
next_action: StewardRuntimeNextAction = Field(description="小财管家下一步动作。")
target_task_id: str = Field(default="", description="关联的小财管家任务 ID。")
target_message_id: str = Field(default="", description="关联的前端消息 ID。")
field_key: str = Field(default="", description="补字段时对应 canonical ontology field。")
field_value: str = Field(default="", description="补字段时用户提供的字段值。")
confirmation_required: bool = Field(default=False, description="执行该动作前是否仍需要用户二次确认。")
question: str = Field(default="", description="需要追问用户时展示的问题。")
response_text: str = Field(default="", description="无需调用工具时给用户的简短回复。")
rationale: str = Field(default="", description="面向用户的简短判断依据,不暴露推理链。")
steward_state: dict[str, Any] = Field(default_factory=dict, description="小财管家更新后的跨轮业务状态。")
model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。")
class StewardFlowStatePatch(BaseModel):
active_flow: StewardFlowId = Field(description="本轮对话正在推进的业务流程。")
flow_id: StewardFlowId = Field(description="需要合并字段的目标业务流程。")
intent: str = Field(default="", description="本轮识别出的业务意图。")
status: str = Field(default="collecting", description="流程状态。")
fields: dict[str, Any] = Field(default_factory=dict, description="待写入流程的本体字段 patch。")
missing_fields: list[str] = Field(default_factory=list, description="仍缺失的 canonical ontology 字段。")
application_claim_id: str = Field(default="", description="出差申请流程已生成的申请单 ID。")
linked_application_claim_id: str = Field(default="", description="报销流程关联的申请单 ID。")
attachments: list[dict[str, Any]] = Field(default_factory=list, description="流程关联附件摘要。")
evidence: list[dict[str, Any]] = Field(default_factory=list, description="字段来源证据。")

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from app.services.agent_asset_travel_spreadsheets import build_styled_workbook
def build_communication_expense_workbook() -> bytes:
return build_styled_workbook(
"通信费报销标准",
[
"序号",
"适用对象",
"岗位/职级范围",
"月度报销上限",
"票据要求",
"申请阶段预算口径",
"审批/例外说明",
"备注",
],
[
[
1,
"一线销售/客户成功",
"销售经理、客户成功经理、项目驻场岗位",
200,
"运营商通信费发票或电子账单",
"按月度上限占用预算",
"超出上限需直属领导审批并说明客户项目",
"仅覆盖因公通信支出",
],
[
2,
"项目交付/实施",
"实施顾问、项目经理、现场支持岗位",
150,
"运营商通信费发票或电子账单",
"按月度上限占用预算",
"长期驻场可按项目专项审批调整",
"需关联项目或客户",
],
[
3,
"管理岗位",
"部门负责人及以上",
120,
"运营商通信费发票或电子账单",
"按月度上限占用预算",
"超出上限需补充业务说明",
"按自然月核算",
],
[
4,
"普通员工",
"未单列岗位",
80,
"运营商通信费发票或电子账单",
"按月度上限占用预算",
"原则上不支持超额报销",
"特殊岗位需先维护适用对象",
],
],
column_widths=[8, 22, 30, 16, 30, 24, 38, 28],
)

View File

@@ -14,6 +14,16 @@ from zipfile import ZIP_DEFLATED, ZipFile
from openpyxl import load_workbook
from app.core.config import SERVER_DIR, get_settings
from app.services.agent_asset_finance_spreadsheets import build_communication_expense_workbook
from app.services.agent_asset_travel_spreadsheets import (
build_travel_allowance_workbook,
build_travel_grade_mapping_workbook,
build_travel_lodging_workbook_from_source,
build_travel_season_mapping_workbook,
build_travel_transport_class_workbook,
build_travel_transport_estimate_workbook,
build_xlsx_bytes_from_source_sheet,
)
RULE_SPREADSHEET_BLOCK_PATTERN = re.compile(
r"```rule-spreadsheet\s*(\{.*?\})\s*```",
@@ -21,9 +31,29 @@ RULE_SPREADSHEET_BLOCK_PATTERN = re.compile(
)
COMPANY_TRAVEL_EXPENSE_RULE_CODE = "rule.expense.company_travel_expense_reimbursement"
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME = "公司差旅费报销规则.xlsx"
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME = "差旅住宿费标准.xlsx"
COMPANY_TRAVEL_SOURCE_RULE_FILENAME = "公司差旅费报销规则.xlsx"
COMPANY_TRAVEL_ALLOWANCE_RULE_CODE = "rule.expense.company_travel_allowance_reimbursement"
COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME = "出差补助标准.xlsx"
COMPANY_TRAVEL_TRANSPORT_RULE_CODE = "rule.expense.company_travel_transport_class"
COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME = "交通工具等级标准.xlsx"
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE = "rule.expense.company_travel_transport_estimate"
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME = "交通费用预估表.xlsx"
COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE = "rule.expense.company_travel_grade_mapping"
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME = "差旅职级映射表.xlsx"
COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE = "rule.expense.company_travel_season_mapping"
COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME = "地区淡旺季映射表.xlsx"
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE = "rule.expense.company_communication_expense_reimbursement"
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME = "公司通信费报销规则.xlsx"
COMPANY_PREAPPROVAL_RULE_CODE = "rule.expense.company_preapproval_requirement"
COMPANY_PREAPPROVAL_RULE_FILENAME = "公司费用申请审批规则.xlsx"
TRAVEL_SPREADSHEET_RULE_CODES = {
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_ALLOWANCE_RULE_CODE,
COMPANY_TRAVEL_TRANSPORT_RULE_CODE,
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE,
}
FINANCE_RULES_LIBRARY = "finance-rules"
RISK_RULES_LIBRARY = "risk-rules"
RULE_LIBRARY_NAMES = {FINANCE_RULES_LIBRARY, RISK_RULES_LIBRARY}
@@ -282,65 +312,79 @@ class AgentAssetSpreadsheetManager:
@staticmethod
def build_company_travel_rule_template() -> bytes:
standard_rows = [
["费用分类", "适用场景", "票据要求", "报销标准", "审批要求", "备注"],
[
"长途交通",
"飞机、高铁、火车等跨城出行",
"行程单、车票、发票",
"据实报销",
"超预算需直属领导审批",
"优先选择公共交通",
],
[
"住宿费",
"出差住宿",
"酒店发票、入住清单",
"一线城市 650/晚;二线城市 500/晚;其他城市 380/晚",
"超标需总监审批",
"协议酒店优先",
],
[
"市内交通",
"出租车、网约车、地铁、公交",
"发票或电子行程单",
"150/天",
"超限需补充说明",
"夜间或无公共交通场景可豁免",
],
[
"餐补",
"出差期间日常补助",
"无需票据",
"120/天",
"系统自动核定",
"当天往返默认不享受",
],
[
"招待餐费",
"客户接待或项目宴请",
"餐饮发票、参与人清单",
"300/人",
"需业务负责人审批",
"需关联客户或项目",
],
return AgentAssetSpreadsheetManager.build_travel_lodging_rule_template()
@staticmethod
def build_travel_lodging_rule_template() -> bytes:
lodging_rows = [
["地区(城市)", "城市级别", "P0", "P1", "P2", "P3", "P4", "P5", "P6", "P7", "P8", "备注"],
["北京", "一线城市", 450, 450, 450, 450, 450, 450, 450, 500, 500, "中心城区按本标准执行"],
["上海", "一线城市", 450, 450, 450, 450, 450, 450, 450, 500, 500, "中心城区按本标准执行"],
["广州", "一线城市", 430, 430, 430, 430, 450, 450, 450, 500, 500, "广交会期间可按例外流程说明"],
["深圳", "一线城市", 430, 430, 430, 430, 450, 450, 450, 500, 500, "旺季需补充超标说明"],
["杭州", "二线城市", 380, 380, 380, 380, 430, 430, 430, 480, 480, ""],
["南京", "二线城市", 380, 380, 380, 380, 430, 430, 430, 480, 480, ""],
["成都", "二线城市", 380, 380, 380, 380, 430, 430, 430, 480, 480, ""],
["武汉", "二线城市", 380, 380, 380, 380, 430, 430, 430, 480, 480, ""],
["其他地区", "其他地区", 320, 320, 320, 320, 380, 380, 380, 450, 450, "未单列城市按其他地区执行"],
]
instruction_rows = [
["字段", "填写说明"],
["费用分类", "建议保持固定选项,避免审批口径漂移。"],
["适用场景", "写清楚业务场景,例如客户拜访、项目驻场、参会等。"],
["票据要求", "必须明确哪些单据为必传,哪些场景允许补充说明替代。"],
["报销标准", "建议拆成统一金额、按城市等级、按职级分档三类口径。"],
["审批要求", "超标、例外、补录等情形应写清升级审批链。"],
["备注", "记录豁免条件、灰度口径或制度来源。"],
["版本建议", "每次修改表格后在规则中心同步生成一个新的规则版本。"],
]
return _build_xlsx_bytes(
[
("差旅报销标准", standard_rows),
("填表说明", instruction_rows),
]
source_path = (
SERVER_DIR
/ "rules"
/ FINANCE_RULES_LIBRARY
/ COMPANY_TRAVEL_SOURCE_RULE_FILENAME
)
return build_travel_lodging_workbook_from_source(source_path, lodging_rows)
@staticmethod
def build_travel_allowance_rule_template() -> bytes:
return build_travel_allowance_workbook()
@staticmethod
def build_travel_transport_rule_template() -> bytes:
return build_travel_transport_class_workbook()
@staticmethod
def build_travel_grade_mapping_template() -> bytes:
return build_travel_grade_mapping_workbook()
@staticmethod
def build_travel_season_mapping_template() -> bytes:
source_path = (
SERVER_DIR
/ "rules"
/ FINANCE_RULES_LIBRARY
/ COMPANY_TRAVEL_SOURCE_RULE_FILENAME
)
return build_travel_season_mapping_workbook(source_path)
@staticmethod
def build_travel_transport_estimate_rule_template() -> bytes:
return build_travel_transport_estimate_workbook()
@staticmethod
def build_company_communication_rule_template() -> bytes:
return build_communication_expense_workbook()
@staticmethod
def _build_travel_source_sheet(
sheet_name: str,
*,
fallback_rows: list[list[object]],
) -> bytes:
source_path = (
SERVER_DIR
/ "rules"
/ FINANCE_RULES_LIBRARY
/ COMPANY_TRAVEL_SOURCE_RULE_FILENAME
)
if source_path.exists():
try:
return build_xlsx_bytes_from_source_sheet(source_path, sheet_name)
except (OSError, ValueError):
pass
return _build_xlsx_bytes([(sheet_name, fallback_rows)])
@staticmethod
def build_rule_workbook(sheets: list[tuple[str, list[list[object]]]]) -> bytes:
@@ -348,7 +392,17 @@ class AgentAssetSpreadsheetManager:
@staticmethod
def build_blank_rule_workbook(sheet_name: str = "规则配置") -> bytes:
return _build_xlsx_bytes([(sheet_name, [[""]])])
return _build_xlsx_bytes(
[
(
sheet_name,
[
["规则项", "适用条件", "标准/阈值", "所需材料", "审批要求", "备注"],
["", "", "", "", "", ""],
],
)
]
)
@staticmethod
def rebuild_from_uploaded_content(content: bytes) -> bytes:
@@ -358,23 +412,20 @@ class AgentAssetSpreadsheetManager:
try:
workbook = load_workbook(
filename=BytesIO(content),
read_only=True,
read_only=False,
data_only=False,
)
except Exception as exc: # noqa: BLE001
raise ValueError("无法解析上传的 Excel 表格。") from exc
sheets: list[tuple[str, list[list[object]]]] = []
for worksheet in workbook.worksheets:
rows = [
list(row)
for row in worksheet.iter_rows(values_only=True)
]
sheets.append((worksheet.title, _trim_empty_table(rows)))
if not sheets:
raise ValueError("上传的 Excel 表格中没有可导入的工作表。")
return _build_xlsx_bytes(sheets)
try:
if not workbook.worksheets:
raise ValueError("上传的 Excel 表格中没有可导入的工作表。")
rebuilt_buffer = BytesIO()
workbook.save(rebuilt_buffer)
return rebuilt_buffer.getvalue()
finally:
workbook.close()
def _build_xlsx_bytes(sheets: list[tuple[str, list[list[object]]]]) -> bytes:
@@ -542,7 +593,7 @@ def _build_styles_xml() -> str:
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
'<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>'
'<fonts count="1"><font><sz val="13"/><name val="Microsoft YaHei"/></font></fonts>'
'<fills count="2"><fill><patternFill patternType="none"/></fill>'
'<fill><patternFill patternType="gray125"/></fill></fills>'
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
@@ -560,6 +611,14 @@ def _build_styles_xml() -> str:
def _build_sheet_xml(rows: list[list[object]]) -> str:
normalized_rows = rows or [[""]]
max_column_count = max((len(row) for row in normalized_rows), default=1)
column_widths = _build_sheet_column_widths(normalized_rows, max_column_count)
column_xml = "".join(
(
f'<col min="{index}" max="{index}" width="{width}" '
'customWidth="1" bestFit="1"/>'
)
for index, width in enumerate(column_widths, start=1)
)
worksheet_rows: list[str] = []
for row_index, row in enumerate(normalized_rows, start=1):
@@ -571,15 +630,18 @@ def _build_sheet_xml(rows: list[list[object]]) -> str:
cells.append(
f'<c r="{ref}" t="inlineStr"><is><t{preserve}>{escape(text)}</t></is></c>'
)
worksheet_rows.append(f'<row r="{row_index}">{"".join(cells)}</row>')
worksheet_rows.append(
f'<row r="{row_index}" ht="25" customHeight="1">{"".join(cells)}</row>'
)
dimension = f"A1:{_column_letter(max_column_count)}{len(normalized_rows)}"
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
f'<dimension ref="{dimension}"/>'
"<sheetViews><sheetView workbookViewId=\"0\"/></sheetViews>"
"<sheetFormatPr defaultRowHeight=\"18\"/>"
'<sheetViews><sheetView workbookViewId="0" zoomScale="120" zoomScaleNormal="120"/></sheetViews>'
"<sheetFormatPr defaultRowHeight=\"25\" customHeight=\"1\"/>"
f"<cols>{column_xml}</cols>"
f"<sheetData>{''.join(worksheet_rows)}</sheetData>"
"</worksheet>"
)
@@ -594,6 +656,31 @@ def _column_letter(index: int) -> str:
return result
def _build_sheet_column_widths(
rows: list[list[object]],
max_column_count: int,
) -> list[str]:
widths: list[str] = []
for column_index in range(max_column_count):
max_text_width = 0.0
for row in rows[:120]:
value = row[column_index] if column_index < len(row) else ""
text = "" if value is None else str(value)
if not text:
continue
max_text_width = max(max_text_width, _estimate_display_width(text))
width = min(max(max_text_width + 4, 16), 42)
widths.append(f"{width:.1f}")
return widths
def _estimate_display_width(text: str) -> float:
width = 0.0
for char in text:
width += 2.0 if ord(char) > 127 else 1.0
return width
def _trim_empty_table(rows: list[list[object]]) -> list[list[object]]:
normalized_rows = [list(row) for row in rows]
while normalized_rows and all(cell in (None, "") for cell in normalized_rows[-1]):

View File

@@ -13,8 +13,18 @@ from app.schemas.agent_asset import (
from app.services.agent_asset_spreadsheet import (
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_ALLOWANCE_RULE_CODE,
COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE,
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME,
COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE,
COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME,
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME,
COMPANY_TRAVEL_TRANSPORT_RULE_CODE,
COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME,
FINANCE_RULES_LIBRARY,
RULE_LIBRARY_NAMES,
SPREADSHEET_MIME_TYPE,
@@ -133,7 +143,7 @@ class AgentAssetSpreadsheetHelperMixin:
}
if config_json.get("rule_document") != expected_document:
config_json["detail_mode"] = "spreadsheet"
config_json["tag"] = str(config_json.get("tag") or "财务规则").strip() or "财务规则"
config_json["tag"] = str(config_json.get("tag") or "基础规则").strip() or "基础规则"
config_json["rule_library"] = library
config_json["rule_document"] = expected_document
asset.config_json = config_json
@@ -160,7 +170,7 @@ class AgentAssetSpreadsheetHelperMixin:
)
config_json = dict(asset.config_json or {})
config_json["detail_mode"] = "spreadsheet"
config_json["tag"] = str(config_json.get("tag") or "财务规则").strip() or "财务规则"
config_json["tag"] = str(config_json.get("tag") or "基础规则").strip() or "基础规则"
config_json["rule_library"] = library
config_json["rule_document"] = {
**self.spreadsheet_manager.build_rule_document_config(
@@ -187,6 +197,16 @@ class AgentAssetSpreadsheetHelperMixin:
return COMPANY_TRAVEL_EXPENSE_RULE_FILENAME
if asset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE:
return COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME
if asset.code == COMPANY_TRAVEL_ALLOWANCE_RULE_CODE:
return COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME
if asset.code == COMPANY_TRAVEL_TRANSPORT_RULE_CODE:
return COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME
if asset.code == COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE:
return COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME
if asset.code == COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE:
return COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME
if asset.code == COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE:
return COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME
fallback = Path(str(asset.name or "规则表").strip()).name
return fallback if fallback.lower().endswith(".xlsx") else f"{fallback}.xlsx"

View File

@@ -0,0 +1,554 @@
from __future__ import annotations
import re
from copy import copy
from io import BytesIO
from pathlib import Path
from typing import Any
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from app.services.travel_policy_grades import TRAVEL_GRADE_KEYS
LODGING_SHEET_NAME = "差旅住宿费标准"
ALLOWANCE_SHEET_NAME = "出差补助标准"
TRANSPORT_CLASS_SHEET_NAME = "交通工具等级标准"
TRANSPORT_ESTIMATE_SHEET_NAME = "交通费用预估表"
TRAVEL_GRADE_LABELS = {
"P0": "实习/见习",
"P1": "基础员工",
"P2": "初级员工",
"P3": "普通员工",
"P4": "资深员工/主管",
"P5": "基层经理",
"P6": "中层经理",
"P7": "高层经理",
"P8": "董事会",
}
def build_travel_lodging_workbook_from_source(
source_path: Path,
fallback_rows: list[list[object]],
) -> bytes:
rows: list[list[object]] = []
if source_path.exists():
workbook = load_workbook(source_path, read_only=True, data_only=True)
try:
if LODGING_SHEET_NAME in workbook.sheetnames:
rows = _extract_lodging_rows(
list(workbook[LODGING_SHEET_NAME].iter_rows(values_only=True))
)
finally:
workbook.close()
if not rows:
rows = _fallback_lodging_rows(fallback_rows)
return build_styled_workbook(
LODGING_SHEET_NAME,
["序号", "地区", "地区(城市)", *TRAVEL_GRADE_KEYS, "常规超标限额"],
[
[
row[0],
row[1],
row[2],
*_expand_lodging_grade_amounts(row),
row[7],
]
for row in rows
],
column_widths=[8, 14, 28, *([12] * len(TRAVEL_GRADE_KEYS)), 16],
)
def build_travel_grade_mapping_workbook() -> bytes:
return build_styled_workbook(
"差旅职级映射表",
["序号", "职级", "职级名称", "住宿标准列", "交通标准行", "适用说明", "备注"],
[
[index, grade, TRAVEL_GRADE_LABELS[grade], grade, grade, _grade_usage_note(grade), ""]
for index, grade in enumerate(TRAVEL_GRADE_KEYS, start=1)
],
column_widths=[8, 14, 28, 14, 14, 32, 32],
)
def build_travel_allowance_workbook() -> bytes:
return build_styled_workbook(
ALLOWANCE_SHEET_NAME,
["序号", "补助区域", "伙食补助/天", "基本补助/天", "补助合计/天", "适用说明", "备注"],
[
[1, "直辖市/特区", 65, 35, 100, "北京、上海、天津、重庆、深圳等地区", "按出差自然日计算"],
[2, "其他地区", 55, 35, 90, "未单列的境内城市和地区", "申请阶段用于预算占用"],
[3, "新疆-乌鲁木齐", 75, 45, 120, "乌鲁木齐市", "按高原/远途地区补助口径执行"],
[4, "新疆-其他", 65, 40, 105, "新疆除乌鲁木齐外地区", "按远途地区补助口径执行"],
[5, "西藏", 80, 50, 130, "西藏自治区", "按高原地区补助口径执行"],
[6, "港澳台", 120, 80, 200, "香港、澳门、台湾地区", "需按出入境及外币票据要求补充材料"],
[7, "国外", 180, 120, 300, "境外国家和地区", "外币折算按财务汇率口径执行"],
],
column_widths=[8, 18, 16, 16, 16, 34, 34],
)
def build_travel_transport_class_workbook() -> bytes:
return build_styled_workbook(
TRANSPORT_CLASS_SHEET_NAME,
[
"序号",
"职级",
"职级说明",
"飞机标准",
"火车标准",
"轮船标准",
"适用说明",
"超标处理",
"备注",
],
[
[
index,
grade,
TRAVEL_GRADE_LABELS[grade],
"经济舱",
"二等座/硬卧/硬座" if grade != "P8" else "二等座/软卧/硬卧",
"二等舱",
"按已审批出差申请执行" if grade in {"P6", "P7", "P8"} else "优先选择火车或高铁;确需飞机时按经济舱执行",
"超出标准需说明原因并走审批" if grade != "P8" else "超出标准需董事会或授权审批确认",
"申请阶段按交通费用预估表占用预算" if grade != "P8" else "P8 为董事会级别",
]
for index, grade in enumerate(TRAVEL_GRADE_KEYS, start=1)
],
column_widths=[8, 18, 34, 14, 22, 14, 42, 34, 34],
)
def build_travel_season_mapping_workbook(source_path: Path) -> bytes:
rows: list[list[object]] = []
if source_path.exists():
workbook = load_workbook(source_path, read_only=True, data_only=True)
try:
if LODGING_SHEET_NAME in workbook.sheetnames:
lodging_rows = _extract_lodging_rows(
list(workbook[LODGING_SHEET_NAME].iter_rows(values_only=True))
)
rows = [
[row[0], row[1], row[2], row[3], row[7], row[8]]
for row in lodging_rows
]
finally:
workbook.close()
if not rows:
rows = [[1, "北京", "北京", "", 500, ""]]
return build_styled_workbook(
"地区淡旺季映射表",
["序号", "地区", "地区(城市)", "旺季期间(月)", "常规超标限额", "旺季超标限额"],
rows,
column_widths=[8, 14, 28, 18, 16, 16],
)
def build_travel_transport_estimate_workbook() -> bytes:
return build_styled_workbook(
TRANSPORT_ESTIMATE_SHEET_NAME,
[
"序号",
"出发城市",
"目的地",
"目的地范围",
"交通方式",
"单程预估金额",
"往返预估金额",
"置信度",
"预算占用口径",
"来源说明",
],
[
[1, "武汉", "北京", "高频城市", "火车", 520, 1040, "基础规则", "往返二等座/硬卧预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
[2, "武汉", "北京", "高频城市", "飞机", 700, 1400, "基础规则", "往返经济舱预估", "参考民航经济舱市场均价和高频航线公开价格"],
[3, "武汉", "上海", "高频城市", "火车", 360, 720, "基础规则", "往返二等座预估", "参考历史票据样例与 12306 公布票价查询口径"],
[4, "武汉", "上海", "高频城市", "飞机", 600, 1200, "基础规则", "往返经济舱预估", "参考高频航线公开往返价格,按申请预算保守占用"],
[5, "武汉", "广州", "高频城市", "火车", 470, 940, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
[6, "武汉", "广州", "高频城市", "飞机", 650, 1300, "基础规则", "往返经济舱预估", "参考民航经济舱市场均价和高频航线公开价格"],
[7, "武汉", "深圳", "高频城市", "火车", 540, 1080, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
[8, "武汉", "深圳", "高频城市", "飞机", 700, 1400, "基础规则", "往返经济舱预估", "参考民航经济舱市场均价和高频航线公开价格"],
[9, "武汉", "杭州", "高频城市", "火车", 330, 660, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
[10, "武汉", "南京", "高频城市", "火车", 260, 520, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
[11, "武汉", "成都", "普通城市", "火车", 350, 700, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
[12, "武汉", "成都", "普通城市", "飞机", 600, 1200, "基础规则", "往返经济舱预估", "参考民航经济舱市场均价和高频航线公开价格"],
[13, "武汉", "西安", "普通城市", "火车", 300, 600, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
[14, "武汉", "厦门", "沿海城市", "火车", 450, 900, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
[15, "武汉", "厦门", "沿海城市", "飞机", 650, 1300, "基础规则", "往返经济舱预估", "参考民航经济舱市场均价和沿海航线公开价格"],
[16, "武汉", "三亚", "远途地区", "飞机", 900, 1800, "基础规则", "往返经济舱预估", "参考旅游/远途航线公开价格,申请阶段占用预算用"],
[17, "武汉", "乌鲁木齐", "远途地区", "飞机", 1600, 3200, "基础规则", "往返经济舱预估", "远途航线按预算保守占用"],
[18, "武汉", "拉萨", "远途地区", "飞机", 1800, 3600, "基础规则", "往返经济舱预估", "远途航线按预算保守占用"],
[19, "*", "", "高频城市", "火车", 520, 1040, "兜底", "往返二等座/硬卧预估", "未命中精确城市对时使用"],
[20, "*", "", "高频城市", "飞机", 650, 1300, "兜底", "往返经济舱预估", "未命中精确城市对时使用"],
[21, "*", "", "沿海城市", "火车", 520, 1040, "兜底", "往返二等座/硬卧预估", "未命中精确城市对时使用"],
[22, "*", "", "沿海城市", "飞机", 700, 1400, "兜底", "往返经济舱预估", "未命中精确城市对时使用"],
[23, "*", "", "远途地区", "火车", 900, 1800, "兜底", "往返二等座/硬卧预估", "未命中精确城市对时使用"],
[24, "*", "", "远途地区", "飞机", 1600, 3200, "兜底", "往返经济舱预估", "未命中精确城市对时使用"],
[25, "*", "", "普通城市", "火车", 360, 720, "兜底", "往返二等座/硬卧预估", "未命中精确城市对时使用"],
[26, "*", "", "普通城市", "飞机", 600, 1200, "兜底", "往返经济舱预估", "未命中精确城市对时使用"],
[27, "*", "", "普通城市", "轮船", 320, 640, "兜底", "往返二等舱预估", "水路交通暂无实时接口时使用"],
],
column_widths=[8, 14, 18, 16, 12, 16, 16, 12, 24, 42],
)
def build_xlsx_bytes_from_source_sheet(source_path: Path, sheet_name: str) -> bytes:
source_workbook = load_workbook(source_path, read_only=False, data_only=False)
try:
if sheet_name not in source_workbook.sheetnames:
raise ValueError("原始规则表中没有对应工作表。")
source_sheet = source_workbook[sheet_name]
target_workbook = Workbook()
target_sheet = target_workbook.active
target_sheet.title = sheet_name
_copy_worksheet(source_sheet, target_sheet)
_clarify_travel_source_sheet_headers(sheet_name, target_sheet)
_remove_redundant_title_row(target_sheet, sheet_name)
target_sheet.sheet_view.zoomScale = 120
target_sheet.sheet_view.zoomScaleNormal = 120
workbook_buffer = BytesIO()
target_workbook.save(workbook_buffer)
target_workbook.close()
return workbook_buffer.getvalue()
finally:
source_workbook.close()
def build_styled_workbook(
sheet_name: str,
headers: list[str],
rows: list[list[object]],
*,
column_widths: list[int],
) -> bytes:
workbook = Workbook()
worksheet = workbook.active
worksheet.title = sheet_name
header_fill = PatternFill(fill_type="solid", fgColor="FFD9EAF7")
thin_side = Side(style="thin", color="FF7F9DB9")
table_border = Border(left=thin_side, right=thin_side, top=thin_side, bottom=thin_side)
for column_index, header in enumerate(headers, start=1):
cell = worksheet.cell(row=1, column=column_index, value=header)
cell.font = Font(name="Microsoft YaHei", size=12, bold=True, color="FF0F172A")
cell.fill = header_fill
cell.border = table_border
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
worksheet.row_dimensions[1].height = 30
for row_index, row in enumerate(rows, start=2):
for column_index, value in enumerate(row, start=1):
cell = worksheet.cell(row=row_index, column=column_index, value=value)
cell.font = Font(name="Microsoft YaHei", size=11, color="FF0F172A")
cell.border = table_border
cell.alignment = Alignment(vertical="center", wrap_text=True)
worksheet.row_dimensions[row_index].height = 30
for column_index, width in enumerate(column_widths, start=1):
worksheet.column_dimensions[_column_letter(column_index)].width = width
worksheet.freeze_panes = "A2"
worksheet.sheet_view.zoomScale = 120
worksheet.sheet_view.zoomScaleNormal = 120
workbook_buffer = BytesIO()
workbook.save(workbook_buffer)
workbook.close()
return workbook_buffer.getvalue()
def _extract_lodging_rows(source_rows: list[tuple[Any, ...]]) -> list[list[object]]:
header_index = -1
indexes: dict[str, int] = {}
expected_headers = {
"seq": "序号",
"region": "地区",
"city": "地区(城市)",
"peak_period": "旺季期间",
"p7": "公司级管理人员、高层经理P7及以上",
"p4": "中层经理、基层经理P4-P6、外聘专家",
"p1": "其他员工",
"regular_limit": "超标限额",
"peak_limit": "旺季超标限额",
}
for row_index, row in enumerate(source_rows[:10]):
values = [str(value or "").strip() for value in row]
if "地区(城市)" not in values:
continue
for key, label in expected_headers.items():
if label in values:
indexes[key] = values.index(label)
header_index = row_index
break
if header_index < 0 or "city" not in indexes:
return []
rows: list[list[object]] = []
for row in source_rows[header_index + 1 :]:
region = _row_value(row, indexes.get("region", -1))
raw_city = _row_value(row, indexes.get("city", -1))
cities = _split_location_names(raw_city)
if not cities:
continue
period_by_city, shared_period = _parse_peak_periods(
_row_value(row, indexes.get("peak_period", -1))
)
for city in cities:
period = period_by_city.get(_normalize_period_key(city), shared_period)
rows.append(
[
_row_value(row, indexes.get("seq", -1)),
region,
city,
period,
_row_value(row, indexes.get("p7", -1)),
_row_value(row, indexes.get("p4", -1)),
_row_value(row, indexes.get("p1", -1)),
_row_value(row, indexes.get("regular_limit", -1)),
_row_value(row, indexes.get("peak_limit", -1)) if period else "",
]
)
return rows
def _fallback_lodging_rows(fallback_rows: list[list[object]]) -> list[list[object]]:
rows: list[list[object]] = []
for index, row in enumerate(fallback_rows[1:], start=1):
if len(row) >= 11:
junior_amount = row[5]
manager_amount = row[8]
executive_amount = row[10]
else:
junior_amount = row[2] if len(row) > 2 else ""
manager_amount = row[3] if len(row) > 3 else ""
executive_amount = row[4] if len(row) > 4 else ""
rows.append(
[
index,
"",
row[0] if len(row) > 0 else "",
"",
executive_amount,
manager_amount,
junior_amount,
executive_amount,
"",
]
)
return rows
def _expand_lodging_grade_amounts(row: list[object]) -> list[object]:
executive_amount = row[4] if len(row) > 4 else ""
manager_amount = row[5] if len(row) > 5 else ""
junior_amount = row[6] if len(row) > 6 else ""
return [
junior_amount,
junior_amount,
junior_amount,
junior_amount,
manager_amount,
manager_amount,
manager_amount,
executive_amount,
executive_amount,
]
def _grade_usage_note(grade: str) -> str:
if grade == "P8":
return "最高职级,适用于董事会"
if grade in {"P6", "P7"}:
return "适用于中高层管理人员"
if grade in {"P4", "P5"}:
return "适用于主管及基层管理人员"
return "适用于员工序列"
def _split_location_names(value: object) -> list[str]:
text = str(value or "").strip()
if not text:
return []
text = re.sub(r"[(].*?[)]", "", text)
text = re.sub(r"^\s*\d+\s*个中心城区[、,]?", "", text)
text = re.sub(r"[;,/]+", "", text)
names: list[str] = []
for part in text.split(""):
cleaned = _normalize_location_name(part)
if not cleaned or cleaned == "中心城区":
continue
names.append(cleaned)
return list(dict.fromkeys(names))
def _parse_peak_periods(value: object) -> tuple[dict[str, str], str]:
text = str(value or "").strip()
if not text:
return ({}, "")
period_by_city: dict[str, str] = {}
for part in re.split(r"[;]", text):
if "" not in part and ":" not in part:
continue
city, period = re.split(r"[:]", part, maxsplit=1)
normalized_city = _normalize_period_key(city)
normalized_period = _normalize_peak_period(period)
if normalized_city and normalized_period:
period_by_city[normalized_city] = normalized_period
if period_by_city:
return (period_by_city, "")
return ({}, _normalize_peak_period(text))
def _normalize_peak_period(value: object) -> str:
text = str(value or "").strip()
text = re.sub(r"\s+", "", text)
text = re.sub(r"(月|上旬|中旬|下旬)", "", text)
text = re.sub(r"[、,;;]+", ",", text)
text = re.sub(r"[^0-9,\-]", "", text)
text = re.sub(r",{2,}", ",", text).strip(",")
return text
def _normalize_period_key(value: object) -> str:
return _normalize_location_name(value).removesuffix("")
def _normalize_location_name(value: object) -> str:
text = str(value or "").strip()
text = re.sub(r"\s+", "", text)
text = text.removesuffix("")
if text != "其他地区":
text = text.removesuffix("地区")
return text
def _row_value(row: tuple[Any, ...], index: int) -> object:
if index < 0 or index >= len(row):
return ""
return "" if row[index] is None else row[index]
def _copy_worksheet(source_sheet, target_sheet) -> None:
target_sheet.freeze_panes = source_sheet.freeze_panes
target_sheet.sheet_format = copy(source_sheet.sheet_format)
target_sheet.sheet_properties = copy(source_sheet.sheet_properties)
target_sheet.page_margins = copy(source_sheet.page_margins)
target_sheet.page_setup = copy(source_sheet.page_setup)
target_sheet.print_options = copy(source_sheet.print_options)
for row in source_sheet.iter_rows():
for source_cell in row:
target_cell = target_sheet[source_cell.coordinate]
target_cell.value = source_cell.value
if source_cell.has_style:
target_cell.font = copy(source_cell.font)
target_cell.fill = copy(source_cell.fill)
target_cell.border = copy(source_cell.border)
target_cell.alignment = copy(source_cell.alignment)
target_cell.protection = copy(source_cell.protection)
target_cell.number_format = source_cell.number_format
if source_cell.hyperlink:
target_cell._hyperlink = copy(source_cell.hyperlink)
if source_cell.comment:
target_cell.comment = copy(source_cell.comment)
for merged_range in source_sheet.merged_cells.ranges:
target_sheet.merge_cells(str(merged_range))
for key, source_dimension in source_sheet.column_dimensions.items():
target_dimension = target_sheet.column_dimensions[key]
target_dimension.width = source_dimension.width
target_dimension.hidden = source_dimension.hidden
target_dimension.bestFit = source_dimension.bestFit
target_dimension.outlineLevel = source_dimension.outlineLevel
target_dimension.collapsed = source_dimension.collapsed
for index, source_dimension in source_sheet.row_dimensions.items():
target_dimension = target_sheet.row_dimensions[index]
target_dimension.height = source_dimension.height
target_dimension.hidden = source_dimension.hidden
target_dimension.outlineLevel = source_dimension.outlineLevel
target_dimension.collapsed = source_dimension.collapsed
def _clarify_travel_source_sheet_headers(sheet_name: str, worksheet) -> None:
if sheet_name == "交通工具等级标准":
worksheet["A4"] = "P5+"
worksheet["A5"] = "P1-P4"
worksheet.row_dimensions[4].height = max(worksheet.row_dimensions[4].height or 0, 42)
worksheet.row_dimensions[5].height = max(worksheet.row_dimensions[5].height or 0, 42)
worksheet.column_dimensions["A"].width = max(worksheet.column_dimensions["A"].width or 0, 18)
def _remove_redundant_title_row(worksheet, title: str) -> None:
first_cell_value = str(worksheet["A1"].value or "").strip()
if first_cell_value != str(title or "").strip():
return
has_other_first_row_values = any(
str(worksheet.cell(row=1, column=column_index).value or "").strip()
for column_index in range(2, worksheet.max_column + 1)
)
if has_other_first_row_values:
return
shifted_merged_ranges: list[tuple[int, int, int, int]] = []
for merged_range in list(worksheet.merged_cells.ranges):
range_text = str(merged_range)
min_col = merged_range.min_col
min_row = merged_range.min_row
max_col = merged_range.max_col
max_row = merged_range.max_row
worksheet.unmerge_cells(range_text)
if min_row <= 1:
continue
shifted_merged_ranges.append((min_col, min_row - 1, max_col, max_row - 1))
old_freeze_panes = worksheet.freeze_panes
worksheet.delete_rows(1, 1)
for min_col, min_row, max_col, max_row in shifted_merged_ranges:
worksheet.merge_cells(
start_row=min_row,
start_column=min_col,
end_row=max_row,
end_column=max_col,
)
worksheet.freeze_panes = _shift_freeze_panes_after_deleted_first_row(old_freeze_panes)
def _shift_freeze_panes_after_deleted_first_row(freeze_panes: object) -> str | None:
if not freeze_panes:
return None
coordinate = str(freeze_panes)
match = re.fullmatch(r"([A-Z]+)([0-9]+)", coordinate)
if not match:
return coordinate
column, row_text = match.groups()
row_index = int(row_text)
if row_index <= 1:
return None
return f"{column}{row_index - 1}"
def _column_letter(index: int) -> str:
value = max(1, int(index))
result = ""
while value > 0:
value, remainder = divmod(value - 1, 26)
result = f"{chr(65 + remainder)}{result}"
return result

View File

@@ -74,7 +74,7 @@ class AgentAssetService(
) -> list[AgentAssetListItem]:
self._ensure_ready()
if asset_type in {None, "", AgentAssetType.RULE.value}:
self.sync_platform_risk_rules_from_library()
self.sync_rule_assets_from_libraries()
assets = self.repository.list(
asset_type=asset_type, status=status, domain=domain, keyword=keyword
)
@@ -94,7 +94,7 @@ class AgentAssetService(
) -> PageResult[AgentAssetListItem]:
self._ensure_ready()
if asset_type in {None, "", AgentAssetType.RULE.value}:
self.sync_platform_risk_rules_from_library()
self.sync_rule_assets_from_libraries()
assets = self.repository.list(
asset_type=asset_type,
status=status,
@@ -552,6 +552,13 @@ class AgentAssetService(
self.db.commit()
return manifest_count
def sync_rule_assets_from_libraries(self) -> int:
foundation = AgentFoundationService(self.db)
synced_count = foundation.sync_finance_rule_assets_from_catalog()
synced_count += foundation.sync_platform_risk_rules_from_library()
self.db.commit()
return synced_count
def _validate_version_payload(
self, asset: AgentAsset, payload: AgentAssetVersionCreate
) -> None:

View File

@@ -19,6 +19,7 @@ STATEFUL_CONTEXT_KEYS = (
"ocr_summary",
"ocr_documents",
"review_form_values",
"steward_state",
"business_time_context",
)
REVIEW_FLOW_CONTEXT_KEYS = {

View File

@@ -17,6 +17,7 @@ from app.core.logging import get_logger
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
from app.services.agent_asset_spreadsheet import (
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_PREAPPROVAL_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
FINANCE_RULES_LIBRARY,
AgentAssetSpreadsheetManager,
@@ -26,6 +27,8 @@ from app.services.agent_foundation_constants import (
ATTACHMENT_RULE_RUNTIME_CONFIG,
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
COMPANY_COMMUNICATION_RULE_VERSION,
COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON,
COMPANY_PREAPPROVAL_RULE_VERSION,
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
COMPANY_TRAVEL_RULE_VERSION,
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE,
@@ -267,7 +270,7 @@ class AgentFoundationAssetSeedMixin:
config_json={
"severity": "medium",
"enabled": True,
"tag": "财务规则",
"tag": "基础规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
@@ -293,7 +296,7 @@ class AgentFoundationAssetSeedMixin:
config_json={
"severity": "medium",
"enabled": True,
"tag": "财务规则",
"tag": "基础规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
@@ -301,6 +304,35 @@ class AgentFoundationAssetSeedMixin:
"rule_template_label": "通信费报销 Excel 模板",
},
)
company_preapproval_rule = AgentAsset(
asset_type=AgentAssetType.RULE.value,
code=COMPANY_PREAPPROVAL_RULE_CODE,
name="公司费用申请审批规则",
description="通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON),
owner="财务制度管理组",
reviewer="顾承宣",
status=AgentAssetStatus.ACTIVE.value,
current_version=COMPANY_PREAPPROVAL_RULE_VERSION,
published_version=COMPANY_PREAPPROVAL_RULE_VERSION,
working_version=COMPANY_PREAPPROVAL_RULE_VERSION,
config_json={
"severity": "high",
"enabled": True,
"tag": "申请规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
"ai_review_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"expense_types": ["meal", "entertainment", "office", "all"],
"business_stage": ["expense_application", "reimbursement"],
"budget_required": True,
"rule_template_label": "费用申请审批 Excel 模板",
},
)
skill_expense_asset = AgentAsset(
asset_type=AgentAssetType.SKILL.value,
code="skill.expense.summary_lookup",
@@ -468,6 +500,7 @@ class AgentFoundationAssetSeedMixin:
*platform_risk_assets,
company_travel_rule,
company_communication_rule,
company_preapproval_rule,
skill_expense_asset,
skill_ar_asset,
invoice_mcp_asset,
@@ -495,6 +528,11 @@ class AgentFoundationAssetSeedMixin:
version=COMPANY_COMMUNICATION_RULE_VERSION,
actor_name="系统初始化",
)
company_preapproval_rule_meta = self._ensure_company_preapproval_rule_spreadsheet_seed(
company_preapproval_rule,
version=COMPANY_PREAPPROVAL_RULE_VERSION,
actor_name="系统初始化",
)
self._hide_deprecated_finance_rule_assets()
@@ -581,6 +619,18 @@ class AgentFoundationAssetSeedMixin:
change_note="初始化通信费报销 Excel 规则表。",
created_by="系统初始化",
),
AgentAssetVersion(
asset=company_preapproval_rule,
version=COMPANY_PREAPPROVAL_RULE_VERSION,
content=AgentAssetSpreadsheetManager.build_version_markdown(
rule_name=company_preapproval_rule.name,
version=COMPANY_PREAPPROVAL_RULE_VERSION,
metadata=company_preapproval_rule_meta,
),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note="初始化费用申请审批 Excel 规则表。",
created_by="系统初始化",
),
AgentAssetVersion(
asset=skill_expense_asset,
version="v1.0.0",
@@ -679,7 +729,7 @@ class AgentFoundationAssetSeedMixin:
version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇",
review_status=AgentReviewStatus.APPROVED.value,
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
reviewed_at=datetime.now(UTC),
),
AgentAssetReview(
@@ -687,7 +737,7 @@ class AgentFoundationAssetSeedMixin:
version=COMPANY_COMMUNICATION_RULE_VERSION,
reviewer="顾承宇",
review_status=AgentReviewStatus.APPROVED.value,
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
reviewed_at=datetime.now(UTC),
),
]

View File

@@ -16,6 +16,7 @@ from app.models.agent_asset import AgentAsset
from app.models.agent_run import AgentRun
from app.services.agent_asset_spreadsheet import (
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_PREAPPROVAL_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
FINANCE_RULES_LIBRARY,
AgentAssetSpreadsheetManager,
@@ -25,6 +26,8 @@ from app.services.agent_foundation_constants import (
ATTACHMENT_RULE_RUNTIME_CONFIG,
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
COMPANY_COMMUNICATION_RULE_VERSION,
COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON,
COMPANY_PREAPPROVAL_RULE_VERSION,
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
COMPANY_TRAVEL_RULE_VERSION,
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE,
@@ -115,6 +118,10 @@ class AgentFoundationAssetTopUpMixin:
select(AgentAsset).where(AgentAsset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE)
)
company_preapproval_rule = self.db.scalar(
select(AgentAsset).where(AgentAsset.code == COMPANY_PREAPPROVAL_RULE_CODE)
)
if ATTACHMENT_RULE_ASSET_CODE not in existing_codes:
attachment_rule = self._create_seed_asset(
@@ -361,7 +368,7 @@ class AgentFoundationAssetTopUpMixin:
config_json={
"severity": "medium",
"enabled": True,
"tag": "财务规则",
"tag": "基础规则",
"detail_mode": "spreadsheet",
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
"ai_review_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
@@ -384,7 +391,7 @@ class AgentFoundationAssetTopUpMixin:
config_json={
"severity": "medium",
"enabled": True,
"tag": "财务规则",
"tag": "基础规则",
"detail_mode": "spreadsheet",
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
"ai_review_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
@@ -392,6 +399,36 @@ class AgentFoundationAssetTopUpMixin:
},
)
if COMPANY_PREAPPROVAL_RULE_CODE not in existing_codes:
company_preapproval_rule = self._create_seed_asset(
asset_type=AgentAssetType.RULE.value,
code=COMPANY_PREAPPROVAL_RULE_CODE,
name="公司费用申请审批规则",
description="通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON),
owner="财务制度管理组",
reviewer="顾承宣",
status=AgentAssetStatus.ACTIVE.value,
current_version=COMPANY_PREAPPROVAL_RULE_VERSION,
config_json={
"severity": "high",
"enabled": True,
"tag": "申请规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
"ai_review_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"expense_types": ["meal", "entertainment", "office", "all"],
"business_stage": ["expense_application", "reimbursement"],
"budget_required": True,
"rule_template_label": "费用申请审批 Excel 模板",
},
)
if company_travel_rule is not None:
company_travel_rule.scenario_json = list(COMPANY_TRAVEL_RULE_SCENARIO_JSON)
if not str(company_travel_rule.current_version or "").strip():
@@ -416,7 +453,7 @@ class AgentFoundationAssetTopUpMixin:
**(company_travel_rule.config_json or {}),
"severity": "medium",
"enabled": True,
"tag": "财务规则",
"tag": "基础规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
@@ -452,7 +489,7 @@ class AgentFoundationAssetTopUpMixin:
version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇",
review_status=AgentReviewStatus.APPROVED.value,
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
reviewed_at=datetime.now(UTC),
)
@@ -486,7 +523,7 @@ class AgentFoundationAssetTopUpMixin:
**(company_communication_rule.config_json or {}),
"severity": "medium",
"enabled": True,
"tag": "财务规则",
"tag": "基础规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
@@ -532,7 +569,78 @@ class AgentFoundationAssetTopUpMixin:
version=COMPANY_COMMUNICATION_RULE_VERSION,
reviewer="顾承宇",
review_status=AgentReviewStatus.APPROVED.value,
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
reviewed_at=datetime.now(UTC),
)
if company_preapproval_rule is not None:
company_preapproval_rule.scenario_json = list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON)
if not str(company_preapproval_rule.current_version or "").strip():
company_preapproval_rule.current_version = COMPANY_PREAPPROVAL_RULE_VERSION
if not str(company_preapproval_rule.working_version or "").strip():
company_preapproval_rule.working_version = company_preapproval_rule.current_version
if not str(company_preapproval_rule.published_version or "").strip():
company_preapproval_rule.published_version = company_preapproval_rule.current_version
if not str(company_preapproval_rule.status or "").strip():
company_preapproval_rule.status = AgentAssetStatus.ACTIVE.value
company_preapproval_rule.description = (
"通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。"
)
company_preapproval_rule.config_json = {
**(company_preapproval_rule.config_json or {}),
"severity": "high",
"enabled": True,
"tag": "申请规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
"ai_review_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"expense_types": ["meal", "entertainment", "office", "all"],
"business_stage": ["expense_application", "reimbursement"],
"budget_required": True,
"rule_template_label": "费用申请审批 Excel 模板",
}
company_preapproval_rule_meta = self._ensure_company_preapproval_rule_spreadsheet_seed(
company_preapproval_rule,
version=str(
company_preapproval_rule.current_version
or COMPANY_PREAPPROVAL_RULE_VERSION
),
actor_name="系统初始化",
)
self._ensure_asset_version(
company_preapproval_rule,
version=str(
company_preapproval_rule.current_version
or COMPANY_PREAPPROVAL_RULE_VERSION
),
content=AgentAssetSpreadsheetManager.build_version_markdown(
rule_name=company_preapproval_rule.name,
version=str(
company_preapproval_rule.current_version
or COMPANY_PREAPPROVAL_RULE_VERSION
),
metadata=company_preapproval_rule_meta,
),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note="初始化费用申请审批 Excel 规则表。",
created_by="系统初始化",
)
if (
str(company_preapproval_rule.current_version or "").strip()
== COMPANY_PREAPPROVAL_RULE_VERSION
):
self._ensure_asset_review(
company_preapproval_rule,
version=COMPANY_PREAPPROVAL_RULE_VERSION,
reviewer="顾承宣",
review_status=AgentReviewStatus.APPROVED.value,
review_note="首版费用申请审批规则表已确认,可作为申请规则使用。",
reviewed_at=datetime.now(UTC),
)

View File

@@ -82,10 +82,14 @@ COMPANY_TRAVEL_RULE_VERSION = "v1.0.0"
COMPANY_COMMUNICATION_RULE_VERSION = "v1.0.0"
COMPANY_PREAPPROVAL_RULE_VERSION = "v1.0.0"
COMPANY_TRAVEL_RULE_SCENARIO_JSON = ("差旅费",)
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON = ("通信费",)
COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON = ("费用申请",)
DIGITAL_EMPLOYEE_SKILL_CATEGORIES = ("积累", "升级", "整理", "评估")
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE = "task.hermes.finance_policy_knowledge_organize"

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
def build_preapproval_rule_workbook_sheets() -> list[tuple[str, list[list[object]]]]:
return [
(
"费用申请审批规则",
[
[
"费用类型代码",
"费用类型",
"触发条件",
"阈值金额",
"前置要求",
"审批要求",
"风险动作",
"备注",
],
[
"meal/entertainment",
"业务招待费",
"单次费用金额大于 500 元",
500,
"必须先提交费用申请单,并说明客户、参与人和招待事由",
"申请单需按审批链完成审批后方可报销",
"报销阶段未关联已通过申请单时标记高风险",
"适配 meal 与 entertainment 两个本体费用类型",
],
[
"office",
"办公用品费",
"单次或批量采购金额大于 2000 元",
2000,
"必须先提交办公采购或费用申请单",
"申请单需经直属领导审批;如触发预算管控则继续预算复核",
"报销阶段未关联已通过申请单时标记高风险",
"覆盖办公用品、办公耗材、低值易耗品等场景",
],
[
"all",
"通用大额费用",
"任意费用金额大于 2000 元",
2000,
"必须进入费用申请和审批流程",
"至少完成直属领导审批;按预算和基础规则继续流转",
"报销阶段未关联已通过申请单时标记高风险",
"差旅、通信等已有专项规则时可同时适用专项规则",
],
],
),
(
"字段说明",
[
["字段", "说明"],
["费用类型代码", "使用系统本体费用类型,不新增非本体字段"],
["阈值金额", "单位为人民币元,执行时按 claim.amount 进行数值比较"],
["前置要求", "说明是否需要事前申请以及申请单需要包含的信息"],
["审批要求", "说明申请单进入审批链后的最低审批要求"],
["风险动作", "说明报销阶段未满足规则时的系统处理"],
],
),
]

View File

@@ -5,6 +5,10 @@ from pathlib import Path
from sqlalchemy import select
from app.core.agent_enums import (
AgentAssetContentType,
AgentAssetDomain,
AgentAssetType,
AgentReviewStatus,
AgentAssetStatus,
)
from app.core.logging import get_logger
@@ -12,19 +16,38 @@ from app.models.agent_asset import AgentAsset
from app.services.agent_asset_spreadsheet import (
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_ALLOWANCE_RULE_CODE,
COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME,
COMPANY_PREAPPROVAL_RULE_CODE,
COMPANY_PREAPPROVAL_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE,
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME,
COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE,
COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME,
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME,
COMPANY_TRAVEL_TRANSPORT_RULE_CODE,
COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME,
FINANCE_RULES_LIBRARY,
AgentAssetSpreadsheetManager,
)
from app.services.agent_foundation_constants import (
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
COMPANY_COMMUNICATION_RULE_VERSION,
COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON,
COMPANY_PREAPPROVAL_RULE_VERSION,
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
COMPANY_TRAVEL_RULE_VERSION,
)
from app.services.finance_rule_catalog import (
DEPRECATED_FINANCE_RULE_CODES,
DEPRECATED_FINANCE_RULE_REPLACEMENTS,
)
from app.services.agent_foundation_preapproval_spreadsheet import (
build_preapproval_rule_workbook_sheets,
)
logger = get_logger("app.services.agent_foundation")
@@ -41,17 +64,131 @@ class AgentFoundationSpreadsheetMixin:
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_TRAVEL_EXPENSE_RULE_CODE,
name="差旅住宿报销标准",
description="按地区和职级维护差旅住宿费报销上限。",
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="差旅住宿费标准",
expense_types=["travel", "hotel", "transport"],
expense_types=["hotel"],
version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇",
file_name=COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
workbook_content=AgentAssetSpreadsheetManager.build_travel_lodging_rule_template(),
rule_template_label="差旅住宿 Excel 模板",
travel_policy_component="lodging",
)
)
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_TRAVEL_ALLOWANCE_RULE_CODE,
name="出差补助报销标准",
description="按地区维护伙食补助、基本出差补贴和补助合计。",
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="出差补助标准",
expense_types=["travel"],
version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇",
file_name=COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME,
workbook_content=AgentAssetSpreadsheetManager.build_travel_allowance_rule_template(),
rule_template_label="出差补助 Excel 模板",
travel_policy_component="allowance",
)
)
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_TRAVEL_TRANSPORT_RULE_CODE,
name="交通工具等级标准",
description="按员工职级维护飞机、火车等长途交通工具等级。",
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="交通工具等级标准",
expense_types=["travel", "transport"],
version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇",
file_name=COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME,
workbook_content=AgentAssetSpreadsheetManager.build_travel_transport_rule_template(),
rule_template_label="交通工具等级 Excel 模板",
travel_policy_component="transport",
)
)
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
name="交通费用预估表",
description="按出发城市、目的地和交通方式维护申请阶段预算占用的交通费用预估金额。",
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="交通费用预估表",
expense_types=["travel", "transport"],
version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇",
file_name=COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME,
workbook_content=AgentAssetSpreadsheetManager.build_travel_transport_estimate_rule_template(),
rule_template_label="交通费用预估 Excel 模板",
travel_policy_component="transport_estimate",
)
)
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE,
name="差旅职级映射表",
description="明确 P0-P8 九级职级与住宿、交通规则列之间的对应关系,其中 P8 为董事会。",
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="差旅职级映射表",
expense_types=["hotel", "travel", "transport"],
version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇",
file_name=COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME,
workbook_content=AgentAssetSpreadsheetManager.build_travel_grade_mapping_template(),
rule_template_label="差旅职级映射 Excel 模板",
travel_policy_component="grade_mapping",
)
)
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE,
name="地区淡旺季映射表",
description="明确住宿标准中旺季地区、旺季月份和旺季超标限额的对应关系。",
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="地区淡旺季映射表",
expense_types=["hotel", "travel"],
version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇",
file_name=COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME,
workbook_content=AgentAssetSpreadsheetManager.build_travel_season_mapping_template(),
rule_template_label="地区淡旺季映射 Excel 模板",
travel_policy_component="season_mapping",
)
)
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
name="公司通信费报销规则",
description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。",
scenario_category=COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
finance_rule_sheet="通信费报销标准",
expense_types=["communication"],
version=COMPANY_COMMUNICATION_RULE_VERSION,
reviewer="顾承宇",
file_name=COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
workbook_content=AgentAssetSpreadsheetManager.build_company_communication_rule_template(),
rule_template_label="通信费报销 Excel 模板",
finance_rule_code="expense.communication.policy",
refresh_workbook_content=True,
)
)
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_PREAPPROVAL_RULE_CODE,
name="公司费用申请审批规则",
description="通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。",
scenario_category=COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="费用申请审批规则",
expense_types=["meal", "entertainment", "office", "all"],
version=COMPANY_PREAPPROVAL_RULE_VERSION,
reviewer="顾承宣",
file_name=COMPANY_PREAPPROVAL_RULE_FILENAME,
workbook_content=None,
rule_template_label="费用申请审批 Excel 模板",
finance_rule_code="expense.preapproval.policy",
tag="申请规则",
)
)
return synced_count
@@ -60,30 +197,183 @@ class AgentFoundationSpreadsheetMixin:
self,
*,
code: str,
name: str,
description: str,
scenario_category: str,
finance_rule_sheet: str,
expense_types: list[str],
version: str,
reviewer: str,
file_name: str,
workbook_content: bytes | None,
rule_template_label: str,
finance_rule_code: str | None = None,
travel_policy_component: str = "",
tag: str = "基础规则",
refresh_workbook_content: bool = False,
) -> bool:
asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code))
created_asset = asset is None
if asset is None:
return False
asset = self._create_seed_asset(
asset_type=AgentAssetType.RULE.value,
code=code,
name=name,
description=description,
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=[scenario_category],
owner="财务制度管理组",
reviewer=reviewer,
status=AgentAssetStatus.ACTIVE.value,
current_version=version,
config_json={
"severity": "medium",
"enabled": True,
"tag": tag,
"rule_tag": tag,
"tags": [tag],
"rule_tags": [tag],
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": scenario_category,
"ai_review_category": scenario_category,
"finance_rule_code": code,
"finance_rule_sheet": finance_rule_sheet,
"expense_types": expense_types,
"business_stage": ["expense_application", "reimbursement"],
"budget_required": True,
"rule_template_label": rule_template_label,
},
)
else:
asset.name = name
asset.description = description
asset.owner = asset.owner or "财务制度管理组"
asset.reviewer = asset.reviewer or reviewer
if not str(asset.current_version or "").strip():
asset.current_version = version
if not str(asset.working_version or "").strip():
asset.working_version = asset.current_version
if not str(asset.published_version or "").strip():
asset.published_version = asset.current_version
if not str(asset.status or "").strip() or asset.status == AgentAssetStatus.DISABLED.value:
asset.status = AgentAssetStatus.ACTIVE.value
asset.scenario_json = [scenario_category]
asset.config_json = {
config_json = {
**(asset.config_json or {}),
"enabled": True,
"tag": "财务规则",
"tag": tag,
"rule_tag": tag,
"tags": [tag],
"rule_tags": [tag],
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": scenario_category,
"ai_review_category": scenario_category,
"finance_rule_code": code,
"finance_rule_code": finance_rule_code or code,
"finance_rule_sheet": finance_rule_sheet,
"expense_types": expense_types,
"business_stage": ["expense_application", "reimbursement"],
"budget_required": True,
"rule_template_label": rule_template_label,
}
if travel_policy_component:
config_json["travel_policy_component"] = travel_policy_component
asset.config_json = config_json
rule_document = (asset.config_json or {}).get("rule_document")
has_rule_document = isinstance(rule_document, dict) and bool(
str(rule_document.get("storage_key") or "").strip()
)
if workbook_content is not None and (
created_asset or not has_rule_document or refresh_workbook_content
):
self._ensure_finance_rule_asset_document(
asset,
version=version,
reviewer=reviewer,
file_name=file_name,
content=workbook_content,
force_live_document=refresh_workbook_content,
)
return True
def _ensure_finance_rule_asset_document(
self,
asset: AgentAsset,
*,
version: str,
reviewer: str,
file_name: str,
content: bytes,
force_live_document: bool = False,
) -> None:
manager = AgentAssetSpreadsheetManager()
manager.ensure_rule_library_dirs()
rule_document = (asset.config_json or {}).get("rule_document")
storage_key = (
str(rule_document.get("storage_key") or "").strip()
if isinstance(rule_document, dict)
else ""
)
should_seed_file = force_live_document or not storage_key
if storage_key:
try:
current_path = manager.resolve_storage_path(storage_key)
except FileNotFoundError:
current_path = None
should_seed_file = should_seed_file or current_path is None or not current_path.exists()
if should_seed_file:
metadata = manager.store_rule_library_spreadsheet(
library=FINANCE_RULES_LIBRARY,
file_name=file_name,
content=content,
actor_name="系统初始化",
source="rule-library",
)
asset.config_json = {
**(asset.config_json or {}),
"rule_document": {
**AgentAssetSpreadsheetManager.build_rule_document_config(
metadata,
asset_version=version,
),
"storage_key": metadata.storage_key,
},
}
else:
metadata = manager.store_rule_library_spreadsheet_snapshot(
library=FINANCE_RULES_LIBRARY,
asset_id=asset.id,
version=version,
file_name=file_name,
content=content,
actor_name="系统初始化",
source="rule-library-version",
)
self._ensure_asset_version(
asset,
version=version,
content=AgentAssetSpreadsheetManager.build_version_markdown(
rule_name=asset.name,
version=version,
metadata=metadata,
),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note=f"初始化{asset.name} Excel 规则表。",
created_by="系统初始化",
)
self._ensure_asset_review(
asset,
version=version,
reviewer=reviewer,
review_status=AgentReviewStatus.APPROVED.value,
review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
reviewed_at=None,
)
def _hide_deprecated_finance_rule_assets(self) -> None:
for code in DEPRECATED_FINANCE_RULE_CODES:
asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code))
@@ -92,14 +382,19 @@ class AgentFoundationSpreadsheetMixin:
asset.status = AgentAssetStatus.DISABLED.value
asset.scenario_json = ["已废弃"]
replacement = DEPRECATED_FINANCE_RULE_REPLACEMENTS.get(code)
deprecated_reason = (
"交通/住宿细分并入公司差旅费报销规则,不再作为独立财务规则展示。"
if replacement
else (
"该费用类型没有独立职务金额分档,额度控制转入预算中心,"
"不再作为独立财务规则表展示。"
if replacement == COMPANY_TRAVEL_EXPENSE_RULE_CODE:
deprecated_reason = (
"交通/住宿细分并入公司差旅费报销规则,不再作为独立基础规则展示。"
)
elif replacement == COMPANY_PREAPPROVAL_RULE_CODE:
deprecated_reason = (
"申请审批阈值已并入公司费用申请审批规则,不再作为独立基础规则展示。"
)
else:
deprecated_reason = (
"该费用类型没有独立职务金额分档,额度控制转入预算中心,"
"不再作为独立基础规则表展示。"
)
)
asset.config_json = {
**(asset.config_json or {}),
"enabled": False,
@@ -180,7 +475,10 @@ class AgentFoundationSpreadsheetMixin:
"detail_mode": "spreadsheet",
"tag": "财务规则",
"tag": "基础规则",
"rule_tag": "基础规则",
"tags": ["基础规则"],
"rule_tags": ["基础规则"],
"rule_library": FINANCE_RULES_LIBRARY,
@@ -208,7 +506,10 @@ class AgentFoundationSpreadsheetMixin:
"detail_mode": "spreadsheet",
"tag": "财务规则",
"tag": "基础规则",
"rule_tag": "基础规则",
"tags": ["基础规则"],
"rule_tags": ["基础规则"],
"rule_library": FINANCE_RULES_LIBRARY,
@@ -258,6 +559,37 @@ class AgentFoundationSpreadsheetMixin:
)
def _ensure_company_preapproval_rule_spreadsheet_seed(
self,
asset: AgentAsset,
*,
version: str,
actor_name: str,
):
return self._ensure_finance_rule_spreadsheet_seed(
asset,
version=version,
actor_name=actor_name,
file_name=COMPANY_PREAPPROVAL_RULE_FILENAME,
fallback_sheet_name="费用申请审批规则",
tag="申请规则",
workbook_sheets=build_preapproval_rule_workbook_sheets(),
)
@staticmethod
def _read_or_build_company_travel_rule_file(
@@ -282,7 +614,7 @@ class AgentFoundationSpreadsheetMixin:
return live_path.read_bytes()
return AgentAssetSpreadsheetManager.build_blank_rule_workbook("差旅费报销规则")
return AgentAssetSpreadsheetManager.build_travel_lodging_rule_template()
def _ensure_finance_rule_spreadsheet_seed(
@@ -301,6 +633,7 @@ class AgentFoundationSpreadsheetMixin:
fallback_sheet_name: str,
workbook_sheets: list[tuple[str, list[list[object]]]] | None = None,
tag: str = "基础规则",
):
@@ -370,7 +703,10 @@ class AgentFoundationSpreadsheetMixin:
"detail_mode": "spreadsheet",
"tag": "财务规则",
"tag": tag,
"rule_tag": tag,
"tags": [tag],
"rule_tags": [tag],
"rule_library": FINANCE_RULES_LIBRARY,
@@ -398,7 +734,10 @@ class AgentFoundationSpreadsheetMixin:
"detail_mode": "spreadsheet",
"tag": "财务规则",
"tag": tag,
"rule_tag": tag,
"tags": [tag],
"rule_tags": [tag],
"rule_library": FINANCE_RULES_LIBRARY,

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import json
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
@@ -24,6 +25,34 @@ logger = get_logger("app.services.agent_runs")
KNOWLEDGE_SYNC_HEARTBEAT_TIMEOUT = timedelta(minutes=30)
KNOWLEDGE_SYNC_JOB_TYPES = {"knowledge_index_sync", "llm_wiki_sync"}
LIST_ROUTE_FIELDS = (
("route_job_type", "job_type"),
("route_task_type", "task_type"),
("route_task_code", "task_code"),
("route_task_name", "task_name"),
("route_task_title", "task_title"),
("route_asset_name", "asset_name"),
("route_selected_agent", "selected_agent"),
("route_phase", "phase"),
("route_stage", "stage"),
("route_report_type", "report_type"),
("route_snapshot_key", "snapshot_key"),
("route_folder", "folder"),
("route_heartbeat_at", "heartbeat_at"),
)
LIST_ONTOLOGY_FIELDS = (
("ontology_scenario", "scenario"),
("ontology_intent", "intent"),
("ontology_parse_strategy", "parse_strategy"),
)
LIST_PROGRESS_FIELDS = {
"percent",
"total_documents",
"completed_documents",
"failed_documents",
"skipped_documents",
"current_stage",
}
class AgentRunService:
@@ -41,8 +70,22 @@ class AgentRunService:
) -> list[AgentRunRead]:
self._ensure_ready()
self._reconcile_stale_knowledge_index_runs()
runs = self.repository.list(agent=agent, status=status, source=source, limit=limit)
return [self._serialize_run(item) for item in runs]
rows = self.repository.list_light(
agent=agent,
status=status,
source=source,
limit=limit,
)
tool_calls_by_run_id = self._group_light_tool_calls(
self.repository.list_light_tool_calls([str(item["run_id"]) for item in rows])
)
return [
self._serialize_run_list_item(
item,
tool_calls_by_run_id.get(str(item["run_id"]), []),
)
for item in rows
]
def get_run(self, run_id: str) -> AgentRunRead | None:
self._ensure_ready()
@@ -435,3 +478,99 @@ class AgentRunService:
if semantic_parse
else None,
)
def _serialize_run_list_item(
self,
row: dict[str, Any],
tool_calls: list[dict[str, Any]],
) -> AgentRunRead:
return AgentRunRead(
id=str(row["id"]),
run_id=str(row["run_id"]),
agent=str(row["agent"]),
source=str(row["source"]),
user_id=row.get("user_id"),
task_id=row.get("task_id"),
ontology_json=self._build_list_ontology_json(row),
route_json=self._build_list_route_json(row),
permission_level=str(row["permission_level"]),
status=str(row["status"]),
result_summary=row.get("result_summary"),
error_message=row.get("error_message"),
started_at=row["started_at"],
finished_at=row.get("finished_at"),
tool_calls=[self._serialize_light_tool_call(item) for item in tool_calls],
semantic_parse=None,
)
def _build_list_route_json(self, row: dict[str, Any]) -> dict[str, Any]:
payload: dict[str, Any] = {}
for source_key, target_key in LIST_ROUTE_FIELDS:
self._set_if_present(payload, target_key, row.get(source_key))
progress = self._coerce_json_object(row.get("route_progress"))
compact_progress = {
key: value
for key, value in progress.items()
if key in LIST_PROGRESS_FIELDS and self._is_scalar_json_value(value)
}
if compact_progress:
payload["progress"] = compact_progress
return payload
def _build_list_ontology_json(self, row: dict[str, Any]) -> dict[str, Any]:
payload: dict[str, Any] = {}
for source_key, target_key in LIST_ONTOLOGY_FIELDS:
self._set_if_present(payload, target_key, row.get(source_key))
return payload
def _serialize_light_tool_call(self, row: dict[str, Any]) -> AgentToolCallRead:
return AgentToolCallRead(
id=str(row["id"]),
run_id=str(row["run_id"]),
tool_type=str(row["tool_type"]),
tool_name=str(row["tool_name"]),
request_json={},
response_json={},
status=str(row["status"]),
duration_ms=int(row.get("duration_ms") or 0),
error_message=row.get("error_message"),
created_at=row["created_at"],
)
@staticmethod
def _group_light_tool_calls(
tool_calls: list[dict[str, Any]],
) -> dict[str, list[dict[str, Any]]]:
grouped: dict[str, list[dict[str, Any]]] = {}
for tool_call in tool_calls:
grouped.setdefault(str(tool_call.get("run_id") or ""), []).append(tool_call)
return grouped
@staticmethod
def _coerce_json_object(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, str):
normalized = value.strip()
if normalized.startswith("{") and normalized.endswith("}"):
try:
loaded = json.loads(normalized)
except json.JSONDecodeError:
return {}
return loaded if isinstance(loaded, dict) else {}
return {}
@staticmethod
def _set_if_present(payload: dict[str, Any], key: str, value: Any) -> None:
if value is None:
return
if isinstance(value, str) and not value.strip():
return
if not AgentRunService._is_scalar_json_value(value):
return
payload[key] = value
@staticmethod
def _is_scalar_json_value(value: Any) -> bool:
return value is None or isinstance(value, str | int | float | bool)

View File

@@ -5,6 +5,7 @@ from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import func, or_, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session, selectinload
from app.core.config import get_settings
@@ -127,8 +128,15 @@ class AuthService:
if not self.settings.setup_completed:
return None
EmployeeService(self.db).ensure_directory_ready()
employee = self._find_employee_by_email(identifier)
try:
employee = self._find_employee_by_email(identifier)
except SQLAlchemyError:
self.db.rollback()
employee = None
if employee is None:
EmployeeService(self.db).ensure_directory_ready()
employee = self._find_employee_by_email(identifier)
if employee is None or not employee.password_hash:
return None

View File

@@ -23,6 +23,7 @@ from app.services.budget_types import (
SUBJECT_CODE_ALIASES,
SUPPORTED_BUDGET_SUBJECT_CODES,
)
from app.services.document_numbering import is_application_claim_no
from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS
from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
@@ -349,7 +350,11 @@ class BudgetSupportMixin:
def _reservation_source_type_from_claim(claim: ExpenseClaim) -> str:
claim_no = str(claim.claim_no or "").strip().upper()
expense_type = str(claim.expense_type or "").strip().lower()
if claim_no.startswith(("AP-", "APP-")) or expense_type == "application" or expense_type.endswith("_application"):
if (
is_application_claim_no(claim_no)
or expense_type == "application"
or expense_type.endswith("_application")
):
return "application"
return "claim"

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import re
import secrets
from datetime import UTC, datetime
from datetime import datetime
from typing import Callable, Literal
from sqlalchemy import select
@@ -13,20 +13,29 @@ from app.models.financial_record import ExpenseClaim
DocumentNumberKind = Literal["application", "reimbursement", "audit"]
DOCUMENT_NUMBER_PREFIXES: dict[DocumentNumberKind, str] = {
"application": "AP",
"reimbursement": "RE",
"audit": "AD",
"application": "A",
"reimbursement": "R",
"audit": "D",
}
DOCUMENT_NUMBER_TOKEN_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
DOCUMENT_NUMBER_TOKEN_LENGTH = 8
DOCUMENT_NUMBER_SHORT_BODY = (
rf"(?:A|R|D)[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}"
)
DOCUMENT_NUMBER_LEGACY_BODY = (
rf"(?:AP|RE|AD)-\d{{14}}-[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}"
)
DOCUMENT_NUMBER_PATTERN = re.compile(
rf"^(?:AP|RE|AD)-\d{{14}}-[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}$",
rf"^(?:{DOCUMENT_NUMBER_SHORT_BODY}|{DOCUMENT_NUMBER_LEGACY_BODY})$",
flags=re.IGNORECASE,
)
DOCUMENT_NUMBER_EXTRACT_PATTERN = re.compile(
rf"(?:AP|RE|AD)-\d{{14}}-[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}"
rf"(?<![A-Z0-9])(?:"
rf"{DOCUMENT_NUMBER_SHORT_BODY}"
rf"|{DOCUMENT_NUMBER_LEGACY_BODY}"
r"|APP-\d{8}-[A-Z0-9]{6}"
r"|EXP-\d{6}-\d{3}",
r"|EXP-\d{6}-\d{3}"
r")(?![A-Z0-9])",
flags=re.IGNORECASE,
)
@@ -45,16 +54,13 @@ def build_document_number(
token: str | None = None,
) -> str:
prefix = DOCUMENT_NUMBER_PREFIXES[kind]
generated_at = timestamp or datetime.now(UTC)
if generated_at.tzinfo is None:
generated_at = generated_at.replace(tzinfo=UTC)
normalized_token = (token or generate_document_token()).strip().upper()
if not re.fullmatch(
rf"[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}",
normalized_token,
):
raise ValueError("document number token must be 8 chars from the configured alphabet")
return f"{prefix}-{generated_at.astimezone(UTC):%Y%m%d%H%M%S}-{normalized_token}"
return f"{prefix}{normalized_token}"
def generate_unique_expense_claim_no(
@@ -83,4 +89,9 @@ def generate_unique_expense_claim_no(
def is_application_claim_no(value: object) -> bool:
normalized = str(value or "").strip().upper()
return normalized.startswith(("AP-", "APP-"))
return bool(
re.fullmatch(
rf"A[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}",
normalized,
)
) or normalized.startswith(("AP-", "APP-"))

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from collections import Counter
from datetime import UTC, date, datetime
import threading
from typing import Any
from sqlalchemy import select
@@ -81,11 +82,31 @@ def prepare_employee_directory() -> None:
class EmployeeService:
_directory_ready_lock = threading.Lock()
_directory_ready_keys: set[tuple[str, int]] = set()
def __init__(self, db: Session) -> None:
self.db = db
self.repository = EmployeeRepository(db)
@staticmethod
def _bind_cache_key(db: Session) -> tuple[str, int]:
bind = db.get_bind()
return (bind.url.render_as_string(hide_password=True), id(bind.pool))
def ensure_directory_ready(self) -> None:
cache_key = self._bind_cache_key(self.db)
if cache_key in self._directory_ready_keys:
return
with self._directory_ready_lock:
if cache_key in self._directory_ready_keys:
return
self._ensure_directory_ready_uncached()
self._directory_ready_keys.add(cache_key)
def _ensure_directory_ready_uncached(self) -> None:
try:
Base.metadata.create_all(bind=self.db.get_bind())
ensure_employee_schema(self.db)

View File

@@ -11,8 +11,9 @@ from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.models.organization import OrganizationUnit
from app.models.role import Role
from app.services.document_numbering import is_application_claim_no
from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE,
APPLICATION_ARCHIVE_STAGE,
ARCHIVE_ACCOUNTING_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
@@ -28,9 +29,8 @@ APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
BUDGET_APPROVAL_ROLE_CODES = {"budget_monitor", "executive"}
BUDGET_MONITOR_ROLE_CODE = "budget_monitor"
BUDGET_MONITOR_APPROVAL_GRADE = "P8"
CLAIM_DELETE_ROLE_CODES = {"executive"}
ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid")
APPLICATION_ARCHIVED_STAGES = (APPROVAL_DONE_STAGE, "申请归档", "completed")
APPLICATION_ARCHIVED_STAGES = (APPLICATION_ARCHIVE_STAGE,)
ARCHIVED_REIMBURSEMENT_STAGES = (
ARCHIVE_ACCOUNTING_STAGE,
PAYMENT_PAID_STAGE,
@@ -43,6 +43,14 @@ class ExpenseClaimAccessPolicy:
def __init__(self, db: Session) -> None:
self.db = db
@staticmethod
def _build_application_claim_no_condition(claim_no: Any) -> Any:
return or_(
claim_no.like("AP-%"),
claim_no.like("APP-%"),
claim_no.like("A________"),
)
@staticmethod
def has_privileged_claim_access(current_user: CurrentUserContext) -> bool:
if current_user.is_admin:
@@ -62,54 +70,54 @@ class ExpenseClaimAccessPolicy:
normalized_type = func.lower(func.coalesce(ExpenseClaim.expense_type, ""))
claim_no = func.upper(func.coalesce(ExpenseClaim.claim_no, ""))
application_condition = or_(
claim_no.like("AP-%"),
claim_no.like("APP-%"),
ExpenseClaimAccessPolicy._build_application_claim_no_condition(claim_no),
normalized_type == "application",
normalized_type.like("%\\_application", escape="\\"),
)
return or_(
stage.in_(ARCHIVED_REIMBURSEMENT_STAGES),
stage == "completed",
and_(
application_condition,
normalized_status.in_(ARCHIVED_CLAIM_STATUSES),
stage.in_(APPLICATION_ARCHIVED_STAGES),
),
and_(
normalized_status.in_(ARCHIVED_CLAIM_STATUSES),
or_(
stage == "",
stage.is_(None),
stage.in_(ARCHIVED_REIMBURSEMENT_STAGES),
stage == "completed",
reimbursement_condition = and_(
~application_condition,
or_(
stage.in_(ARCHIVED_REIMBURSEMENT_STAGES),
stage == "completed",
and_(
normalized_status.in_(ARCHIVED_CLAIM_STATUSES),
or_(
stage == "",
stage.is_(None),
stage.in_(ARCHIVED_REIMBURSEMENT_STAGES),
stage == "completed",
),
),
),
)
application_archive_condition = and_(
application_condition,
normalized_status.in_(ARCHIVED_CLAIM_STATUSES),
stage.in_(APPLICATION_ARCHIVED_STAGES),
)
return or_(
reimbursement_condition,
application_archive_condition,
)
@staticmethod
def has_claim_delete_access(current_user: CurrentUserContext) -> bool:
if current_user.is_admin:
return True
return bool(ExpenseClaimAccessPolicy.normalize_role_codes(current_user) & CLAIM_DELETE_ROLE_CODES)
return bool(current_user.is_admin)
@staticmethod
def is_archived_claim(claim: ExpenseClaim) -> bool:
normalized_status = str(claim.status or "").strip().lower()
stage = str(claim.approval_stage or "").strip()
if stage in set(ARCHIVED_REIMBURSEMENT_STAGES):
return True
normalized_type = str(claim.expense_type or "").strip().lower()
claim_no = str(claim.claim_no or "").strip().upper()
claim_no = str(claim.claim_no or "").strip()
is_application_claim = (
claim_no.startswith(("AP-", "APP-"))
is_application_claim_no(claim_no)
or normalized_type == "application"
or normalized_type.endswith("_application")
)
if (
is_application_claim
and normalized_status in ARCHIVED_CLAIM_STATUSES
and stage in APPLICATION_ARCHIVED_STAGES
):
if is_application_claim:
return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in APPLICATION_ARCHIVED_STAGES
if stage in set(ARCHIVED_REIMBURSEMENT_STAGES):
return True
return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", *ARCHIVED_REIMBURSEMENT_STAGES}
@@ -247,6 +255,45 @@ class ExpenseClaimAccessPolicy:
return role_code
return BUDGET_MONITOR_ROLE_CODE
@staticmethod
def resolve_claim_finance_owner_name(claim: ExpenseClaim) -> str:
employee = claim.employee
if employee is not None and employee.finance_owner_name:
return str(employee.finance_owner_name).strip()
return ""
def resolve_finance_approver(self, claim: ExpenseClaim) -> Employee | None:
claim_employee_id = str(claim.employee_id or "").strip()
base_stmt = (
select(Employee)
.options(selectinload(Employee.roles))
.where(Employee.roles.any(Role.role_code == "finance"))
)
if claim_employee_id:
base_stmt = base_stmt.where(Employee.id != claim_employee_id)
finance_owner_name = self.resolve_claim_finance_owner_name(claim)
if finance_owner_name:
named_finance = self.db.scalar(
base_stmt
.where(Employee.name == finance_owner_name)
.order_by(Employee.name.asc(), Employee.employee_no.asc())
.limit(1)
)
if named_finance is not None:
return named_finance
owner_matched_finance = self.db.scalar(
base_stmt
.where(func.lower(func.coalesce(Employee.finance_owner_name, "")) == finance_owner_name.lower())
.order_by(Employee.name.asc(), Employee.employee_no.asc())
.limit(1)
)
if owner_matched_finance is not None:
return owner_matched_finance
return self.db.scalar(base_stmt.order_by(Employee.name.asc(), Employee.employee_no.asc()).limit(1))
def attach_budget_approval_snapshot(self, claim: ExpenseClaim | None) -> ExpenseClaim | None:
if claim is None:
return None
@@ -266,9 +313,25 @@ class ExpenseClaimAccessPolicy:
)
return claim
def attach_finance_approval_snapshot(self, claim: ExpenseClaim | None) -> ExpenseClaim | None:
if claim is None:
return None
if str(claim.approval_stage or "").strip() != FINANCE_APPROVAL_STAGE:
return claim
finance_approver = self.resolve_finance_approver(claim)
if finance_approver is not None and finance_approver.name:
setattr(claim, "finance_approver_name", str(finance_approver.name).strip())
return claim
def attach_approval_snapshot(self, claim: ExpenseClaim | None) -> ExpenseClaim | None:
self.attach_budget_approval_snapshot(claim)
self.attach_finance_approval_snapshot(claim)
return claim
def attach_budget_approval_snapshots(self, claims: list[ExpenseClaim]) -> list[ExpenseClaim]:
for claim in claims:
self.attach_budget_approval_snapshot(claim)
self.attach_approval_snapshot(claim)
return claims
@staticmethod
@@ -644,6 +707,11 @@ class ExpenseClaimAccessPolicy:
*,
include_approval_scope: bool = False,
) -> Any:
if current_user.is_admin:
if include_approval_scope:
return stmt
return stmt.where(~self.build_archived_claim_condition())
conditions = self.build_personal_claim_conditions(current_user)
role_codes = self.normalize_role_codes(current_user)
@@ -655,8 +723,9 @@ class ExpenseClaimAccessPolicy:
"%\\_application",
escape="\\",
),
~func.upper(func.coalesce(ExpenseClaim.claim_no, "")).like("AP-%"),
~func.upper(func.coalesce(ExpenseClaim.claim_no, "")).like("APP-%"),
~self._build_application_claim_no_condition(
func.upper(func.coalesce(ExpenseClaim.claim_no, ""))
),
~self.build_archived_claim_condition(),
)
conditions.append(company_reimbursement_condition)

View File

@@ -5,7 +5,11 @@ from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import or_, select
from app.models.financial_record import ExpenseClaim
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.expense_claim_workflow_constants import APPLICATION_ARCHIVE_STAGE
APPLICATION_REIMBURSEMENT_TYPE_MAP = {
@@ -15,6 +19,7 @@ APPLICATION_REIMBURSEMENT_TYPE_MAP = {
"expense_application": "other",
"application": "other",
}
APPLICATION_LINK_FLAG_SOURCES = {"application_handoff", "application_link"}
class ExpenseClaimApplicationHandoffMixin:
@@ -130,3 +135,116 @@ class ExpenseClaimApplicationHandoffMixin:
approval_flag["handoff_event_type"] = "expense_application_to_reimbursement_draft"
approval_flag["handoff_message"] = f"已生成报销草稿 {draft_claim.claim_no}"
return draft_claim
@staticmethod
def _collect_application_references_from_reimbursement(claim: ExpenseClaim) -> tuple[set[str], set[str]]:
application_ids: set[str] = set()
application_nos: set[str] = set()
for flag in list(claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
source = str(flag.get("source") or "").strip()
has_application_reference = any(
str(flag.get(key) or "").strip()
for key in (
"application_claim_id",
"applicationClaimId",
"application_claim_no",
"applicationClaimNo",
)
)
if source not in APPLICATION_LINK_FLAG_SOURCES and not has_application_reference:
continue
application_id = str(flag.get("application_claim_id") or flag.get("applicationClaimId") or "").strip()
application_no = str(flag.get("application_claim_no") or flag.get("applicationClaimNo") or "").strip()
if application_id:
application_ids.add(application_id)
if application_no:
application_nos.add(application_no)
return application_ids, application_nos
def _find_linked_application_claims(self, reimbursement_claim: ExpenseClaim) -> list[ExpenseClaim]:
application_ids, application_nos = self._collect_application_references_from_reimbursement(reimbursement_claim)
conditions = []
if application_ids:
conditions.append(ExpenseClaim.id.in_(application_ids))
if application_nos:
conditions.append(ExpenseClaim.claim_no.in_(application_nos))
if not conditions:
return []
claims = list(self.db.scalars(select(ExpenseClaim).where(or_(*conditions))).all())
return [claim for claim in claims if self._is_expense_application_claim(claim)]
def _archive_linked_applications_after_reimbursement_paid(
self,
*,
reimbursement_claim: ExpenseClaim,
payment_flag: dict[str, Any],
operator: str,
current_user: Any,
) -> list[dict[str, str]]:
archived_applications: list[dict[str, str]] = []
payment_event_id = str(payment_flag.get("payment_event_id") or "").strip()
for application_claim in self._find_linked_application_claims(reimbursement_claim):
previous_status = str(application_claim.status or "").strip()
previous_stage = str(application_claim.approval_stage or "").strip()
if previous_stage == APPLICATION_ARCHIVE_STAGE:
continue
normalized_status = previous_status.lower()
if normalized_status not in {"approved", "completed"}:
continue
before_json = self._serialize_claim(application_claim)
archive_flag = with_risk_business_stage(
{
"source": "application_archive_sync",
"event_type": "expense_application_archived_by_reimbursement",
"archive_event_id": str(uuid.uuid4()),
"severity": "info",
"label": "申请归档",
"message": (
f"关联报销单 {reimbursement_claim.claim_no} 已完成付款,"
"系统同步将申请单归档。"
),
"operator": operator,
"operator_username": getattr(current_user, "username", ""),
"operator_role_codes": [
str(item).strip().lower()
for item in getattr(current_user, "role_codes", [])
if str(item).strip()
],
"application_claim_id": application_claim.id,
"application_claim_no": application_claim.claim_no,
"reimbursement_claim_id": reimbursement_claim.id,
"reimbursement_claim_no": reimbursement_claim.claim_no,
"payment_event_id": payment_event_id,
"previous_status": previous_status,
"previous_approval_stage": previous_stage,
"next_status": "approved",
"next_approval_stage": APPLICATION_ARCHIVE_STAGE,
"created_at": datetime.now(UTC).isoformat(),
},
"expense_application",
)
application_claim.status = "approved"
application_claim.approval_stage = APPLICATION_ARCHIVE_STAGE
application_claim.risk_flags_json = [*list(application_claim.risk_flags_json or []), archive_flag]
archived_applications.append(
{
"application_claim_id": application_claim.id,
"application_claim_no": str(application_claim.claim_no or "").strip(),
"next_approval_stage": APPLICATION_ARCHIVE_STAGE,
}
)
self.audit_service.log_action(
actor=operator,
action="expense_application.archive_by_reimbursement",
resource_type="expense_claim",
resource_id=application_claim.id,
before_json=before_json,
after_json=self._serialize_claim(application_claim),
)
return archived_applications

View File

@@ -2,11 +2,13 @@ from __future__ import annotations
import uuid
from datetime import UTC, datetime
from decimal import Decimal, InvalidOperation
from typing import Any
from app.api.deps import CurrentUserContext
from app.services.budget import BudgetService
from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE,
APPLICATION_LINK_STATUS_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
@@ -62,7 +64,7 @@ class ExpenseClaimApprovalFlowMixin:
if merged_budget_approval:
label = "领导及预算审核通过"
next_status = "approved"
next_stage = APPROVAL_DONE_STAGE
next_stage = APPLICATION_LINK_STATUS_STAGE
default_message = "{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。"
elif requires_budget_review:
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
@@ -73,10 +75,19 @@ class ExpenseClaimApprovalFlowMixin:
default_message = "{operator} 已确认直属领导审核,因预算或风险关注项流转至预算管理者审批。"
else:
next_status = "approved"
next_stage = APPROVAL_DONE_STAGE
next_stage = APPLICATION_LINK_STATUS_STAGE
default_message = "{operator} 已确认直属领导审核,系统判断预算充足且无风险,申请流程完成并生成报销草稿。"
else:
if requires_budget_review:
merged_budget_approval = (
requires_budget_review
and self._access_policy.is_department_p8_budget_monitor(current_user, claim)
)
if merged_budget_approval:
label = "领导及预算审核通过"
next_status = "submitted"
next_stage = FINANCE_APPROVAL_STAGE
default_message = "{operator} 已完成直属领导和预算管理者审核,流转至{next_stage}"
elif requires_budget_review:
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
if next_budget_manager is None:
raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。")
@@ -99,7 +110,7 @@ class ExpenseClaimApprovalFlowMixin:
label = "预算管理者审核通过"
if is_application_claim:
next_status = "approved"
next_stage = APPROVAL_DONE_STAGE
next_stage = APPLICATION_LINK_STATUS_STAGE
default_message = "{operator} 已完成预算审核,申请流程完成并生成报销草稿。"
else:
next_status = "submitted"
@@ -120,6 +131,19 @@ class ExpenseClaimApprovalFlowMixin:
raise ValueError("当前节点不支持审批通过。")
approval_opinion = str(opinion or "").strip()
if (
previous_stage == BUDGET_MANAGER_APPROVAL_STAGE
and self._budget_approval_opinion_required(claim)
and not approval_opinion
):
raise ValueError("预算已超过警戒值,预算管理者需填写审批意见后才能通过。")
if (
previous_stage == DIRECT_MANAGER_APPROVAL_STAGE
and merged_budget_approval
and self._budget_approval_opinion_required(claim)
and not approval_opinion
):
raise ValueError("预算已超过警戒值,预算管理者需填写审批意见后才能通过。")
if previous_stage in {DIRECT_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE} and not approval_opinion:
approval_opinion = "同意"
@@ -186,7 +210,7 @@ class ExpenseClaimApprovalFlowMixin:
claim.approval_stage = next_stage
if claim.submitted_at is None:
claim.submitted_at = datetime.now(UTC)
if is_application_claim and next_stage == APPROVAL_DONE_STAGE:
if is_application_claim and next_stage == APPLICATION_LINK_STATUS_STAGE:
if previous_stage == BUDGET_MANAGER_APPROVAL_STAGE:
approval_flag["leader_opinion"] = self._resolve_latest_approval_opinion(
claim,
@@ -289,6 +313,15 @@ class ExpenseClaimApprovalFlowMixin:
"reimbursement",
)
archived_applications = self._archive_linked_applications_after_reimbursement_paid(
reimbursement_claim=claim,
payment_flag=payment_flag,
operator=operator,
current_user=current_user,
)
if archived_applications:
payment_flag["archived_application_claims"] = archived_applications
claim.status = PAYMENT_PAID_STATUS
claim.approval_stage = PAYMENT_PAID_STAGE
claim.risk_flags_json = [*list(claim.risk_flags_json or []), payment_flag]
@@ -318,3 +351,28 @@ class ExpenseClaimApprovalFlowMixin:
if opinion:
return opinion
return ""
def _budget_approval_opinion_required(self, claim) -> bool:
budget_result = BudgetService(self.db).analyze_claim_budget(claim)
metrics = budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {}
context = (
budget_result.get("budget_context")
if isinstance(budget_result.get("budget_context"), dict)
else {}
)
over_budget_amount = self._budget_decimal(metrics.get("over_budget_amount"))
if over_budget_amount > Decimal("0.00"):
return True
after_usage_rate = self._budget_decimal(metrics.get("after_usage_rate"))
claim_amount_ratio = self._budget_decimal(metrics.get("claim_amount_ratio"))
warning_threshold = self._budget_decimal(context.get("warning_threshold") or "80.00")
return max(after_usage_rate, claim_amount_ratio) >= warning_threshold
@staticmethod
def _budget_decimal(value: Any) -> Decimal:
try:
return Decimal(str(value if value is not None else "0")).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return Decimal("0.00")

View File

@@ -17,6 +17,7 @@ from app.services.expense_claim_risk_stage import (
class ExpenseClaimApprovalRoutingMixin:
_APPLICATION_BUDGET_REVIEW_USAGE_THRESHOLD = Decimal("90.00")
_BUDGET_REVIEW_RATINGS = {"block"}
_BUDGET_REVIEW_RISK_LEVELS = {"high", "critical"}
_ROUTE_RISK_SEVERITIES = {"medium", "high", "critical", "danger"}
@@ -63,7 +64,11 @@ class ExpenseClaimApprovalRoutingMixin:
) -> dict[str, Any]:
business_stage = risk_business_stage_for_claim(is_application_claim=is_application_claim)
budget_result = BudgetService(self.db).analyze_claim_budget(claim)
budget_reasons = self._collect_budget_route_reasons(budget_result)
budget_reasons = (
self._collect_application_budget_route_reasons(budget_result)
if is_application_claim
else self._collect_budget_route_reasons(budget_result)
)
current_risk_reasons = self._collect_current_route_risk_reasons(
claim.risk_flags_json,
business_stage=business_stage,
@@ -75,7 +80,9 @@ class ExpenseClaimApprovalRoutingMixin:
else []
)
reasons = self._dedupe_reasons(
[*budget_reasons, *current_risk_reasons, *historical_risk_reasons]
budget_reasons
if is_application_claim
else [*budget_reasons, *current_risk_reasons, *historical_risk_reasons]
)
requires_budget_review = bool(reasons)
route = (
@@ -86,11 +93,18 @@ class ExpenseClaimApprovalRoutingMixin:
else "finance"
)
label = "需要预算管理者复核" if requires_budget_review else "跳过预算管理者复核"
message = (
"系统根据预算、当前风险和历史风险判断,该单据需要预算管理者二次确认。"
if requires_budget_review
else "系统根据预算、当前风险和历史风险判断,该单据可跳过预算管理者复核。"
)
if is_application_claim:
message = (
"系统根据预算占用阈值判断,该申请单达到 90% 预算复核线,需要预算管理者二次确认。"
if requires_budget_review
else "系统根据预算占用阈值判断,该申请单未达到 90% 预算复核线,可跳过预算管理者复核。"
)
else:
message = (
"系统根据预算、当前风险和历史风险判断,该单据需要预算管理者二次确认。"
if requires_budget_review
else "系统根据预算、当前风险和历史风险判断,该单据可跳过预算管理者复核。"
)
return with_risk_business_stage(
{
@@ -136,6 +150,20 @@ class ExpenseClaimApprovalRoutingMixin:
reasons.append(f"预计超预算 {over_budget_amount}")
return self._dedupe_reasons(reasons)
def _collect_application_budget_route_reasons(self, budget_result: dict[str, Any]) -> list[str]:
metrics = budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {}
over_budget_amount = self._decimal(metrics.get("over_budget_amount"))
if over_budget_amount > Decimal("0.00"):
return [f"预计超预算 {over_budget_amount}"]
after_usage_rate = self._decimal(metrics.get("after_usage_rate"))
claim_amount_ratio = self._decimal(metrics.get("claim_amount_ratio"))
budget_usage_rate = max(after_usage_rate, claim_amount_ratio)
if budget_usage_rate >= self._APPLICATION_BUDGET_REVIEW_USAGE_THRESHOLD:
return [f"审批后预算占用达到 {budget_usage_rate}%,触发 90% 预算复核线"]
return []
def _collect_current_route_risk_reasons(
self,
risk_flags: list[Any] | None,

View File

@@ -254,6 +254,7 @@ class ExpenseClaimAttachmentOperationsMixin:
)
self._sync_claim_from_items(claim)
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
self.db.commit()
self.db.refresh(claim)
@@ -351,11 +352,18 @@ class ExpenseClaimAttachmentOperationsMixin:
self._ensure_draft_claim(claim)
self._ensure_mutable_claim_item(item)
before_json = self._serialize_claim(claim)
previous_invoice_id = str(item.invoice_id or "").strip()
previous_name = self._attachment_presentation.resolve_display_name(item.invoice_id)
self._attachment_storage.delete_item_files(item)
item.invoice_id = None
claim.risk_flags_json = self._remove_deleted_attachment_risk_flags(
claim.risk_flags_json,
item_id=item.id,
invoice_id=previous_invoice_id,
)
self._sync_claim_from_items(claim)
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
self.db.commit()
self.db.refresh(claim)
@@ -377,6 +385,36 @@ class ExpenseClaimAttachmentOperationsMixin:
"attachment": None,
}
@staticmethod
def _remove_deleted_attachment_risk_flags(
risk_flags: Any,
*,
item_id: str | None,
invoice_id: str | None,
) -> list[Any]:
normalized_item_id = str(item_id or "").strip()
normalized_invoice_id = str(invoice_id or "").strip()
cleaned_flags: list[Any] = []
for flag in list(risk_flags or []):
if not isinstance(flag, dict):
cleaned_flags.append(flag)
continue
source = str(flag.get("source") or "").strip()
if source != "attachment_analysis":
cleaned_flags.append(flag)
continue
flag_item_id = str(flag.get("item_id") or flag.get("itemId") or "").strip()
flag_invoice_id = str(flag.get("invoice_id") or flag.get("invoiceId") or "").strip()
matches_deleted_item = bool(normalized_item_id and flag_item_id == normalized_item_id)
matches_deleted_invoice = bool(normalized_invoice_id and flag_invoice_id == normalized_invoice_id)
if matches_deleted_item or matches_deleted_invoice:
continue
cleaned_flags.append(flag)
return cleaned_flags
def _get_claim_item_or_raise(
self,
*,

View File

@@ -5,6 +5,7 @@ from typing import Any
from app.api.deps import CurrentUserContext
from app.models.financial_record import ExpenseClaim
from app.services.budget import BudgetService
from app.services.expense_claim_budget_risk_flags import dedupe_budget_risk_flags
from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics
@@ -104,7 +105,7 @@ class ExpenseClaimBudgetFlowMixin:
else flag
for flag in next_flags
]
return [*list(risk_flags or []), *enriched_flags]
return dedupe_budget_risk_flags([*list(risk_flags or []), *enriched_flags])
@staticmethod
def _resolve_budget_operator(current_user: CurrentUserContext) -> str:

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
from typing import Any
_DEDUPED_BUDGET_RISK_EVENT_TYPES = {
"budget_frozen",
"budget_insufficient",
"budget_missing",
"budget_warning",
}
def dedupe_budget_risk_flags(flags: list[Any] | None) -> list[Any]:
"""Collapse repeated budget risk warnings while preserving non-risk audit flags."""
deduped: list[Any] = []
key_to_index: dict[tuple[str, str, str, str], int] = {}
for flag in list(flags or []):
key = budget_risk_flag_key(flag)
if key is None:
deduped.append(flag)
continue
existing_index = key_to_index.get(key)
if existing_index is None:
key_to_index[key] = len(deduped)
deduped.append(flag)
continue
deduped[existing_index] = flag
return deduped
def budget_risk_flag_key(flag: Any) -> tuple[str, str, str, str] | None:
if not isinstance(flag, dict):
return None
source = str(flag.get("source") or "").strip()
event_type = str(flag.get("event_type") or flag.get("eventType") or "").strip()
if source != "budget_control" or event_type not in _DEDUPED_BUDGET_RISK_EVENT_TYPES:
return None
allocation_key = str(flag.get("allocation_id") or flag.get("allocationId") or "").strip()
budget_no = str(flag.get("budget_no") or flag.get("budgetNo") or "").strip()
subject_code = str(flag.get("subject_code") or flag.get("subjectCode") or "").strip()
return (source, event_type, allocation_key or budget_no, subject_code)

View File

@@ -641,6 +641,12 @@ class ExpenseClaimItemSyncMixin:
issues: list[str] = []
claim_location_required = self._is_location_required_expense_type(claim.expense_type)
claim_min_attachment_count = self._resolve_claim_required_attachment_count(claim)
substantive_items = [
item
for item in list(claim.items or [])
if str(item.item_type or "").strip().lower() not in SYSTEM_GENERATED_ITEM_TYPES
and not self._is_submission_placeholder_item(item)
]
if self._is_missing_value(claim.employee_name):
issues.append("申请人未完善")
@@ -658,28 +664,39 @@ class ExpenseClaimItemSyncMixin:
issues.append("发生时间未完善")
if int(claim.invoice_count or 0) < claim_min_attachment_count:
issues.append("票据附件数量不足")
if not claim.items:
if not substantive_items:
issues.append("费用明细不能为空")
for index, item in enumerate(claim.items, start=1):
prefix = f"费用明细第 {index}"
is_system_generated = str(item.item_type or "").strip().lower() in SYSTEM_GENERATED_ITEM_TYPES
if is_system_generated or self._is_submission_placeholder_item(item):
continue
item_location_required = self._is_location_required_expense_type(item.item_type or claim.expense_type)
if item.item_date is None:
item_has_attachment = not self._is_missing_value(item.invoice_id)
if not item_has_attachment and item.item_date is None:
issues.append(f"{prefix}缺少日期")
if self._is_missing_value(item.item_type):
issues.append(f"{prefix}缺少费用项目")
if self._is_missing_value(item.item_reason):
if not item_has_attachment and self._is_missing_value(item.item_reason):
issues.append(f"{prefix}缺少说明")
if item_location_required and self._is_missing_value(item.item_location):
if not item_has_attachment and item_location_required and self._is_missing_value(item.item_location):
issues.append(f"{prefix}缺少地点")
if item.item_amount is None or item.item_amount <= Decimal("0.00"):
if not item_has_attachment and (item.item_amount is None or item.item_amount <= Decimal("0.00")):
issues.append(f"{prefix}缺少金额")
if self._is_attachment_required_item_type(item.item_type) and self._is_missing_value(item.invoice_id):
if self._is_attachment_required_item_type(item.item_type) and not item_has_attachment:
issues.append(f"{prefix}缺少票据标识")
return issues
def _is_submission_placeholder_item(self, item: ExpenseClaimItem) -> bool:
if not self._is_missing_value(item.invoice_id):
return False
missing_reason = self._is_missing_value(item.item_reason)
missing_location = self._is_missing_value(item.item_location)
missing_amount = item.item_amount is None or item.item_amount <= Decimal("0.00")
return missing_reason and missing_location and missing_amount
def _is_location_required_expense_type(self, expense_type: str | None) -> bool:
policy = self._get_expense_scene_policy(expense_type)
if policy is None:

View File

@@ -30,6 +30,7 @@ class ExpenseClaimPaginationMixin:
)
stmt = self._access_policy.apply_claim_scope(stmt, current_user)
result = paginate_select(self.db, stmt, page=page, page_size=page_size)
self._repair_duplicate_budget_approval_stages(result.items)
self._access_policy.attach_budget_approval_snapshots(result.items)
return result
@@ -46,6 +47,7 @@ class ExpenseClaimPaginationMixin:
)
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
result = paginate_select(self.db, stmt, page=page, page_size=page_size)
self._repair_duplicate_budget_approval_stages(result.items)
self._access_policy.attach_budget_approval_snapshots(result.items)
return result

View File

@@ -0,0 +1,97 @@
from __future__ import annotations
import re
from typing import Any
from app.services.expense_rule_runtime import RuntimeTravelPolicy
def count_values(values: list[str]) -> dict[str, int]:
counts: dict[str, int] = {}
for value in values:
normalized = str(value or "").strip()
if not normalized:
continue
counts[normalized] = counts.get(normalized, 0) + 1
return counts
def collect_invoice_keys_from_contexts(contexts: list[dict[str, Any]]) -> list[str]:
invoice_keys: list[str] = []
for context in contexts:
document_info = context.get("document_info") or {}
for key in collect_invoice_keys_from_document_info(document_info):
if key not in invoice_keys:
invoice_keys.append(key)
return invoice_keys
def collect_invoice_keys_from_document_info(document_info: dict[str, Any]) -> list[str]:
keys: list[str] = []
for field in list(document_info.get("fields") or []):
if not isinstance(field, dict):
continue
field_key = str(field.get("key") or "").strip().lower().replace("_", "")
label = str(field.get("label") or "").replace(" ", "")
value = str(field.get("value") or "").strip()
if not value:
continue
if field_key in {"invoiceno", "invoicenumber", "number", "code"} or any(
token in label for token in ("发票号码", "票号", "发票代码", "号码")
):
normalized = re.sub(r"\s+", "", value)
if normalized and normalized not in keys:
keys.append(normalized)
return keys
def collect_attachment_cities(
contexts: list[dict[str, Any]],
policy: RuntimeTravelPolicy,
) -> list[str]:
cities: list[str] = []
for context in contexts:
document_info = context.get("document_info") or {}
parts = [
str(context.get("ocr_summary") or ""),
str(context.get("ocr_text") or ""),
str(context.get("item").item_location if context.get("item") is not None else ""),
]
for field in list(document_info.get("fields") or []):
if isinstance(field, dict):
parts.append(str(field.get("value") or ""))
for city in extract_known_cities_from_text(" ".join(parts), policy):
if city not in cities:
cities.append(city)
return cities
def extract_known_cities_from_text(text: str, policy: RuntimeTravelPolicy) -> list[str]:
normalized = str(text or "").strip()
if not normalized:
return []
cities: list[str] = []
for city in sorted(policy.city_tiers.keys(), key=lambda item: len(item), reverse=True):
if city in normalized and city not in cities:
cities.append(city)
return cities
def resolve_first_document_field_value(
document_info: dict[str, Any],
*,
keys: set[str],
labels: set[str],
) -> str:
normalized_keys = {key.replace("_", "").lower() for key in keys}
for field in list(document_info.get("fields") or []):
if not isinstance(field, dict):
continue
field_key = str(field.get("key") or "").strip().lower().replace("_", "")
label = str(field.get("label") or "").replace(" ", "")
value = str(field.get("value") or "").strip()
if not value:
continue
if field_key in normalized_keys or any(token in label for token in labels):
return value
return ""

View File

@@ -11,11 +11,24 @@ from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.budget import BudgetService
from app.services.expense_claim_platform_context_tools import (
collect_attachment_cities,
collect_invoice_keys_from_contexts,
collect_invoice_keys_from_document_info,
count_values,
extract_known_cities_from_text,
resolve_first_document_field_value,
)
from app.services.expense_rule_runtime import (
RuntimeTravelPolicy,
)
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
from app.services.expense_claim_platform_route_risk import resolve_multi_city_related_item_ids
from app.services.expense_claim_platform_risk_flag import build_platform_risk_flag
from app.services.expense_claim_platform_text_risk import (
collect_vague_goods_description_evidence,
)
from app.services.risk_rule_manifest_classifier import is_budget_risk_manifest
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
@@ -24,44 +37,6 @@ from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
class ExpenseClaimPlatformRiskMixin:
_DEFAULT_RISK_BUSINESS_STAGE = "reimbursement"
_SUPPORTED_RISK_BUSINESS_STAGES = {"expense_application", "reimbursement"}
_CLEAR_TRAVEL_DOCUMENT_TYPES = {
"flight_itinerary",
"train_ticket",
"ship_ticket",
"hotel_invoice",
"taxi_receipt",
"parking_toll_receipt",
}
_CLEAR_TRAVEL_SCENE_CODES = {"travel", "hotel", "transport"}
_GOODS_DESCRIPTION_FIELD_KEYS = {
"goodsname",
"servicename",
"itemname",
"project",
"productname",
"description",
"content",
"expensecontent",
"feeitem",
}
_GOODS_DESCRIPTION_LABEL_TOKENS = (
"商品",
"服务",
"货物",
"项目",
"品名",
"名称",
"费用内容",
"消费内容",
)
_VAGUE_KEYWORD_NEGATION_MARKERS = (
"不含",
"不包含",
"不包括",
"未包含",
"不涉及",
"不属于",
)
def evaluate_platform_risk_rules(
self,
@@ -105,6 +80,13 @@ class ExpenseClaimPlatformRiskMixin:
continue
flags.append(flag)
flags = [
flag
for flag in dedupe_claim_risk_flags(flags)
if isinstance(flag, dict)
]
for flag in flags:
severity = str(flag.get("severity") or "").strip().lower()
action = str(flag.get("action") or "").strip().lower()
if severity in {"high", "critical"} or action == "block":
@@ -427,10 +409,13 @@ class ExpenseClaimPlatformRiskMixin:
)
if result is None:
return None
return self._build_platform_risk_flag(
manifest,
message=str(result.get("message") or "自然语言风险规则命中。"),
evidence=result.get("evidence") if isinstance(result.get("evidence"), dict) else {},
return self._with_related_item_ids(
self._build_platform_risk_flag(
manifest,
message=str(result.get("message") or "自然语言风险规则命中。"),
evidence=result.get("evidence") if isinstance(result.get("evidence"), dict) else {},
),
self._context_item_ids(contexts),
)
return None
@@ -517,10 +502,13 @@ class ExpenseClaimPlatformRiskMixin:
if not mismatches:
return None
return self._build_platform_risk_flag(
manifest,
message="".join(mismatches[:3]) + ",与当前费用场景不匹配。",
evidence={"mismatches": mismatches[:5]},
return self._with_related_item_ids(
self._build_platform_risk_flag(
manifest,
message="".join(mismatches[:3]) + ",与当前费用场景不匹配。",
evidence={"mismatches": mismatches[:5]},
),
self._context_item_ids(contexts),
)
def _evaluate_location_consistency_risk(
@@ -533,7 +521,7 @@ class ExpenseClaimPlatformRiskMixin:
policy = self._get_expense_rule_catalog().travel_policy
if policy is None:
return None
declared_cities = self._extract_known_cities_from_text(
declared_cities = extract_known_cities_from_text(
" ".join(
[
str(claim.location or ""),
@@ -542,20 +530,23 @@ class ExpenseClaimPlatformRiskMixin:
),
policy,
)
evidence_cities = self._collect_attachment_cities(contexts, policy)
evidence_cities = collect_attachment_cities(contexts, policy)
if not declared_cities or not evidence_cities:
return None
if set(declared_cities) & set(evidence_cities):
return None
declared_text = "".join(declared_cities)
evidence_text = "".join(evidence_cities[:5])
return self._build_platform_risk_flag(
manifest,
message=(
f"申报地点 {declared_text} 与票据识别地点 {evidence_text} 不一致,"
"建议补充异地说明或更换附件。"
return self._with_related_item_ids(
self._build_platform_risk_flag(
manifest,
message=(
f"申报地点 {declared_text} 与票据识别地点 {evidence_text} 不一致,"
"建议补充异地说明或更换附件。"
),
evidence={"declared_cities": declared_cities, "evidence_cities": evidence_cities},
),
evidence={"declared_cities": declared_cities, "evidence_cities": evidence_cities},
self._context_item_ids(contexts),
)
def _evaluate_duplicate_invoice_risk(
@@ -565,9 +556,9 @@ class ExpenseClaimPlatformRiskMixin:
claim: ExpenseClaim,
contexts: list[dict[str, Any]],
) -> dict[str, Any] | None:
invoice_keys = self._collect_invoice_keys_from_contexts(contexts)
invoice_keys = collect_invoice_keys_from_contexts(contexts)
duplicate_keys = [
key for key, count in self._count_values(invoice_keys).items() if count > 1
key for key, count in count_values(invoice_keys).items() if count > 1
]
if duplicate_keys:
return self._build_platform_risk_flag(
@@ -595,7 +586,7 @@ class ExpenseClaimPlatformRiskMixin:
other_document_info = other_meta.get("document_info")
if not isinstance(other_document_info, dict):
continue
other_keys = self._collect_invoice_keys_from_document_info(other_document_info)
other_keys = collect_invoice_keys_from_document_info(other_document_info)
if set(invoice_keys) & set(other_keys):
matched_claim_ids.add(str(other_item.claim_id or ""))
@@ -626,7 +617,7 @@ class ExpenseClaimPlatformRiskMixin:
return None
mismatched_buyers: list[str] = []
for context in contexts:
buyer = self._resolve_first_document_field_value(
buyer = resolve_first_document_field_value(
context.get("document_info") or {},
keys={"buyer_name", "buyer", "purchaser_name", "claimant"},
labels={"购买方", "抬头", "买方", "购方"},
@@ -658,7 +649,7 @@ class ExpenseClaimPlatformRiskMixin:
for context in contexts:
text = " ".join(
[
self._resolve_first_document_field_value(
resolve_first_document_field_value(
context.get("document_info") or {},
keys={"date", "issue_date", "invoice_date"},
labels={"日期", "开票日期", "发生时间"},
@@ -714,99 +705,16 @@ class ExpenseClaimPlatformRiskMixin:
keywords: list[str],
fallback_message: str,
) -> dict[str, Any] | None:
matched_keywords: list[str] = []
matched_fields: list[dict[str, str]] = []
for context in contexts:
document_info = context.get("document_info") or {}
if self._is_clear_travel_document(document_info):
continue
field_values = self._collect_goods_description_field_values(document_info)
if field_values:
for value in field_values:
hits = self._collect_non_negated_keyword_hits(value, keywords)
for keyword in hits:
if keyword not in matched_keywords:
matched_keywords.append(keyword)
if hits:
matched_fields.append(
{
"item_index": str(context.get("index") or ""),
"value": value[:80],
}
)
continue
fallback_text = f"{context.get('ocr_summary') or ''}\n{context.get('ocr_text') or ''}"
hits = self._collect_non_negated_keyword_hits(fallback_text, keywords)
for keyword in hits:
if keyword not in matched_keywords:
matched_keywords.append(keyword)
if hits:
matched_fields.append(
{
"item_index": str(context.get("index") or ""),
"value": "OCR全文兜底",
}
)
if not matched_keywords:
evidence = collect_vague_goods_description_evidence(contexts, keywords)
if not evidence:
return None
return self._build_platform_risk_flag(
manifest,
message=fallback_message,
evidence={
"matched_keywords": matched_keywords,
"matched_fields": matched_fields[:5],
},
evidence=evidence,
)
@classmethod
def _is_clear_travel_document(cls, document_info: dict[str, Any]) -> bool:
document_type = str(document_info.get("document_type") or "").strip().lower()
scene_code = str(document_info.get("scene_code") or "").strip().lower()
return (
document_type in cls._CLEAR_TRAVEL_DOCUMENT_TYPES
or scene_code in cls._CLEAR_TRAVEL_SCENE_CODES
)
@classmethod
def _collect_goods_description_field_values(cls, document_info: dict[str, Any]) -> list[str]:
values: list[str] = []
for field in list(document_info.get("fields") or []):
if not isinstance(field, dict):
continue
field_key = str(field.get("key") or "").strip().lower().replace("_", "")
label = str(field.get("label") or "").replace(" ", "")
value = str(field.get("value") or "").strip()
if not value:
continue
if field_key in cls._GOODS_DESCRIPTION_FIELD_KEYS or any(
token in label for token in cls._GOODS_DESCRIPTION_LABEL_TOKENS
):
values.append(value)
return values
@classmethod
def _collect_non_negated_keyword_hits(cls, text: str, keywords: list[str]) -> list[str]:
normalized = str(text or "")
if not normalized:
return []
hits: list[str] = []
for keyword in keywords:
if not keyword:
continue
for match in re.finditer(re.escape(keyword), normalized):
window = normalized[max(0, match.start() - 12): match.end() + 12]
if any(marker in window for marker in cls._VAGUE_KEYWORD_NEGATION_MARKERS):
continue
hits.append(keyword)
break
return hits
def _evaluate_multi_city_reason_required_risk(
self,
manifest: dict[str, Any],
@@ -817,9 +725,9 @@ class ExpenseClaimPlatformRiskMixin:
policy = self._get_expense_rule_catalog().travel_policy
if policy is None:
return None
cities = self._collect_attachment_cities(contexts, policy)
cities = collect_attachment_cities(contexts, policy)
for item in list(claim.items or []):
for city in self._extract_known_cities_from_text(str(item.item_location or ""), policy):
for city in extract_known_cities_from_text(str(item.item_location or ""), policy):
if city not in cities:
cities.append(city)
if len(cities) <= 2:
@@ -827,10 +735,21 @@ class ExpenseClaimPlatformRiskMixin:
reason_corpus = self._build_travel_reason_corpus(claim)
if self._text_contains_keywords(reason_corpus, policy.route_exception_keywords):
return None
return self._build_platform_risk_flag(
manifest,
message=f"本次报销识别到多城市行程({''.join(cities[:5])}),但事由中未说明中转、多地拜访或改签原因。",
evidence={"cities": cities[:8]},
related_item_ids, extra_cities = resolve_multi_city_related_item_ids(
claim,
contexts,
policy,
)
evidence = {"cities": cities[:8]}
if extra_cities:
evidence["extra_cities"] = extra_cities[:8]
return self._with_related_item_ids(
self._build_platform_risk_flag(
manifest,
message=f"本次报销识别到多城市行程({''.join(cities[:5])}),但事由中未说明中转、多地拜访或改签原因。",
evidence=evidence,
),
related_item_ids or self._context_item_ids(contexts),
)
def _build_platform_risk_flag(
@@ -848,90 +767,25 @@ class ExpenseClaimPlatformRiskMixin:
)
@staticmethod
def _count_values(values: list[str]) -> dict[str, int]:
counts: dict[str, int] = {}
for value in values:
normalized = str(value or "").strip()
if not normalized:
continue
counts[normalized] = counts.get(normalized, 0) + 1
return counts
def _collect_invoice_keys_from_contexts(self, contexts: list[dict[str, Any]]) -> list[str]:
invoice_keys: list[str] = []
for context in contexts:
document_info = context.get("document_info") or {}
for key in self._collect_invoice_keys_from_document_info(document_info):
if key not in invoice_keys:
invoice_keys.append(key)
return invoice_keys
def _collect_invoice_keys_from_document_info(self, document_info: dict[str, Any]) -> list[str]:
keys: list[str] = []
for field in list(document_info.get("fields") or []):
if not isinstance(field, dict):
continue
field_key = str(field.get("key") or "").strip().lower().replace("_", "")
label = str(field.get("label") or "").replace(" ", "")
value = str(field.get("value") or "").strip()
if not value:
continue
if field_key in {"invoiceno", "invoicenumber", "number", "code"} or any(
token in label for token in ("发票号码", "票号", "发票代码", "号码")
):
normalized = re.sub(r"\s+", "", value)
if normalized and normalized not in keys:
keys.append(normalized)
return keys
def _collect_attachment_cities(
self,
contexts: list[dict[str, Any]],
policy: RuntimeTravelPolicy,
) -> list[str]:
cities: list[str] = []
for context in contexts:
document_info = context.get("document_info") or {}
parts = [
str(context.get("ocr_summary") or ""),
str(context.get("ocr_text") or ""),
str(context.get("item").item_location if context.get("item") is not None else ""),
]
for field in list(document_info.get("fields") or []):
if isinstance(field, dict):
parts.append(str(field.get("value") or ""))
for city in self._extract_known_cities_from_text(" ".join(parts), policy):
if city not in cities:
cities.append(city)
return cities
def _context_item_ids(contexts: list[dict[str, Any]]) -> list[str]:
item_ids: list[str] = []
seen: set[str] = set()
for context in list(contexts or []):
item = context.get("item") if isinstance(context, dict) else None
item_id = str(getattr(item, "id", "") or "").strip()
if item_id and item_id not in seen:
seen.add(item_id)
item_ids.append(item_id)
return item_ids
@staticmethod
def _extract_known_cities_from_text(text: str, policy: RuntimeTravelPolicy) -> list[str]:
normalized = str(text or "").strip()
if not normalized:
return []
cities: list[str] = []
for city in sorted(policy.city_tiers.keys(), key=lambda item: len(item), reverse=True):
if city in normalized and city not in cities:
cities.append(city)
return cities
@staticmethod
def _resolve_first_document_field_value(
document_info: dict[str, Any],
*,
keys: set[str],
labels: set[str],
) -> str:
normalized_keys = {key.replace("_", "").lower() for key in keys}
for field in list(document_info.get("fields") or []):
if not isinstance(field, dict):
continue
field_key = str(field.get("key") or "").strip().lower().replace("_", "")
label = str(field.get("label") or "").replace(" ", "")
value = str(field.get("value") or "").strip()
if not value:
continue
if field_key in normalized_keys or any(token in label for token in labels):
return value
return ""
def _with_related_item_ids(flag: dict[str, Any], item_ids: list[str]) -> dict[str, Any]:
normalized_item_ids = list(
dict.fromkeys(str(item_id or "").strip() for item_id in list(item_ids or []) if str(item_id or "").strip())
)
if not normalized_item_ids:
return flag
flag["item_ids"] = normalized_item_ids
if len(normalized_item_ids) == 1:
flag["item_id"] = normalized_item_ids[0]
return flag

View File

@@ -11,6 +11,33 @@ from app.services.expense_claim_risk_stage import (
)
def _normalize_basic_rule_refs(value: Any) -> list[dict[str, str]]:
if not isinstance(value, list):
return []
refs: list[dict[str, str]] = []
seen: set[tuple[str, str]] = set()
for item in value:
if not isinstance(item, dict):
continue
code = str(item.get("code") or item.get("rule_code") or "").strip()
sheet = str(item.get("sheet") or item.get("rule_sheet") or "").strip()
if not code and not sheet:
continue
key = (code, sheet)
if key in seen:
continue
seen.add(key)
refs.append(
{
"code": code,
"sheet": sheet,
"name": str(item.get("name") or "").strip(),
"component": str(item.get("component") or "").strip(),
}
)
return refs
def build_platform_risk_flag(
manifest: dict[str, Any],
*,
@@ -33,20 +60,20 @@ def build_platform_risk_flag(
default=default_business_stage,
)
risk_domain = infer_risk_domain(manifest)
default_visibility_scope = (
"budget_manager"
if risk_domain == "budget"
else "leader"
if business_stage == "expense_application"
else "submitter"
)
default_actionability = (
"budget_governance"
if risk_domain == "budget"
else "review_decision"
if business_stage == "expense_application"
else "fixable_by_submitter"
)
# 申请单阶段:信息完整性/差旅/金额类风险申请人可自行补充修正,对申请人可见;
# 预算走预算审批人;其余(画像/审批流程)仍走领导可见。
if risk_domain == "budget":
default_visibility_scope = "budget_manager"
default_actionability = "budget_governance"
elif business_stage == "expense_application" and risk_domain in {"policy", "trip", "amount"}:
default_visibility_scope = "submitter"
default_actionability = "fixable_by_submitter"
elif business_stage == "expense_application":
default_visibility_scope = "leader"
default_actionability = "review_decision"
else:
default_visibility_scope = "submitter"
default_actionability = "fixable_by_submitter"
visibility_scope = normalize_risk_visibility_scope(
metadata.get("visibility_scope") or manifest.get("visibility_scope"),
default_visibility_scope,
@@ -55,6 +82,42 @@ def build_platform_risk_flag(
metadata.get("actionability") or manifest.get("actionability"),
default_actionability,
)
finance_rule_code = str(
manifest.get("finance_rule_code")
or metadata.get("finance_rule_code")
or manifest.get("basic_rule_code")
or metadata.get("basic_rule_code")
or ""
).strip()
finance_rule_sheet = str(
manifest.get("finance_rule_sheet")
or metadata.get("finance_rule_sheet")
or manifest.get("basic_rule_sheet")
or metadata.get("basic_rule_sheet")
or ""
).strip()
basic_rule_code = str(
manifest.get("basic_rule_code")
or metadata.get("basic_rule_code")
or finance_rule_code
).strip()
basic_rule_sheet = str(
manifest.get("basic_rule_sheet")
or metadata.get("basic_rule_sheet")
or finance_rule_sheet
).strip()
basic_rule_refs = _normalize_basic_rule_refs(
manifest.get("basic_rule_refs") or metadata.get("basic_rule_refs")
)
if not basic_rule_refs and (basic_rule_code or basic_rule_sheet):
basic_rule_refs = [
{
"code": basic_rule_code,
"sheet": basic_rule_sheet,
"name": "",
"component": "",
}
]
return with_risk_business_stage(
{
@@ -63,6 +126,11 @@ def build_platform_risk_flag(
"rule_type": "risk",
"rule_code": str(manifest.get("rule_code") or "").strip(),
"rule_version": str(manifest.get("_rule_version") or "v1.0.0").strip(),
"basic_rule_code": basic_rule_code,
"basic_rule_sheet": basic_rule_sheet,
"basic_rule_refs": basic_rule_refs,
"finance_rule_code": finance_rule_code,
"finance_rule_sheet": finance_rule_sheet,
"severity": severity,
"action": action,
"label": label,

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