From 7141e1d11ae7a2de93b002f8450c51fd6541053a Mon Sep 17 00:00:00 2001 From: "WIN-JHFT4D3SIVT\\caoxiaozhu" Date: Tue, 28 Apr 2026 17:20:52 +0800 Subject: [PATCH] feat: refactor monolithic App.vue into modular Vue component architecture - Extract 711-line App.vue into 15+ focused files across 5 directories - Add data layer (icons, metrics, policies, auditTrail, requests) - Add composables (useNavigation, useRequests, useChat, useToast) - Add layout components (SidebarRail, TopBar, FilterBar) - Add shared components (PanelHead, InfoRow, ToastNotification) - Add business component (RequestTable) and 5 view components - Extract global CSS to assets/styles/global.css - Add start.sh with WSL/Windows cross-platform support - Add .gitignore for node_modules, dist, and IDE dirs --- .gitignore | 9 + ai-reimbursement-mac-prototype.html | 2158 +++++++++++++++++ docs/plans/00-overview.md | 117 + docs/plans/phase-1-project-infra/README.md | 802 ++++++ docs/plans/phase-2-backend-core/README.md | 834 +++++++ .../phase-3-agent-orchestration/README.md | 568 +++++ docs/plans/phase-4-frontend-pages/README.md | 500 ++++ docs/plans/phase-5-integration/README.md | 259 ++ docs/plans/phase-6-testing-polish/README.md | 553 +++++ .../plans/2026-04-24-ai-reimbursement-mvp.md | 1602 ++++++++++++ .../AI报销操作层_影子报销账本_开发文档.md | 1493 ++++++++++++ index.html | 12 + package.json | 18 + reimbursement-admin-prototype.html | 12 + src/App.vue | 122 + src/assets/styles/global.css | 94 + src/components/business/RequestTable.vue | 63 + src/components/layout/FilterBar.vue | 69 + src/components/layout/SidebarRail.vue | 70 + src/components/layout/TopBar.vue | 56 + src/components/shared/InfoRow.vue | 27 + src/components/shared/PanelHead.vue | 21 + src/components/shared/ToastNotification.vue | 31 + src/composables/useChat.js | 63 + src/composables/useNavigation.js | 24 + src/composables/useRequests.js | 37 + src/composables/useToast.js | 13 + src/data/auditTrail.js | 5 + src/data/icons.js | 11 + src/data/metrics.js | 20 + src/data/policies.js | 5 + src/data/requests.js | 14 + src/main.js | 5 + src/views/AuditView.vue | 31 + src/views/ChatView.vue | 167 ++ src/views/OverviewView.vue | 94 + src/views/PoliciesView.vue | 31 + src/views/RequestsView.vue | 20 + start.sh | 97 + vite.config.js | 6 + 40 files changed, 10133 insertions(+) create mode 100644 .gitignore create mode 100644 ai-reimbursement-mac-prototype.html create mode 100644 docs/plans/00-overview.md create mode 100644 docs/plans/phase-1-project-infra/README.md create mode 100644 docs/plans/phase-2-backend-core/README.md create mode 100644 docs/plans/phase-3-agent-orchestration/README.md create mode 100644 docs/plans/phase-4-frontend-pages/README.md create mode 100644 docs/plans/phase-5-integration/README.md create mode 100644 docs/plans/phase-6-testing-polish/README.md create mode 100644 docs/superpowers/plans/2026-04-24-ai-reimbursement-mvp.md create mode 100644 document/AI报销操作层_影子报销账本_开发文档.md create mode 100644 index.html create mode 100644 package.json create mode 100644 reimbursement-admin-prototype.html create mode 100644 src/App.vue create mode 100644 src/assets/styles/global.css create mode 100644 src/components/business/RequestTable.vue create mode 100644 src/components/layout/FilterBar.vue create mode 100644 src/components/layout/SidebarRail.vue create mode 100644 src/components/layout/TopBar.vue create mode 100644 src/components/shared/InfoRow.vue create mode 100644 src/components/shared/PanelHead.vue create mode 100644 src/components/shared/ToastNotification.vue create mode 100644 src/composables/useChat.js create mode 100644 src/composables/useNavigation.js create mode 100644 src/composables/useRequests.js create mode 100644 src/composables/useToast.js create mode 100644 src/data/auditTrail.js create mode 100644 src/data/icons.js create mode 100644 src/data/metrics.js create mode 100644 src/data/policies.js create mode 100644 src/data/requests.js create mode 100644 src/main.js create mode 100644 src/views/AuditView.vue create mode 100644 src/views/ChatView.vue create mode 100644 src/views/OverviewView.vue create mode 100644 src/views/PoliciesView.vue create mode 100644 src/views/RequestsView.vue create mode 100644 start.sh create mode 100644 vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e1051d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +.omc/ +.omx/ +.claude/ +.codex/ +*.log +.DS_Store +Thumbs.db diff --git a/ai-reimbursement-mac-prototype.html b/ai-reimbursement-mac-prototype.html new file mode 100644 index 0000000..b7934b7 --- /dev/null +++ b/ai-reimbursement-mac-prototype.html @@ -0,0 +1,2158 @@ + + + + + + AI 报销预审中台 - macOS Prototype + + + +
+
+
+ +
+ +
+
+ 演示环境 · Mock OCR + +
+
+ +
+ + +
+
+
+
+ AI 操作层 +

把一段报销意图变成可预审的差旅报销单

+

面向员工的入口页:输入报销意图,系统创建任务并引导上传发票、火车票、机票行程单等材料。

+
+
+ + +
+
+ +
+
+
+
+

报销意图

+

自然语言输入会交给受理 Agent 提取出差地点、时间、费用类型和上下文。

+
+ IntakeAgent +
+ +
+ + + +
+
+ +
+
+ +
+
+
+

最近任务

+

用于返回草稿、查看预审结果或审计轨迹。

+
+
+
+ + + +
+
+
+ +
+
今日自动识别48发票、火车票、行程单合计
+
需人工补件7主要来自住宿流水和费用日期异常
+
平均预审耗时42sMock OCR 环境下的端到端耗时
+
+
+ +
+
+
+ 材料收集 +

上传票据并启动 OCR / Agent 编排

+

支持多文件拖拽,文件类型限定为 PDF、JPG、PNG。开始识别后进入 CREATED → MATERIAL_COLLECTING → PARSING → DRAFT_GENERATED。

+
+
+ + +
+
+ +
+
+
+
+

票据上传

+

拖入真实文件会显示文件名;不选择文件也可以使用内置样例继续演示。

+
+ PDF / JPG / PNG +
+ +
+
准备启动 Agent0%
+
+
+
+ +
+
+
+

已上传文件

+

OCR 结果会在草稿页以低置信度标注和可编辑字段展示。

+
+ +
+
+
+
+
+ +
+
+
+ 影子报销账本 +

OCR 已生成报销草稿,关键字段可人工修正

+

草稿页对应 ShadowReimbursement + ReimbursementItem,编辑后再执行规则引擎预审。

+
+
+ + +
+
+ +
+
+
+

报销人信息

+

Agent 从意图和组织配置中补齐部门、成本中心、项目。

+
+ AI 自动生成 +
+
+
+
+
+
+
+
+ +
+
+
+
+

费用明细

+

金额可直接编辑,风险标签会在预审后刷新。

+
+ 待预审 +
+
+ + + + + + + + + + + + + +
费用类型金额税额发生日期城市商户风险
+
+
+ +
+
+
+

AI 识别标注

+

低置信度字段和附件缺口会在这里提示。

+
+
+
+ ParseAgent 发现 2 个需要关注的点 + 住宿金额高于上海 T2 标准 18%,且酒店流水附件未找到。出租车票日期与出差日期匹配,但商户字段置信度 0.71。 +
+
+
高铁票 · 可信
+
酒店发票 · 需流水
+
出租车票 · 低置信
+
+
+
+
+ +
+
+
+ 规则预审 +

预审结果:需补件后再提交

+

规则引擎执行 6 条 MVP 核心规则,并由 ExplainAgent 转化为面向用户的解释和修改建议。

+
+
+ + + +
+
+ +
+
62
+
+

发现 2 个风险项和 1 个缺件项

+

当前可以继续补充酒店流水,并调整住宿金额或补充审批说明。补件后可重新预审。

+
+ 需补件 +
+ +
+
+
+
+

命中规则

+

每条规则展示问题、制度依据和建议动作。

+
+
+
+
+ +
+
+
+

通过项

+

通过项用来建立用户信心,减少重复沟通。

+
+
+
+
+
+
+ +
+
+
+ 补件交互 +

按规则命中项补充材料和说明

+

补件页支持补充附件、补充说明、修改字段三类动作,提交后回到预审结果页重新计算风险。

+
+
+ + +
+
+ +
+
+
+
+

待补件清单

+

由 ExplainAgent 根据缺件类规则自动创建。

+
+ 2 项 +
+
+
+
+
+

补充酒店流水

+

住宿费必须提供酒店水单或入住明细,用于核对入住日期、房费和实际支付金额。

+
+ 补充附件 +
+ +
+
+
+
+

说明住宿标准超额原因

+

上海 T2 住宿标准为 ¥650/晚,当前票据折算 ¥768/晚。可说明客户指定酒店或会务协议价。

+
+ 补充说明 +
+
+ + +
+
+
+
+ +
+
+
+

补件预览

+

提交后系统会自动触发 MATERIAL_COLLECTING → PRECHECKING。

+
+
+
+
尚未添加补件附件。可以直接提交说明,系统会用样例酒店流水完成演示。
+
+
+
+
+ +
+
+
+ 提交确认 +

确认最终报销单并模拟同步后端系统

+

预审通过后,SyncAgent 将影子账本映射为标准报销单,并写入同步记录和审计日志。

+
+
+ + +
+
+ +
+
+
+
+

最终摘要

+

提交前不可编辑,若需调整请回到草稿。

+
+ 预审通过 +
+
报销人林一舟 · 销售华东
+
报销事由上海客户拜访差旅
+
费用总额¥0.00
+
附件数量4 份
+
同步目标expense_system
+
+ +
+
+
+

同步设置

+

MVP 默认使用 mock adapter,展示提交中、已同步、同步失败重试状态。

+
+
+
+ + + +
+
+
等待提交0%
+
+
+
+
+
+ +
+
+
+ 审计日志 +

关键操作均写入可追溯时间线

+

记录文件上传、OCR 识别、Agent 调用、规则命中、用户补件、用户确认和后端同步。

+
+
+
+ + + + +
+
+
+ +
+
+
+
+
+ + +
+
+
+ +
+ + + + diff --git a/docs/plans/00-overview.md b/docs/plans/00-overview.md new file mode 100644 index 0000000..622d1a3 --- /dev/null +++ b/docs/plans/00-overview.md @@ -0,0 +1,117 @@ +# AI 报销预审中台 MVP — 总览 + +> **版本:** v1.0 +> **周期:** 8 周(W1 ~ W8) +> **团队:** 3-5 人 +> **目标:** 跑通「上传材料 → OCR 识别 → 草稿生成 → 规则预审 → 补件交互 → 用户确认 → 模拟同步」完整闭环,优先支持差旅报销场景。 + +--- + +## 技术栈 + +| 层 | 技术 | +|---|---| +| 前端 | Vue 3 + TypeScript + Ant Design Vue + Vite + Pinia | +| 后端 | Python 3.11+ / FastAPI + SQLAlchemy + Alembic + Pydantic v2 | +| 数据库 | PostgreSQL 15 + Redis 7 | +| 文件存储 | MinIO(S3 兼容) | +| OCR | 百度云 OCR API + Mock Provider | +| 规则引擎 | 自研 JSON Rule Engine | +| Agent | 自研 Orchestrator 状态机 + 大模型 API | +| 部署 | Docker Compose | + +--- + +## 团队分工建议 + +| 角色 | 人数 | 职责 | +|---|---|---| +| 后端工程师 A | 1 | 核心后端:任务管理、影子账本、Agent 编排、规则引擎 | +| 后端工程师 B | 1 | OCR 集成、文件服务、适配器层、审计日志 | +| 前端工程师 | 1-2 | 所有页面与组件(可拆分为两人并行) | +| 全栈/Agent 工程师 | 1 | Agent Prompt 设计、大模型集成、规则配置 | + +--- + +## 阶段总览 + +| 阶段 | 周数 | 任务数 | 文档 | 可并行度 | +|---|---|---|---|---| +| Phase 1: 项目基建 | W1 | 4 | [phase-1-project-infra/README.md](phase-1-project-infra/README.md) | 高(前端+后端+Docker并行) | +| Phase 2: 后端核心服务 | W2-W3 | 6 | [phase-2-backend-core/README.md](phase-2-backend-core/README.md) | 高(任务API+文件上传+OCR并行) | +| Phase 3: Agent 编排 | W3-W4 | 4 | [phase-3-agent-orchestration/README.md](phase-3-agent-orchestration/README.md) | 中(Orchestrator先行,Agents并行) | +| Phase 4: 前端核心页面 | W4-W5 | 4 | [phase-4-frontend-pages/README.md](phase-4-frontend-pages/README.md) | 高(页面间独立并行) | +| Phase 5: 联调与集成 | W5-W6 | 2 | [phase-5-integration/README.md](phase-5-integration/README.md) | 中 | +| Phase 6: 测试与打磨 | W7-W8 | 4 | [phase-6-testing-polish/README.md](phase-6-testing-polish/README.md) | 中 | +| **总计** | **8 周** | **24 个任务** | | | + +--- + +## 里程碑时间线 + +``` +W1 W2 W3 W4 W5 W6 W7 W8 +| | | | | | | | +├─Phase 1──┤ | | | | | | +| 基建 | | | | | | | +| ├────────Phase 2──────┤ | | | | +| | 后端核心 API | | | | | +| | ├────────Phase 3──────┤ | | | +| | | Agent 编排 | | | | +| | | ├────────Phase 4──────┤ | | +| | | | 前端页面 | | | +| | | | ├────Phase 5────┤ | | +| | | | | 联调集成 | | | +| | | | | | ├─────Phase 6─────┤ +| | | | | | | 测试打磨 | +``` + +--- + +## 阶段依赖关系 + +``` +Phase 1 (基建) + ↓ +Phase 2 (后端核心) ←── 可与 Phase 3 部分重叠 + ↓ +Phase 3 (Agent 编排) + ↓ +Phase 4 (前端页面) ←── 可与 Phase 3 后半段并行 + ↓ +Phase 5 (联调集成) + ↓ +Phase 6 (测试打磨) +``` + +**关键路径:** Phase 1 → Phase 2 → Phase 3 → Phase 5 → Phase 6 +**可并行路径:** Phase 4 可在 Phase 3 后半段提前开始 + +--- + +## 风险与缓解 + +| 风险 | 影响 | 缓解措施 | +|---|---|---| +| OCR 识别准确率不够 | 草稿数据错误 | 允许用户手动修改,低置信度高亮提示 | +| LLM 响应慢或幻觉 | 用户体验差 | Prompt 严格约束输出格式,超时 fallback | +| 规则引擎复杂度超预期 | 延期 | MVP 先做 6 条硬编码规则,JSON 配置化后续迭代 | +| 前后端联调问题多 | 延期 | W5 提前开始联调,边开发边对齐 API | +| 3-5 人不够 | 交付延期 | 优先砍规则管理页和审计页(W8 补) | + +--- + +## 验收标准 + +MVP 完成的标志: + +- [ ] 用户能通过 Web 界面创建差旅报销任务 +- [ ] 上传 3 种以上票据类型(增值税发票、火车票、酒店流水) +- [ ] OCR 自动识别票据信息并生成报销草稿 +- [ ] 规则引擎执行 6 条核心预审规则 +- [ ] 预审结果以可视化方式展示(风险等级、命中规则、修改建议) +- [ ] 用户能补件并重新预审 +- [ ] 用户确认后模拟同步成功 +- [ ] 影子报销账本完整记录业务数据 +- [ ] 审计日志记录所有关键操作 +- [ ] 完整流程端到端测试通过 diff --git a/docs/plans/phase-1-project-infra/README.md b/docs/plans/phase-1-project-infra/README.md new file mode 100644 index 0000000..ba44d8d --- /dev/null +++ b/docs/plans/phase-1-project-infra/README.md @@ -0,0 +1,802 @@ +# Phase 1: 项目基建(W1) + +> **目标:** 搭建前后端项目骨架、定义数据库模型、配置开发环境,确保团队可以立即开始业务开发。 +> **周期:** 第 1 周 +> **任务数:** 4 个 +> **可并行:** 后端骨架 / 前端骨架 / Docker Compose 可完全并行 +> **前置依赖:** 无 + +--- + +## 本阶段交付物 + +| 交付物 | 说明 | +|---|---| +| 后端项目骨架 | FastAPI + SQLAlchemy + Alembic,可运行的健康检查 | +| 数据库 Schema | 全部 12 张表的 ORM 模型 + Alembic 迁移 | +| 前端项目骨架 | Vue3 + TS + Ant Design Vue,路由和 API 层配置 | +| 开发环境 | Docker Compose(PostgreSQL + Redis + MinIO) | + +--- + +## 任务清单 + +### Task 1.1: 后端项目骨架搭建 + +**负责人:** 后端工程师 A +**预计工时:** 1 天 + +**Files:** +- Create: `backend/app/__init__.py` +- Create: `backend/app/main.py` +- Create: `backend/app/core/config.py` +- Create: `backend/app/core/database.py` +- Create: `backend/app/core/dependencies.py` +- Create: `backend/app/api/__init__.py` +- Create: `backend/app/api/v1/__init__.py` +- Create: `backend/app/api/v1/router.py` +- Create: `backend/app/models/__init__.py` +- Create: `backend/app/schemas/__init__.py` +- Create: `backend/app/services/__init__.py` +- Create: `backend/requirements.txt` +- Create: `backend/pyproject.toml` +- Create: `backend/Dockerfile` +- Create: `backend/alembic.ini` +- Create: `backend/alembic/env.py` +- Test: `backend/tests/__init__.py` +- Test: `backend/tests/conftest.py` +- Test: `backend/tests/test_health.py` + +- [ ] **Step 1: 初始化后端项目结构** + +创建 FastAPI 项目骨架,使用以下目录结构: + +``` +backend/ +├── app/ +│ ├── __init__.py +│ ├── main.py # FastAPI 应用入口 +│ ├── core/ +│ │ ├── config.py # Settings(Pydantic BaseSettings) +│ │ ├── database.py # SQLAlchemy async engine + session +│ │ └── dependencies.py # 通用依赖注入(db session, 当前用户等) +│ ├── api/ +│ │ └── v1/ +│ │ ├── __init__.py +│ │ ├── router.py # v1 路由聚合 +│ │ ├── tasks.py # 报销任务 API +│ │ ├── documents.py # 票据附件 API +│ │ ├── precheck.py # 预审结果 API +│ │ └── supplements.py # 补件 API +│ ├── models/ # SQLAlchemy ORM models +│ │ ├── __init__.py +│ │ ├── task.py +│ │ ├── reimbursement.py +│ │ ├── document.py +│ │ ├── rule.py +│ │ └── audit.py +│ ├── schemas/ # Pydantic schemas +│ │ ├── __init__.py +│ │ ├── task.py +│ │ ├── reimbursement.py +│ │ ├── document.py +│ │ └── rule.py +│ ├── services/ # 业务逻辑层 +│ │ ├── __init__.py +│ │ ├── task_service.py +│ │ ├── document_service.py +│ │ ├── ocr_service.py +│ │ ├── rule_engine.py +│ │ └── sync_service.py +│ └── agents/ # Agent 编排层 +│ ├── __init__.py +│ ├── orchestrator.py +│ ├── intake_agent.py +│ ├── parse_agent.py +│ ├── rule_check_agent.py +│ ├── explain_agent.py +│ └── sync_agent.py +├── alembic/ +│ ├── env.py +│ └── versions/ +├── alembic.ini +├── requirements.txt +├── pyproject.toml +├── Dockerfile +└── tests/ + ├── __init__.py + ├── conftest.py + └── test_health.py +``` + +- [ ] **Step 2: 编写核心配置文件** + +`backend/app/core/config.py` 使用 Pydantic BaseSettings: + +```python +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + # App + APP_NAME: str = "AI Reimbursement Agent" + APP_VERSION: str = "0.1.0" + DEBUG: bool = True + + # Database + DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/x_financial" + + # Redis + REDIS_URL: str = "redis://localhost:6379/0" + + # MinIO / S3 + MINIO_ENDPOINT: str = "localhost:9000" + MINIO_ACCESS_KEY: str = "minioadmin" + MINIO_SECRET_KEY: str = "minioadmin" + MINIO_BUCKET: str = "reimbursement" + + # OCR + OCR_PROVIDER: str = "baidu" # baidu | tencent | mock + BAIDU_OCR_API_KEY: str = "" + BAIDU_OCR_SECRET_KEY: str = "" + + # LLM + LLM_PROVIDER: str = "openai" + LLM_API_KEY: str = "" + LLM_MODEL: str = "gpt-4o-mini" + LLM_BASE_URL: str = "" + + class Config: + env_file = ".env" + +settings = Settings() +``` + +- [ ] **Step 3: 编写数据库连接和 FastAPI 入口** + +`backend/app/core/database.py`: + +```python +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from sqlalchemy.orm import DeclarativeBase +from app.core.config import settings + +engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG) +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + +class Base(DeclarativeBase): + pass + +async def get_db() -> AsyncSession: + async with async_session() as session: + yield session +``` + +`backend/app/main.py`: + +```python +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.core.config import settings +from app.api.v1.router import api_router + +app = FastAPI(title=settings.APP_NAME, version=settings.APP_VERSION) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(api_router, prefix="/api/v1") + +@app.get("/health") +async def health(): + return {"status": "ok", "version": settings.APP_VERSION} +``` + +- [ ] **Step 4: 编写 requirements.txt** + +``` +fastapi==0.115.0 +uvicorn[standard]==0.30.0 +sqlalchemy[asyncio]==2.0.35 +asyncpg==0.30.0 +alembic==1.13.0 +pydantic==2.9.0 +pydantic-settings==2.5.0 +python-multipart==0.0.9 +httpx==0.27.0 +redis==5.1.0 +minio==7.2.9 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +pytest==8.3.0 +pytest-asyncio==0.24.0 +``` + +- [ ] **Step 5: 编写健康检查测试** + +`backend/tests/conftest.py`: + +```python +import pytest +from httpx import AsyncClient, ASGITransport +from app.main import app + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac +``` + +`backend/tests/test_health.py`: + +```python +import pytest + +@pytest.mark.asyncio +async def test_health(client): + response = await client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" +``` + +- [ ] **Step 6: 运行测试确认骨架可用** + +Run: `cd backend && pip install -r requirements.txt && pytest tests/test_health.py -v` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add backend/ +git commit -m "feat: 初始化后端项目骨架(FastAPI + SQLAlchemy + Alembic)" +``` + +--- + +### Task 1.2: 数据库 Schema + 迁移 + +**负责人:** 后端工程师 A +**预计工时:** 1.5 天 +**前置依赖:** Task 1.1 + +**Files:** +- Create: `backend/app/models/base.py` +- Create: `backend/app/models/task.py` +- Create: `backend/app/models/reimbursement.py` +- Create: `backend/app/models/document.py` +- Create: `backend/app/models/rule.py` +- Create: `backend/app/models/audit.py` +- Create: `backend/app/models/enums.py` +- Modify: `backend/app/models/__init__.py` +- Test: `backend/tests/test_models.py` + +- [ ] **Step 1: 定义枚举类型** + +`backend/app/models/enums.py`: + +```python +import enum + +class TaskStatus(str, enum.Enum): + CREATED = "created" + MATERIAL_COLLECTING = "material_collecting" + PARSING = "parsing" + DRAFT_GENERATED = "draft_generated" + PRECHECKING = "prechecking" + NEED_SUPPLEMENT = "need_supplement" + PENDING_USER_CONFIRM = "pending_user_confirm" + SUBMITTING = "submitting" + SYNCED = "synced" + SYNC_FAILED = "sync_failed" + CLOSED = "closed" + +class ExpenseType(str, enum.Enum): + TRAVEL_TRANSPORT = "travel_transport" + TRAVEL_HOTEL = "travel_hotel" + TRAVEL_MEAL = "travel_meal" + LOCAL_TRANSPORT = "local_transport" + BUSINESS_MEAL = "business_meal" + OFFICE_SUPPLY = "office_supply" + COMMUNICATION = "communication" + OTHER = "other" + +class DocumentType(str, enum.Enum): + VAT_INVOICE = "vat_invoice" + TRAIN_TICKET = "train_ticket" + FLIGHT_ITINERARY = "flight_itinerary" + TAXI_RECEIPT = "taxi_receipt" + HOTEL_BILL = "hotel_bill" + PAYMENT_SCREENSHOT = "payment_screenshot" + TRAVEL_ORDER = "travel_order" + OTHER_ATTACHMENT = "other_attachment" + +class RiskLevel(str, enum.Enum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + BLOCKED = "blocked" + +class RuleAction(str, enum.Enum): + PASS = "pass" + WARN = "warn" + REQUIRE_EXPLANATION = "require_explanation" + REQUIRE_ATTACHMENT = "require_attachment" + REQUIRE_APPROVAL = "require_approval" + BLOCK = "block" + +class SyncStatus(str, enum.Enum): + SUCCESS = "success" + FAILED = "failed" + RETRYING = "retrying" + PENDING = "pending" +``` + +- [ ] **Step 2: 定义 Base Mixin** + +`backend/app/models/base.py`: + +```python +import uuid +from datetime import datetime +from sqlalchemy import String, DateTime, func +from sqlalchemy.orm import Mapped, mapped_column + +def generate_id(): + return str(uuid.uuid4()) + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) + +class IDMixin: + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_id) +``` + +- [ ] **Step 3: 定义 ReimbursementTask 模型** + +`backend/app/models/task.py`: + +```python +from sqlalchemy import String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.models.base import IDMixin, TimestampMixin, Base +from app.models.enums import TaskStatus + +class ReimbursementTask(IDMixin, TimestampMixin, Base): + __tablename__ = "reimbursement_task" + + user_id: Mapped[str] = mapped_column(String(36), index=True) + company_id: Mapped[str] = mapped_column(String(36), index=True) + task_type: Mapped[str] = mapped_column(String(50), default="travel_expense") + status: Mapped[TaskStatus] = mapped_column(default=TaskStatus.CREATED) + user_intent: Mapped[str | None] = mapped_column(Text, nullable=True) + current_agent: Mapped[str | None] = mapped_column(String(50), nullable=True) + + documents = relationship("ExpenseDocument", back_populates="task", lazy="selectin") + reimbursement = relationship("ShadowReimbursement", back_populates="task", uselist=False, lazy="selectin") +``` + +- [ ] **Step 4: 定义 ShadowReimbursement + ReimbursementItem + SupplementRequest + SyncRecord** + +`backend/app/models/reimbursement.py` — 字段按开发文档 5.2.2 ~ 5.2.4, 5.2.7, 5.2.8 节: + +```python +from sqlalchemy import String, Text, Numeric, Date, ForeignKey, Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column, relationship +from decimal import Decimal +from datetime import date +from app.models.base import IDMixin, TimestampMixin, Base +from app.models.enums import RiskLevel, SyncStatus + +class ShadowReimbursement(IDMixin, TimestampMixin, Base): + __tablename__ = "shadow_reimbursement" + + task_id: Mapped[str] = mapped_column(String(36), ForeignKey("reimbursement_task.id"), unique=True) + applicant_id: Mapped[str] = mapped_column(String(36)) + department_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + cost_center_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + project_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + reimbursement_type: Mapped[str] = mapped_column(String(50)) + reason: Mapped[str | None] = mapped_column(Text, nullable=True) + total_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=Decimal("0")) + currency: Mapped[str] = mapped_column(String(10), default="CNY") + risk_level: Mapped[str | None] = mapped_column(String(20), nullable=True) + precheck_status: Mapped[str | None] = mapped_column(String(30), nullable=True) + backend_system: Mapped[str | None] = mapped_column(String(50), nullable=True) + backend_bill_id: Mapped[str | None] = mapped_column(String(50), nullable=True) + sync_status: Mapped[str | None] = mapped_column(String(20), nullable=True) + + task = relationship("ReimbursementTask", back_populates="reimbursement") + items = relationship("ReimbursementItem", back_populates="reimbursement", lazy="selectin") + +class ReimbursementItem(IDMixin, TimestampMixin, Base): + __tablename__ = "reimbursement_item" + + reimbursement_id: Mapped[str] = mapped_column(String(36), ForeignKey("shadow_reimbursement.id")) + expense_type: Mapped[str] = mapped_column(String(50)) + amount: Mapped[Decimal] = mapped_column(Numeric(12, 2)) + tax_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) + occurred_at: Mapped[date | None] = mapped_column(Date, nullable=True) + city: Mapped[str | None] = mapped_column(String(50), nullable=True) + vendor_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + invoice_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + policy_status: Mapped[str | None] = mapped_column(String(20), nullable=True) + risk_level: Mapped[str | None] = mapped_column(String(20), nullable=True) + remark: Mapped[str | None] = mapped_column(Text, nullable=True) + + reimbursement = relationship("ShadowReimbursement", back_populates="items") + +class SupplementRequest(IDMixin, TimestampMixin, Base): + __tablename__ = "supplement_request" + + reimbursement_id: Mapped[str] = mapped_column(String(36), ForeignKey("shadow_reimbursement.id")) + request_type: Mapped[str] = mapped_column(String(30)) # attachment / explanation / field_modify + target_item_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + message: Mapped[str] = mapped_column(Text) + status: Mapped[str] = mapped_column(String(20), default="pending") # pending / resolved / closed + user_response: Mapped[str | None] = mapped_column(Text, nullable=True) + resolved_at: Mapped[date | None] = mapped_column(Date, nullable=True) + +class SyncRecord(IDMixin, TimestampMixin, Base): + __tablename__ = "sync_record" + + reimbursement_id: Mapped[str] = mapped_column(String(36), ForeignKey("shadow_reimbursement.id")) + target_system: Mapped[str] = mapped_column(String(50)) + request_payload: Mapped[dict | None] = mapped_column(Text, nullable=True) # JSON string + response_payload: Mapped[dict | None] = mapped_column(Text, nullable=True) # JSON string + sync_status: Mapped[str] = mapped_column(String(20), default="pending") + backend_bill_id: Mapped[str | None] = mapped_column(String(50), nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) +``` + +- [ ] **Step 5: 定义 ExpenseDocument 模型** + +`backend/app/models/document.py` — 字段按开发文档 5.2.4 节: + +```python +from sqlalchemy import String, Text, Numeric, Date, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from decimal import Decimal +from datetime import date +from app.models.base import IDMixin, TimestampMixin, Base + +class ExpenseDocument(IDMixin, TimestampMixin, Base): + __tablename__ = "expense_document" + + reimbursement_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("shadow_reimbursement.id"), nullable=True) + item_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + task_id: Mapped[str] = mapped_column(String(36), ForeignKey("reimbursement_task.id")) + document_type: Mapped[str] = mapped_column(String(30)) + file_url: Mapped[str] = mapped_column(String(500)) + ocr_status: Mapped[str] = mapped_column(String(20), default="pending") # pending / processing / done / failed + extracted_json: Mapped[dict | None] = mapped_column(Text, nullable=True) + invoice_code: Mapped[str | None] = mapped_column(String(30), nullable=True) + invoice_number: Mapped[str | None] = mapped_column(String(30), nullable=True) + invoice_date: Mapped[date | None] = mapped_column(Date, nullable=True) + amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) + tax_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) + seller_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + buyer_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + verify_status: Mapped[str | None] = mapped_column(String(20), nullable=True) + duplicate_status: Mapped[str | None] = mapped_column(String(20), nullable=True) + + task = relationship("ReimbursementTask", back_populates="documents") +``` + +- [ ] **Step 6: 定义 ExpenseRule + RuleHit 模型** + +`backend/app/models/rule.py` — 字段按开发文档 5.2.5, 5.2.6 节: + +```python +from sqlalchemy import String, Text, Boolean +from sqlalchemy.orm import Mapped, mapped_column +from app.models.base import IDMixin, TimestampMixin, Base + +class ExpenseRule(IDMixin, TimestampMixin, Base): + __tablename__ = "expense_rule" + + rule_code: Mapped[str] = mapped_column(String(50), unique=True) + rule_name: Mapped[str] = mapped_column(String(100)) + expense_type: Mapped[str] = mapped_column(String(50)) + condition_json: Mapped[dict] = mapped_column(Text) # JSON string + action: Mapped[str] = mapped_column(String(30)) + severity: Mapped[str] = mapped_column(String(20)) + message_template: Mapped[str] = mapped_column(Text) + policy_ref: Mapped[str | None] = mapped_column(String(200), nullable=True) + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + version: Mapped[str] = mapped_column(String(10), default="1.0") + +class RuleHit(IDMixin, TimestampMixin, Base): + __tablename__ = "rule_hit" + + reimbursement_id: Mapped[str] = mapped_column(String(36)) + item_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + rule_id: Mapped[str] = mapped_column(String(36)) + severity: Mapped[str] = mapped_column(String(20)) + hit_result: Mapped[dict | None] = mapped_column(Text, nullable=True) # JSON string + explanation: Mapped[str | None] = mapped_column(Text, nullable=True) + suggestion: Mapped[str | None] = mapped_column(Text, nullable=True) +``` + +- [ ] **Step 7: 定义 AuditLog 模型** + +`backend/app/models/audit.py`: + +```python +from sqlalchemy import String, Text +from sqlalchemy.orm import Mapped, mapped_column +from app.models.base import IDMixin, TimestampMixin, Base + +class AuditLog(IDMixin, TimestampMixin, Base): + __tablename__ = "audit_log" + + action: Mapped[str] = mapped_column(String(50), index=True) # upload / ocr / agent / rule_hit / supplement / confirm / sync + actor: Mapped[str] = mapped_column(String(36), index=True) + target_type: Mapped[str] = mapped_column(String(50)) # task / document / reimbursement / rule + target_id: Mapped[str] = mapped_column(String(36)) + detail: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON string +``` + +- [ ] **Step 8: 更新 `models/__init__.py` 导出所有模型** + +```python +from app.models.task import ReimbursementTask +from app.models.reimbursement import ShadowReimbursement, ReimbursementItem, SupplementRequest, SyncRecord +from app.models.document import ExpenseDocument +from app.models.rule import ExpenseRule, RuleHit +from app.models.audit import AuditLog +from app.models.base import Base + +__all__ = [ + "ReimbursementTask", "ShadowReimbursement", "ReimbursementItem", + "ExpenseDocument", "ExpenseRule", "RuleHit", + "SupplementRequest", "SyncRecord", "AuditLog", "Base", +] +``` + +- [ ] **Step 9: 生成 Alembic 迁移** + +Run: `cd backend && alembic revision --autogenerate -m "init schema"` +Run: `cd backend && alembic upgrade head` + +- [ ] **Step 10: 编写模型测试** + +`backend/tests/test_models.py` — 验证所有表能正确创建和插入数据。 + +- [ ] **Step 11: Commit** + +```bash +git add backend/ +git commit -m "feat: 完成所有数据模型定义和数据库迁移" +``` + +--- + +### Task 1.3: 前端项目骨架搭建 + +**负责人:** 前端工程师 +**预计工时:** 1 天 +**前置依赖:** 无(可与 Task 1.1 并行) + +**Files:** +- Create: Vue3 + TypeScript 项目(Vite 初始化) +- Create: `frontend/src/router/index.ts` +- Create: `frontend/src/stores/` +- Create: `frontend/src/api/` +- Create: `frontend/src/views/` +- Create: `frontend/src/components/` +- Create: `frontend/src/layouts/` + +- [ ] **Step 1: 初始化 Vue3 项目** + +```bash +npm create vite@latest frontend -- --template vue-ts +cd frontend +npm install ant-design-vue @ant-design/icons-vue vue-router pinia axios dayjs +``` + +目录结构: + +``` +frontend/ +├── src/ +│ ├── api/ # API 调用 +│ │ ├── index.ts # axios 实例 +│ │ ├── task.ts # 报销任务 API +│ │ ├── document.ts # 票据 API +│ │ └── precheck.ts # 预审 API +│ ├── components/ # 通用组件 +│ │ ├── FileUpload.vue +│ │ ├── ExpenseTable.vue +│ │ └── RuleHitCard.vue +│ ├── layouts/ +│ │ └── MainLayout.vue +│ ├── router/ +│ │ └── index.ts +│ ├── stores/ +│ │ ├── task.ts +│ │ └── user.ts +│ ├── views/ +│ │ ├── HomeView.vue # 报销入口 +│ │ ├── UploadView.vue # 票据上传 +│ │ ├── DraftView.vue # 报销草稿 +│ │ ├── PrecheckView.vue # 预审结果 +│ │ ├── SupplementView.vue # 补件交互 +│ │ ├── ConfirmView.vue # 提交确认 +│ │ └── AuditView.vue # 审计日志 +│ ├── App.vue +│ └── main.ts +├── index.html +├── vite.config.ts +├── tsconfig.json +└── package.json +``` + +- [ ] **Step 2: 配置路由和布局** + +`frontend/src/router/index.ts`: + +```typescript +import { createRouter, createWebHistory } from 'vue-router' + +const routes = [ + { path: '/', name: 'home', component: () => import('@/views/HomeView.vue') }, + { path: '/task/:taskId/upload', name: 'upload', component: () => import('@/views/UploadView.vue') }, + { path: '/task/:taskId/draft', name: 'draft', component: () => import('@/views/DraftView.vue') }, + { path: '/task/:taskId/precheck', name: 'precheck', component: () => import('@/views/PrecheckView.vue') }, + { path: '/task/:taskId/supplement', name: 'supplement', component: () => import('@/views/SupplementView.vue') }, + { path: '/task/:taskId/confirm', name: 'confirm', component: () => import('@/views/ConfirmView.vue') }, + { path: '/audit', name: 'audit', component: () => import('@/views/AuditView.vue') }, +] + +export default createRouter({ history: createWebHistory(), routes }) +``` + +- [ ] **Step 3: 配置 API 封装** + +`frontend/src/api/index.ts`: + +```typescript +import axios from 'axios' + +const api = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1', + timeout: 30000, +}) + +export default api +``` + +- [ ] **Step 4: 确认前端能正常启动** + +Run: `cd frontend && npm run dev` +Expected: 浏览器访问 http://localhost:5173 能看到页面 + +- [ ] **Step 5: Commit** + +```bash +git add frontend/ +git commit -m "feat: 初始化前端项目骨架(Vue3 + TypeScript + Ant Design Vue)" +``` + +--- + +### Task 1.4: Docker Compose 开发环境 + +**负责人:** 后端工程师 B +**预计工时:** 0.5 天 +**前置依赖:** 无(可与 Task 1.1、1.3 并行) + +**Files:** +- Create: `docker-compose.yml` +- Create: `backend/Dockerfile` +- Create: `frontend/Dockerfile` +- Create: `.env.example` + +- [ ] **Step 1: 编写 docker-compose.yml** + +```yaml +version: "3.8" +services: + postgres: + image: postgres:15 + environment: + POSTGRES_DB: x_financial + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + + minio: + image: minio/minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + +volumes: + pgdata: + minio_data: +``` + +- [ ] **Step 2: 编写 .env.example** + +```env +# 数据库 +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/x_financial + +# Redis +REDIS_URL=redis://localhost:6379/0 + +# MinIO +MINIO_ENDPOINT=localhost:9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_BUCKET=reimbursement + +# OCR(必填:正式环境;开发环境可用 mock) +OCR_PROVIDER=mock +BAIDU_OCR_API_KEY= +BAIDU_OCR_SECRET_KEY= + +# LLM(必填:正式环境) +LLM_PROVIDER=openai +LLM_API_KEY= +LLM_MODEL=gpt-4o-mini +LLM_BASE_URL= + +# 前端 +VITE_API_BASE_URL=http://localhost:8000/api/v1 +``` + +- [ ] **Step 3: 编写后端 Dockerfile** + +```dockerfile +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +- [ ] **Step 4: 验证环境启动** + +Run: `docker-compose up -d` +Run: `docker-compose ps` +Expected: postgres, redis, minio 均为 running + +- [ ] **Step 5: Commit** + +```bash +git add docker-compose.yml .env.example backend/Dockerfile frontend/Dockerfile +git commit -m "feat: 添加 Docker Compose 开发环境(PostgreSQL + Redis + MinIO)" +``` + +--- + +## 本阶段完成检查 + +- [ ] `cd backend && pytest tests/test_health.py -v` 通过 +- [ ] `cd backend && alembic upgrade head` 无报错,所有表已创建 +- [ ] `cd frontend && npm run dev` 能正常启动 +- [ ] `docker-compose up -d` 三个服务均 running +- [ ] `.env.example` 已创建,配置项说明完整 diff --git a/docs/plans/phase-2-backend-core/README.md b/docs/plans/phase-2-backend-core/README.md new file mode 100644 index 0000000..532dfb3 --- /dev/null +++ b/docs/plans/phase-2-backend-core/README.md @@ -0,0 +1,834 @@ +# Phase 2: 后端核心服务(W2-W3) + +> **目标:** 实现所有后端业务 API,包括任务管理、文件上传、OCR 集成、规则引擎、影子账本、补件与提交。 +> **周期:** 第 2 ~ 3 周 +> **任务数:** 6 个 +> **可并行:** Task 2.1 / 2.2 / 2.3 可并行;Task 2.4 依赖 2.1;Task 2.5 依赖 2.2 + 2.4 +> **前置依赖:** Phase 1 完成 + +--- + +## 本阶段交付物 + +| 交付物 | 说明 | +|---|---| +| 报销任务 API | 创建/查询/列表 | +| 文件上传 API | MinIO 存储 + 票据管理 | +| OCR 服务 | 百度云 + Mock Provider | +| 规则引擎 | 6 条核心规则 + 管理 API | +| 影子账本 API | 草稿/预审结果查询 | +| 补件 + 提交 API | 补件交互 + 模拟同步 | + +--- + +## 任务清单 + +### Task 2.1: 报销任务管理 API + +**负责人:** 后端工程师 A +**预计工时:** 1.5 天 +**前置依赖:** Phase 1 + +**Files:** +- Create: `backend/app/schemas/task.py` +- Create: `backend/app/services/task_service.py` +- Create: `backend/app/api/v1/tasks.py` +- Modify: `backend/app/api/v1/router.py` +- Test: `backend/tests/test_task_api.py` + +- [ ] **Step 1: 定义 Pydantic schemas** + +`backend/app/schemas/task.py`: + +```python +from pydantic import BaseModel +from datetime import datetime + +class TaskCreateRequest(BaseModel): + user_id: str + company_id: str + user_intent: str + entry_channel: str = "web" + +class TaskResponse(BaseModel): + task_id: str + status: str + + class Config: + from_attributes = True + +class TaskDetailResponse(BaseModel): + id: str + user_id: str + company_id: str + task_type: str + status: str + user_intent: str | None + current_agent: str | None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + +class TaskListResponse(BaseModel): + total: int + items: list[TaskDetailResponse] +``` + +- [ ] **Step 2: 实现 TaskService 业务逻辑** + +`backend/app/services/task_service.py`: + +```python +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from app.models.task import ReimbursementTask +from app.models.enums import TaskStatus +from app.schemas.task import TaskCreateRequest + +class TaskService: + def __init__(self, db: AsyncSession): + self.db = db + + async def create_task(self, req: TaskCreateRequest) -> ReimbursementTask: + task = ReimbursementTask( + user_id=req.user_id, + company_id=req.company_id, + user_intent=req.user_intent, + status=TaskStatus.MATERIAL_COLLECTING, + ) + self.db.add(task) + await self.db.commit() + await self.db.refresh(task) + return task + + async def get_task(self, task_id: str) -> ReimbursementTask | None: + result = await self.db.execute( + select(ReimbursementTask).where(ReimbursementTask.id == task_id) + ) + return result.scalar_one_or_none() + + async def list_tasks(self, user_id: str | None = None, status: str | None = None, + page: int = 1, size: int = 20) -> tuple[list[ReimbursementTask], int]: + query = select(ReimbursementTask) + count_query = select(func.count()).select_from(ReimbursementTask) + + if user_id: + query = query.where(ReimbursementTask.user_id == user_id) + count_query = count_query.where(ReimbursementTask.user_id == user_id) + if status: + query = query.where(ReimbursementTask.status == status) + count_query = count_query.where(ReimbursementTask.status == status) + + total_result = await self.db.execute(count_query) + total = total_result.scalar() or 0 + + query = query.offset((page - 1) * size).limit(size).order_by(ReimbursementTask.created_at.desc()) + result = await self.db.execute(query) + return result.scalars().all(), total + + async def update_status(self, task_id: str, status: TaskStatus, current_agent: str | None = None) -> ReimbursementTask | None: + task = await self.get_task(task_id) + if not task: + return None + task.status = status + task.current_agent = current_agent + await self.db.commit() + await self.db.refresh(task) + return task +``` + +- [ ] **Step 3: 实现 API 路由** + +`backend/app/api/v1/tasks.py`: + +```python +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from app.core.database import get_db +from app.schemas.task import TaskCreateRequest, TaskResponse, TaskDetailResponse, TaskListResponse +from app.services.task_service import TaskService + +router = APIRouter(prefix="/reimbursement/tasks", tags=["tasks"]) + +@router.post("", response_model=TaskResponse, status_code=201) +async def create_task(req: TaskCreateRequest, db: AsyncSession = Depends(get_db)): + svc = TaskService(db) + task = await svc.create_task(req) + return TaskResponse(task_id=task.id, status=task.status.value) + +@router.get("/{task_id}", response_model=TaskDetailResponse) +async def get_task(task_id: str, db: AsyncSession = Depends(get_db)): + svc = TaskService(db) + task = await svc.get_task(task_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + return task + +@router.get("", response_model=TaskListResponse) +async def list_tasks(user_id: str | None = None, status: str | None = None, + page: int = 1, size: int = 20, db: AsyncSession = Depends(get_db)): + svc = TaskService(db) + items, total = await svc.list_tasks(user_id, status, page, size) + return TaskListResponse(total=total, items=items) +``` + +更新 `backend/app/api/v1/router.py`: + +```python +from fastapi import APIRouter +from app.api.v1.tasks import router as tasks_router + +api_router = APIRouter() +api_router.include_router(tasks_router) +``` + +- [ ] **Step 4: 编写测试** + +```python +# backend/tests/test_task_api.py +import pytest + +@pytest.mark.asyncio +async def test_create_task(client): + response = await client.post("/api/v1/reimbursement/tasks", json={ + "user_id": "U001", + "company_id": "C001", + "user_intent": "我要报这次北京出差的费用", + "entry_channel": "web" + }) + assert response.status_code == 201 + data = response.json() + assert "task_id" in data + assert data["status"] == "material_collecting" + +@pytest.mark.asyncio +async def test_get_task(client): + # 先创建 + create_resp = await client.post("/api/v1/reimbursement/tasks", json={ + "user_id": "U001", "company_id": "C001", "user_intent": "test" + }) + task_id = create_resp.json()["task_id"] + # 再查询 + get_resp = await client.get(f"/api/v1/reimbursement/tasks/{task_id}") + assert get_resp.status_code == 200 + assert get_resp.json()["user_id"] == "U001" + +@pytest.mark.asyncio +async def test_list_tasks(client): + response = await client.get("/api/v1/reimbursement/tasks") + assert response.status_code == 200 + assert "total" in response.json() + assert "items" in response.json() +``` + +- [ ] **Step 5: 运行测试** + +Run: `cd backend && pytest tests/test_task_api.py -v` +Expected: All PASS + +- [ ] **Step 6: Commit** + +```bash +git add backend/ +git commit -m "feat: 实现报销任务管理 API(创建/查询/列表)" +``` + +--- + +### Task 2.2: 文件上传与票据管理 API + +**负责人:** 后端工程师 B +**预计工时:** 1.5 天 +**前置依赖:** Phase 1 + +**Files:** +- Create: `backend/app/schemas/document.py` +- Create: `backend/app/services/document_service.py` +- Create: `backend/app/services/storage_service.py` +- Create: `backend/app/api/v1/documents.py` +- Modify: `backend/app/api/v1/router.py` +- Test: `backend/tests/test_document_api.py` + +- [ ] **Step 1: 实现 MinIO 存储服务** + +`backend/app/services/storage_service.py` — 封装 MinIO 操作: +- `upload_file(bucket, file_name, file_data, content_type)` → 上传文件到 MinIO +- `get_file_url(bucket, file_name)` → 获取文件访问 URL +- `delete_file(bucket, file_name)` → 删除文件 +- `ensure_bucket(bucket)` → 确保 bucket 存在 + +开发阶段可使用 mock 实现(本地文件系统存储)。 + +- [ ] **Step 2: 实现文档服务** + +`backend/app/services/document_service.py`: +- `upload_document(task_id, file, document_type)` → 保存文件到 MinIO,创建 DB 记录 +- `get_documents(task_id)` → 查询任务下所有票据 +- `get_document(document_id)` → 查询单个票据 +- `update_ocr_result(document_id, ocr_result)` → 更新 OCR 识别结果 + +- [ ] **Step 3: 定义 Pydantic schemas** + +`backend/app/schemas/document.py`: + +```python +from pydantic import BaseModel +from datetime import date +from decimal import Decimal + +class DocumentUploadResponse(BaseModel): + document_id: str + ocr_status: str + +class DocumentResponse(BaseModel): + id: str + task_id: str + document_type: str + file_url: str + ocr_status: str + invoice_code: str | None + invoice_number: str | None + invoice_date: date | None + amount: Decimal | None + seller_name: str | None + + class Config: + from_attributes = True +``` + +- [ ] **Step 4: 实现 API 路由** + +`backend/app/api/v1/documents.py`: + +```python +from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from app.core.database import get_db +from app.schemas.document import DocumentUploadResponse, DocumentResponse +from app.services.document_service import DocumentService + +router = APIRouter(prefix="/reimbursement/tasks/{task_id}/documents", tags=["documents"]) + +@router.post("", response_model=DocumentUploadResponse, status_code=201) +async def upload_document( + task_id: str, + file: UploadFile = File(...), + document_type: str = Form(...), + db: AsyncSession = Depends(get_db) +): + svc = DocumentService(db) + doc = await svc.upload_document(task_id, file, document_type) + return DocumentUploadResponse(document_id=doc.id, ocr_status=doc.ocr_status) + +@router.get("", response_model=list[DocumentResponse]) +async def list_documents(task_id: str, db: AsyncSession = Depends(get_db)): + svc = DocumentService(db) + docs = await svc.get_documents(task_id) + return docs +``` + +在 `router.py` 中注册 documents_router。 + +- [ ] **Step 5: 编写测试(使用 mock MinIO)** + +- [ ] **Step 6: Commit** + +```bash +git add backend/ +git commit -m "feat: 实现文件上传与票据管理 API(MinIO 存储)" +``` + +--- + +### Task 2.3: OCR 服务集成 + +**负责人:** 后端工程师 B +**预计工时:** 2 天 +**前置依赖:** Task 2.2(需要 document_service) +**可并行于:** Task 2.1、Task 2.4 + +**Files:** +- Create: `backend/app/services/ocr_service.py` +- Create: `backend/app/services/ocr_providers/__init__.py` +- Create: `backend/app/services/ocr_providers/base.py` +- Create: `backend/app/services/ocr_providers/baidu.py` +- Create: `backend/app/services/ocr_providers/mock.py` +- Test: `backend/tests/test_ocr_service.py` + +- [ ] **Step 1: 定义 OCR Provider 抽象接口** + +`backend/app/services/ocr_providers/base.py`: + +```python +from abc import ABC, abstractmethod +from dataclasses import dataclass, field + +@dataclass +class OCRResult: + document_type: str # 识别出的票据类型 + raw_text: str # 原始文字 + fields: dict = field(default_factory=dict) # 结构化字段 + confidence: float = 0.0 # 整体置信度 0-1 + provider: str = "" # 提供商名称 + +class OCRProvider(ABC): + @abstractmethod + async def recognize(self, file_url: str, document_type: str | None = None) -> OCRResult: + ... + + @abstractmethod + async def recognize_vat_invoice(self, file_url: str) -> OCRResult: + ... + + @abstractmethod + async def recognize_train_ticket(self, file_url: str) -> OCRResult: + ... +``` + +- [ ] **Step 2: 实现 Mock OCR Provider** + +`backend/app/services/ocr_providers/mock.py` — 根据文件名/类型返回预定义的结构化数据: + +```python +from app.services.ocr_providers.base import OCRProvider, OCRResult + +class MockOCRProvider(OCRProvider): + async def recognize(self, file_url: str, document_type: str | None = None) -> OCRResult: + if document_type == "vat_invoice" or "invoice" in file_url: + return await self.recognize_vat_invoice(file_url) + elif document_type == "train_ticket" or "train" in file_url: + return await self.recognize_train_ticket(file_url) + return OCRResult(document_type="unknown", raw_text="", confidence=0.0, provider="mock") + + async def recognize_vat_invoice(self, file_url: str) -> OCRResult: + return OCRResult( + document_type="vat_invoice", + raw_text="增值税电子普通发票", + fields={ + "invoice_code": "050002100311", + "invoice_number": "23912077", + "invoice_date": "2026-04-20", + "amount": "1061.95", + "tax_amount": "61.95", + "total_amount": "1123.90", + "seller_name": "北京XX酒店管理有限公司", + "buyer_name": "XX科技有限公司", + "check_code": "1234567890", + }, + confidence=0.95, + provider="mock" + ) + + async def recognize_train_ticket(self, file_url: str) -> OCRResult: + return OCRResult( + document_type="train_ticket", + raw_text="火车票", + fields={ + "train_number": "G101", + "departure_station": "北京南", + "arrival_station": "上海虹桥", + "departure_date": "2026-04-18", + "departure_time": "07:00", + "seat_type": "二等座", + "amount": "553.00", + "passenger_name": "张三", + "id_number": "****1234", + }, + confidence=0.90, + provider="mock" + ) +``` + +- [ ] **Step 3: 实现百度 OCR Provider** + +`backend/app/services/ocr_providers/baidu.py` — 调用百度云 OCR API: +- `recognize_vat_invoice()` → 调用增值税发票识别接口 +- `recognize_train_ticket()` → 调用火车票识别接口 +- `recognize()` → 自动判断票据类型,调用对应接口 +- 将百度返回结果标准化为 `OCRResult` +- 包含 access_token 获取和缓存逻辑 + +- [ ] **Step 4: 实现 OCR Service 门面** + +`backend/app/services/ocr_service.py`: + +```python +from app.core.config import settings +from app.services.ocr_providers.base import OCRResult +from app.services.ocr_providers.mock import MockOCRProvider +from app.services.ocr_providers.baidu import BaiduOCRProvider + +class OCRService: + def __init__(self): + self._provider = self._create_provider() + + def _create_provider(self): + if settings.OCR_PROVIDER == "mock": + return MockOCRProvider() + elif settings.OCR_PROVIDER == "baidu": + return BaiduOCRProvider( + api_key=settings.BAIDU_OCR_API_KEY, + secret_key=settings.BAIDU_OCR_SECRET_KEY + ) + raise ValueError(f"Unknown OCR provider: {settings.OCR_PROVIDER}") + + async def recognize(self, file_url: str, document_type: str | None = None) -> OCRResult: + return await self._provider.recognize(file_url, document_type) +``` + +- [ ] **Step 5: 编写测试** + +使用 Mock Provider 测试完整 OCR 流程。 + +- [ ] **Step 6: Commit** + +```bash +git add backend/ +git commit -m "feat: 实现 OCR 服务(百度云 + Mock Provider)" +``` + +--- + +### Task 2.4: 规则引擎 + +**负责人:** 后端工程师 A +**预计工时:** 3 天 +**前置依赖:** Task 2.1(需要 task 模型) + +**Files:** +- Create: `backend/app/services/rule_engine.py` +- Create: `backend/app/services/rule_checkers/__init__.py` +- Create: `backend/app/services/rule_checkers/base.py` +- Create: `backend/app/services/rule_checkers/required_fields.py` +- Create: `backend/app/services/rule_checkers/attachment_check.py` +- Create: `backend/app/services/rule_checkers/duplicate_invoice.py` +- Create: `backend/app/services/rule_checkers/amount_limit.py` +- Create: `backend/app/services/rule_checkers/date_validity.py` +- Create: `backend/app/services/rule_checkers/expense_type_match.py` +- Create: `backend/app/schemas/rule.py` +- Create: `backend/app/api/v1/rules.py` +- Modify: `backend/app/api/v1/router.py` +- Test: `backend/tests/test_rule_engine.py` + +- [ ] **Step 1: 定义规则检查器基类** + +`backend/app/services/rule_checkers/base.py`: + +```python +from abc import ABC, abstractmethod +from dataclasses import dataclass + +@dataclass +class RuleCheckResult: + rule_code: str + severity: str # low / medium / high / blocked + action: str # pass / warn / require_explanation / require_attachment / require_approval / block + message: str + suggestion: str + policy_ref: str + hit_detail: dict + +class RuleChecker(ABC): + @abstractmethod + async def check(self, context: dict) -> RuleCheckResult | None: + """检查规则,命中返回结果,未命中返回 None""" + ... +``` + +- [ ] **Step 2: 实现 RuleEngine 核心引擎** + +`backend/app/services/rule_engine.py`: + +```python +from sqlalchemy.ext.asyncio import AsyncSession +from app.services.rule_checkers.base import RuleChecker, RuleCheckResult +from dataclasses import dataclass, field + +@dataclass +class PrecheckResult: + precheck_status: str # pass / need_supplement / blocked + risk_level: str # low / medium / high / blocked + rule_hits: list[RuleCheckResult] = field(default_factory=list) + summary: str = "" + +class RuleEngine: + def __init__(self, db: AsyncSession): + self.db = db + self.checkers: list[RuleChecker] = [] + + def register_checker(self, checker: RuleChecker): + self.checkers.append(checker) + + async def run_precheck(self, context: dict) -> PrecheckResult: + """执行完整预审,遍历所有注册的 checker""" + hits: list[RuleCheckResult] = [] + for checker in self.checkers: + result = await checker.check(context) + if result: + hits.append(result) + + risk_level = self._calculate_overall_risk(hits) + status = self._determine_status(hits) + summary = self._generate_summary(hits) + + return PrecheckResult( + precheck_status=status, + risk_level=risk_level, + rule_hits=hits, + summary=summary + ) + + def _calculate_overall_risk(self, hits: list[RuleCheckResult]) -> str: + if not hits: + return "low" + severity_order = {"blocked": 4, "high": 3, "medium": 2, "low": 1} + max_severity = max(hits, key=lambda h: severity_order.get(h.severity, 0)) + return max_severity.severity + + def _determine_status(self, hits: list[RuleCheckResult]) -> str: + if not hits: + return "pass" + if any(h.action == "block" for h in hits): + return "blocked" + return "need_supplement" + + def _generate_summary(self, hits: list[RuleCheckResult]) -> str: + if not hits: + return "预审通过,未发现风险。" + blocked = sum(1 for h in hits if h.action == "block") + warnings = sum(1 for h in hits if h.action in ("warn", "require_explanation")) + supplements = sum(1 for h in hits if h.action == "require_attachment") + parts = [] + if blocked: + parts.append(f"{blocked} 个阻断项") + if supplements: + parts.append(f"{supplements} 个缺件") + if warnings: + parts.append(f"{warnings} 个风险提示") + return f"当前报销单存在{'、'.join(parts)}。" +``` + +- [ ] **Step 3: 实现 6 条核心规则检查器** + +1. **`required_fields.py`** — `RequiredFieldsChecker` — 必填字段校验 + - 检查报销人、部门、事由、费用明细是否有空值 + - 命中返回 `require_explanation` + +2. **`attachment_check.py`** — `AttachmentCheckChecker` — 附件完整性校验 + - 住宿费必须上传酒店流水 + - 交通费必须上传对应票据 + - 命中返回 `require_attachment` + +3. **`duplicate_invoice.py`** — `DuplicateInvoiceChecker` — 重复发票检查 + - 检查 invoice_code + invoice_number + amount 是否重复 + - 命中返回 `block` + +4. **`amount_limit.py`** — `AmountLimitChecker` — 金额超标校验 + - 按城市等级和费用类型检查标准 + - 住宿费按每晚金额检查 + - 命中返回 `require_explanation` + +5. **`date_validity.py`** — `DateValidityChecker` — 日期合理性校验 + - 费用日期不能晚于今天 + - 费用日期应在出差期间内 + - 命中返回 `warn` + +6. **`expense_type_match.py`** — `ExpenseTypeMatchChecker` — 费用类型匹配校验 + - 住宿费应关联 hotel_bill 类型票据 + - 交通费应关联 train_ticket / flight_itinerary / taxi_receipt + - 命中返回 `warn` + +- [ ] **Step 4: 实现规则管理 API** + +- `GET /api/v1/rules` — 列出所有规则 +- `POST /api/v1/rules` — 创建规则 +- `PUT /api/v1/rules/{rule_id}` — 更新规则 +- `PATCH /api/v1/rules/{rule_id}/toggle` — 启用/禁用规则 + +- [ ] **Step 5: 编写测试** + +对每条规则编写单元测试: +```python +@pytest.mark.asyncio +async def test_duplicate_invoice_checker(): + checker = DuplicateInvoiceChecker() + # 模拟重复发票场景 + context = {"items": [...], "existing_invoices": [...]} + result = await checker.check(context) + assert result is not None + assert result.action == "block" + +@pytest.mark.asyncio +async def test_no_duplicate(): + checker = DuplicateInvoiceChecker() + context = {"items": [...], "existing_invoices": []} # 无重复 + result = await checker.check(context) + assert result is None +``` + +- [ ] **Step 6: Commit** + +```bash +git add backend/ +git commit -m "feat: 实现规则引擎(6 条核心规则 + 管理 API)" +``` + +--- + +### Task 2.5: 影子报销账本 CRUD + +**负责人:** 后端工程师 A +**预计工时:** 1.5 天 +**前置依赖:** Task 2.1 + Task 2.4 + +**Files:** +- Create: `backend/app/schemas/reimbursement.py` +- Create: `backend/app/services/ledger_service.py` +- Create: `backend/app/api/v1/ledger.py` +- Modify: `backend/app/api/v1/router.py` +- Test: `backend/tests/test_ledger_api.py` + +- [ ] **Step 1: 定义 Pydantic schemas** + +`backend/app/schemas/reimbursement.py`: + +```python +from pydantic import BaseModel +from datetime import date, datetime +from decimal import Decimal + +class ReimbursementItemResponse(BaseModel): + id: str + expense_type: str + amount: Decimal + tax_amount: Decimal | None + occurred_at: date | None + city: str | None + vendor_name: str | None + risk_level: str | None + remark: str | None + + class Config: + from_attributes = True + +class ReimbursementDraftResponse(BaseModel): + reimbursement_id: str + reason: str | None + total_amount: Decimal + precheck_status: str | None + risk_level: str | None + items: list[ReimbursementItemResponse] + created_at: datetime + + class Config: + from_attributes = True + +class PrecheckResultResponse(BaseModel): + precheck_status: str + risk_level: str + summary: str + rule_hits: list[dict] +``` + +- [ ] **Step 2: 实现 LedgerService** + +`backend/app/services/ledger_service.py` — 核心方法: +- `create_shadow_reimbursement(task_id, data)` → 创建影子报销记录 +- `get_draft(reimbursement_id)` → 获取报销草稿 +- `get_draft_by_task(task_id)` → 通过任务 ID 获取草稿 +- `update_precheck_result(reimbursement_id, result)` → 更新预审结果 +- `add_item(reimbursement_id, item_data)` → 添加报销明细 + +- [ ] **Step 3: 实现 API 路由** + +`GET /api/v1/reimbursement/tasks/{task_id}/draft` — 获取报销草稿(对应文档 8.4) +`GET /api/v1/reimbursement/tasks/{task_id}/precheck-result` — 获取预审结果(对应文档 8.5) + +- [ ] **Step 4: 编写测试** + +- [ ] **Step 5: Commit** + +```bash +git add backend/ +git commit -m "feat: 实现影子报销账本 CRUD API" +``` + +--- + +### Task 2.6: 补件与提交 API + +**负责人:** 后端工程师 A +**预计工时:** 1.5 天 +**前置依赖:** Task 2.5 + +**Files:** +- Create: `backend/app/api/v1/supplements.py` +- Create: `backend/app/services/supplement_service.py` +- Create: `backend/app/services/sync_service.py` +- Modify: `backend/app/api/v1/router.py` +- Test: `backend/tests/test_supplement_api.py` + +- [ ] **Step 1: 实现补件服务** + +`backend/app/services/supplement_service.py`: +- `create_supplement_request(reimbursement_id, items)` → 创建补件请求 +- `respond_supplement(request_id, response_text, document_ids)` → 用户补件响应 +- `get_supplement_requests(task_id)` → 查询补件请求列表 + +- [ ] **Step 2: 实现同步服务(MVP 阶段为模拟)** + +`backend/app/services/sync_service.py`: + +```python +import uuid +from datetime import datetime +from sqlalchemy.ext.asyncio import AsyncSession + +class SyncService: + def __init__(self, db: AsyncSession): + self.db = db + + async def mock_sync_to_backend(self, reimbursement_id: str) -> dict: + """模拟后端同步,生成假的 backend_bill_id""" + backend_bill_id = f"BX{datetime.now().strftime('%Y%m%d')}{str(uuid.uuid4())[:6]}" + return { + "sync_status": "success", + "target_system": "expense_system", + "backend_bill_id": backend_bill_id, + } + + async def get_sync_status(self, task_id: str) -> dict | None: + """查询同步状态""" + # 从 sync_record 表查询 + ... +``` + +- [ ] **Step 3: 实现 API 路由** + +`POST /api/v1/reimbursement/tasks/{task_id}/supplements` — 用户补件(对应文档 8.6) +`POST /api/v1/reimbursement/tasks/{task_id}/submit` — 用户确认提交(对应文档 8.7) +`GET /api/v1/reimbursement/tasks/{task_id}/sync-status` — 查询同步状态(对应文档 8.8) + +- [ ] **Step 4: 编写测试** + +- [ ] **Step 5: Commit** + +```bash +git add backend/ +git commit -m "feat: 实现补件与提交确认 API(含模拟同步)" +``` + +--- + +## 本阶段完成检查 + +- [ ] `POST /api/v1/reimbursement/tasks` 创建任务返回 201 +- [ ] `POST /api/v1/reimbursement/tasks/{id}/documents` 上传文件返回 201 +- [ ] OCR Service 对 Mock Provider 正常返回结构化数据 +- [ ] 规则引擎对 6 条规则命中/未命中的测试全部通过 +- [ ] `GET /api/v1/reimbursement/tasks/{id}/draft` 返回草稿数据 +- [ ] `POST /api/v1/reimbursement/tasks/{id}/supplements` 补件返回 received +- [ ] `POST /api/v1/reimbursement/tasks/{id}/submit` 提交返回 submitting +- [ ] 所有测试 `pytest tests/ -v` 全部通过 diff --git a/docs/plans/phase-3-agent-orchestration/README.md b/docs/plans/phase-3-agent-orchestration/README.md new file mode 100644 index 0000000..848c976 --- /dev/null +++ b/docs/plans/phase-3-agent-orchestration/README.md @@ -0,0 +1,568 @@ +# Phase 3: Agent 编排(W3-W4) + +> **目标:** 实现 Agent Orchestrator 状态机、5 个业务 Agent、LLM 集成层和审计日志,完成核心智能处理能力。 +> **周期:** 第 3 ~ 4 周 +> **任务数:** 4 个 +> **可并行:** Task 3.3 / 3.4 可与 Task 3.2 并行 +> **前置依赖:** Phase 2 完成 + +--- + +## 本阶段交付物 + +| 交付物 | 说明 | +|---|---| +| Orchestrator 状态机 | 任务状态流转 + Agent 调度 | +| 5 个 Agent | 受理 / 解析 / 规则校验 / 解释补件 / 同步 | +| LLM 集成层 | 多 Provider 支持 + Prompt 模板 | +| 审计日志 | 所有关键操作留痕 | + +--- + +## 任务清单 + +### Task 3.1: Agent Orchestrator 状态机 + +**负责人:** 全栈/Agent 工程师 +**预计工时:** 2 天 +**前置依赖:** Phase 2(所有 service 就绪) + +**Files:** +- Create: `backend/app/agents/__init__.py` +- Create: `backend/app/agents/state.py` +- Create: `backend/app/agents/orchestrator.py` +- Create: `backend/app/api/v1/agent.py` +- Modify: `backend/app/api/v1/router.py` +- Test: `backend/tests/test_orchestrator.py` + +- [ ] **Step 1: 定义 Agent 状态和上下文** + +`backend/app/agents/state.py`: + +```python +from dataclasses import dataclass, field +from app.models.enums import TaskStatus + +@dataclass +class AgentContext: + task_id: str + status: TaskStatus + user_intent: str | None = None + current_agent: str | None = None + ocr_results: list[dict] = field(default_factory=list) + reimbursement_data: dict | None = None + precheck_result: dict | None = None + supplement_requests: list[dict] = field(default_factory=list) + error_message: str | None = None + retry_count: int = 0 +``` + +- [ ] **Step 2: 定义 Agent 基类和结果** + +`backend/app/agents/base_agent.py`: + +```python +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from sqlalchemy.ext.asyncio import AsyncSession +from app.agents.state import AgentContext + +@dataclass +class AgentResult: + success: bool + data: dict = field(default_factory=dict) + next_action: str = "continue" # continue / wait_user / need_supplement / retry + error: str | None = None + +class BaseAgent(ABC): + name: str = "" + + @abstractmethod + async def execute(self, context: AgentContext, db: AsyncSession) -> AgentResult: + ... +``` + +- [ ] **Step 3: 实现 Orchestrator 状态机** + +`backend/app/agents/orchestrator.py` — 核心编排逻辑: + +状态转换图(对应开发文档 4.2 节): +``` +CREATED → MATERIAL_COLLECTING → PARSING → DRAFT_GENERATED → PRECHECKING + ↑ ↓ + └─── MATERIAL_COLLECTING ←── NEED_SUPPLEMENT ←────────────────┘ + ↓ + PENDING_USER_CONFIRM → SUBMITTING → SYNCED + ↓ + SYNC_FAILED → SUBMITTING(重试) +``` + +```python +from sqlalchemy.ext.asyncio import AsyncSession +from app.agents.state import AgentContext +from app.agents.base_agent import BaseAgent, AgentResult +from app.models.enums import TaskStatus +from app.services.task_service import TaskService + +class Orchestrator: + def __init__(self, db: AsyncSession): + self.db = db + self.agents: dict[str, BaseAgent] = {} + + def register_agent(self, agent: BaseAgent): + self.agents[agent.name] = agent + + async def run(self, task_id: str, start_from: str = "intake") -> AgentContext: + """启动编排流程""" + task_svc = TaskService(self.db) + task = await task_svc.get_task(task_id) + if not task: + raise ValueError(f"Task {task_id} not found") + + context = AgentContext( + task_id=task_id, + status=task.status, + user_intent=task.user_intent, + ) + + # 根据 start_from 决定从哪个状态开始 + agent_sequence = self._get_agent_sequence(start_from) + + for agent_name in agent_sequence: + context.current_agent = agent_name + await task_svc.update_status(task_id, self._agent_to_status(agent_name), agent_name) + + agent = self.agents.get(agent_name) + if not agent: + continue + + result = await agent.execute(context, self.db) + + if not result.success: + context.error_message = result.error + break + + context = self._merge_result(context, result) + + if result.next_action == "wait_user": + await task_svc.update_status(task_id, TaskStatus.PENDING_USER_CONFIRM, agent_name) + break + + if result.next_action == "need_supplement": + await task_svc.update_status(task_id, TaskStatus.NEED_SUPPLEMENT, agent_name) + break + + return context + + def _get_agent_sequence(self, start_from: str) -> list[str]: + sequences = { + "intake": ["intake_agent", "parse_agent", "rule_check_agent", "explain_agent"], + "precheck": ["rule_check_agent", "explain_agent"], + "submit": ["sync_agent"], + } + return sequences.get(start_from, sequences["intake"]) + + def _agent_to_status(self, agent_name: str) -> TaskStatus: + mapping = { + "intake_agent": TaskStatus.MATERIAL_COLLECTING, + "parse_agent": TaskStatus.PARSING, + "rule_check_agent": TaskStatus.PRECHECKING, + "explain_agent": TaskStatus.PRECHECKING, + "sync_agent": TaskStatus.SUBMITTING, + } + return mapping.get(agent_name, TaskStatus.PRECHECKING) + + def _merge_result(self, context: AgentContext, result: AgentResult) -> AgentContext: + """将 Agent 结果合并到上下文""" + data = result.data + if "ocr_results" in data: + context.ocr_results = data["ocr_results"] + if "reimbursement_data" in data: + context.reimbursement_data = data["reimbursement_data"] + if "precheck_result" in data: + context.precheck_result = data["precheck_result"] + if "supplement_requests" in data: + context.supplement_requests = data["supplement_requests"] + return context +``` + +- [ ] **Step 4: 实现 Agent 启动 API** + +`backend/app/api/v1/agent.py`: + +```python +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel +from app.core.database import get_db + +router = APIRouter(prefix="/reimbursement/tasks/{task_id}/agent", tags=["agent"]) + +class AgentRunRequest(BaseModel): + start_from: str = "intake" # intake / precheck / submit + mode: str = "precheck" + +class AgentRunResponse(BaseModel): + task_id: str + status: str + current_agent: str | None + +@router.post("/run", response_model=AgentRunResponse) +async def run_agent(task_id: str, req: AgentRunRequest, db: AsyncSession = Depends(get_db)): + orchestrator = create_orchestrator(db) + context = await orchestrator.run(task_id, start_from=req.start_from) + return AgentRunResponse( + task_id=context.task_id, + status=context.status.value, + current_agent=context.current_agent, + ) +``` + +- [ ] **Step 5: 编写状态机转换测试** + +覆盖路径: +- 正常路径:创建 → 解析 → 草稿 → 预审 → 通过 → 提交 → 同步 +- 补件路径:预审 → 需补件 → 等待用户 +- 重试路径:提交 → 同步失败 → 重试 + +- [ ] **Step 6: Commit** + +```bash +git add backend/ +git commit -m "feat: 实现 Agent Orchestrator 状态机编排" +``` + +--- + +### Task 3.2: 5 个 Agent 实现 + +**负责人:** 全栈/Agent 工程师 +**预计工时:** 3 天 +**前置依赖:** Task 3.1(Orchestrator 就绪) + +**Files:** +- Create: `backend/app/agents/intake_agent.py` +- Create: `backend/app/agents/parse_agent.py` +- Create: `backend/app/agents/rule_check_agent.py` +- Create: `backend/app/agents/explain_agent.py` +- Create: `backend/app/agents/sync_agent.py` +- Test: `backend/tests/test_agents.py` + +- [ ] **Step 1: 实现 IntakeAgent(受理 Agent)** + +`backend/app/agents/intake_agent.py`: +- 分析 user_intent 文本,提取报销类型、出差信息 +- 调用 LLM 做 intent classification +- 返回结构化任务信息(报销类型、出差城市、日期范围等) +- 输出:`AgentResult(data={"task_info": {...}})` + +- [ ] **Step 2: 实现 ParseAgent(单据解析 Agent)** + +`backend/app/agents/parse_agent.py`: +- 遍历任务下所有 document,调用 `ocr_service.recognize()` +- 将 OCR 结果汇总为报销明细 +- 调用 `ledger_service.create_shadow_reimbursement()` 创建影子记录 +- 调用 `ledger_service.add_item()` 添加每条明细 +- 自动识别费用类型(可调用 LLM 辅助) +- 输出:`AgentResult(data={"ocr_results": [...], "reimbursement_data": {...}})` + +- [ ] **Step 3: 实现 RuleCheckAgent(规则校验 Agent)** + +`backend/app/agents/rule_check_agent.py`: +- 构建 context dict(报销数据 + 票据数据 + 已有发票列表) +- 注册 6 个 RuleChecker 到 RuleEngine +- 调用 `rule_engine.run_precheck(context)` +- 保存 RuleHit 记录到 DB +- 更新 shadow_reimbursement 的预审状态 +- 输出:`AgentResult(data={"precheck_result": {...}})` + +- [ ] **Step 4: 实现 ExplainAgent(解释与补件 Agent)** + +`backend/app/agents/explain_agent.py`: +- 遍历 rule_hits,使用 LLM 生成自然语言解释 +- 对 `require_attachment` 类型的命中自动创建 supplement_request +- 生成修改建议 +- 根据预审结果决定 next_action: + - 全部通过 → `continue` + - 有需补件的 → `need_supplement` + - 有阻断的 → `need_supplement` +- 输出:`AgentResult(data={"supplement_requests": [...]}, next_action="need_supplement")` + +- [ ] **Step 5: 实现 SyncAgent(同步执行 Agent)** + +`backend/app/agents/sync_agent.py`: +- 将 ShadowReimbursement 数据映射为标准报销单格式 +- 调用 `sync_service.mock_sync_to_backend()` +- 记录 SyncRecord +- 更新 shadow_reimbursement 的 sync_status 和 backend_bill_id +- 处理同步失败重试(retry_count < 3 时标记 retrying) +- 输出:`AgentResult(data={"sync_result": {...}})` + +- [ ] **Step 6: 编写每个 Agent 的单元测试** + +使用 mock DB、mock OCR、mock LLM 测试每个 Agent 的输入输出。 + +- [ ] **Step 7: Commit** + +```bash +git add backend/ +git commit -m "feat: 实现 5 个 Agent(受理/解析/规则校验/解释补件/同步)" +``` + +--- + +### Task 3.3: LLM 集成层 + +**负责人:** 全栈/Agent 工程师 +**预计工时:** 2 天 +**前置依赖:** Phase 2 +**可并行于:** Task 3.2 + +**Files:** +- Create: `backend/app/services/llm_service.py` +- Create: `backend/app/services/llm_prompts/__init__.py` +- Create: `backend/app/services/llm_prompts/intent_classification.py` +- Create: `backend/app/services/llm_prompts/risk_explanation.py` +- Create: `backend/app/services/llm_prompts/expense_type_mapping.py` +- Test: `backend/tests/test_llm_service.py` + +- [ ] **Step 1: 实现 LLM Service 封装** + +`backend/app/services/llm_service.py`: + +```python +import httpx +import json +from app.core.config import settings + +class LLMService: + def __init__(self): + self.api_key = settings.LLM_API_KEY + self.model = settings.LLM_MODEL + self.base_url = settings.LLM_BASE_URL or "https://api.openai.com/v1" + + async def chat(self, system_prompt: str, user_message: str, json_mode: bool = False) -> str: + """调用 LLM API""" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + payload = { + "model": self.model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_message}, + ], + } + if json_mode: + payload["response_format"] = {"type": "json_object"} + + async with httpx.AsyncClient(timeout=60.0) as client: + resp = await client.post( + f"{self.base_url}/chat/completions", + headers=headers, + json=payload + ) + resp.raise_for_status() + data = resp.json() + return data["choices"][0]["message"]["content"] + + async def chat_json(self, system_prompt: str, user_message: str) -> dict: + """调用 LLM 并解析 JSON 响应""" + raw = await self.chat(system_prompt, user_message, json_mode=True) + return json.loads(raw) +``` + +- [ ] **Step 2: 编写 Prompt 模板** + +**`intent_classification.py`** — 分析用户意图,识别报销类型: + +```python +INTENT_CLASSIFICATION_PROMPT = """你是一个报销意图识别助手。根据用户的描述,识别报销类型和关键信息。 + +请严格按以下 JSON 格式输出: +{ + "reimbursement_type": "travel_expense" | "office_expense" | "business_meal" | "other", + "travel_info": { + "destination": "城市名", + "start_date": "YYYY-MM-DD" 或 null, + "end_date": "YYYY-MM-DD" 或 null, + "purpose": "出差事由" + }, + "confidence": 0.0-1.0 +} + +用户描述:{user_intent} +""" +``` + +**`risk_explanation.py`** — 将规则命中结果转为自然语言解释: + +```python +RISK_EXPLANATION_PROMPT = """你是一个报销制度解释助手。请根据规则命中结果,用简洁易懂的语言向用户解释问题。 + +规则命中信息: +- 规则名称:{rule_name} +- 问题类型:{issue_type} +- 制度依据:{policy_ref} +- 具体数据:{hit_detail} + +请用 2-3 句话解释: +1. 存在什么问题 +2. 制度标准是什么 +3. 建议如何处理 +""" +``` + +**`expense_type_mapping.py`** — 根据 OCR 结果匹配费用类型: + +```python +EXPENSE_TYPE_MAPPING_PROMPT = """根据票据 OCR 识别结果,判断费用类型。 + +可选费用类型: +- travel_transport: 差旅交通费(火车票、机票、打车) +- travel_hotel: 差旅住宿费(酒店发票) +- travel_meal: 差旅餐补 +- local_transport: 市内交通费 +- business_meal: 业务招待费 +- office_supply: 办公用品费 +- communication: 通讯费 +- other: 其他 + +OCR 识别结果:{ocr_result} + +请输出 JSON:{"expense_type": "类型编码", "confidence": 0.0-1.0} +""" +``` + +- [ ] **Step 3: 编写测试(使用 mock LLM 响应)** + +```python +import pytest +from unittest.mock import AsyncMock, patch + +@pytest.mark.asyncio +async def test_llm_intent_classification(): + with patch("app.services.llm_service.LLMService.chat_json", new_callable=AsyncMock) as mock_chat: + mock_chat.return_value = { + "reimbursement_type": "travel_expense", + "travel_info": {"destination": "北京", "purpose": "商务出差"}, + "confidence": 0.9 + } + llm = LLMService() + result = await llm.chat_json("system prompt", "我要报北京出差费用") + assert result["reimbursement_type"] == "travel_expense" +``` + +- [ ] **Step 4: Commit** + +```bash +git add backend/ +git commit -m "feat: 实现 LLM 集成层(多 Provider + Prompt 模板)" +``` + +--- + +### Task 3.4: 审计日志 + +**负责人:** 后端工程师 B +**预计工时:** 1 天 +**前置依赖:** Phase 2 +**可并行于:** Task 3.1、3.2、3.3 + +**Files:** +- Create: `backend/app/services/audit_service.py` +- Create: `backend/app/api/v1/audit.py` +- Modify: `backend/app/api/v1/router.py` +- Test: `backend/tests/test_audit.py` + +- [ ] **Step 1: 实现 AuditService** + +`backend/app/services/audit_service.py`: + +```python +import json +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from sqlalchemy.orm import Mapped +from app.models.audit import AuditLog + +class AuditService: + def __init__(self, db: AsyncSession): + self.db = db + + async def log(self, action: str, actor: str, target_type: str, target_id: str, detail: dict | None = None): + """记录审计日志""" + log_entry = AuditLog( + action=action, + actor=actor, + target_type=target_type, + target_id=target_id, + detail=json.dumps(detail, ensure_ascii=False) if detail else None, + ) + self.db.add(log_entry) + await self.db.flush() # 不 commit,让调用方统一 commit + + async def query_logs(self, target_type: str | None = None, target_id: str | None = None, + actor: str | None = None, page: int = 1, size: int = 50): + """查询审计日志""" + query = select(AuditLog) + if target_type: + query = query.where(AuditLog.target_type == target_type) + if target_id: + query = query.where(AuditLog.target_id == target_id) + if actor: + query = query.where(AuditLog.actor == actor) + query = query.order_by(AuditLog.created_at.desc()).offset((page - 1) * size).limit(size) + result = await self.db.execute(query) + return result.scalars().all() +``` + +- [ ] **Step 2: 定义审计动作枚举** + +```python +class AuditAction: + FILE_UPLOAD = "file_upload" + OCR_RECOGNIZE = "ocr_recognize" + AGENT_CALL = "agent_call" + RULE_HIT = "rule_hit" + SUPPLEMENT_REQUEST = "supplement_request" + SUPPLEMENT_RESPOND = "supplement_respond" + USER_CONFIRM = "user_confirm" + BACKEND_SYNC = "backend_sync" +``` + +- [ ] **Step 3: 在关键路径埋点** + +在以下位置调用 `audit_service.log()`: +- `document_service.upload_document()` → `FILE_UPLOAD` +- `ocr_service.recognize()` → `OCR_RECOGNIZE` +- `orchestrator._run_agent()` → `AGENT_CALL` +- `rule_engine.run_precheck()` → `RULE_HIT`(每条命中记录一条) +- `supplement_service.create_supplement_request()` → `SUPPLEMENT_REQUEST` +- `supplement_service.respond_supplement()` → `SUPPLEMENT_RESPOND` +- `sync_service.mock_sync_to_backend()` → `BACKEND_SYNC` + +- [ ] **Step 4: 实现审计日志查询 API** + +`GET /api/v1/audit/logs` — 支持按 target_type、target_id、actor、date_range 过滤 + +- [ ] **Step 5: 编写测试** + +- [ ] **Step 6: Commit** + +```bash +git add backend/ +git commit -m "feat: 实现审计日志服务(记录 + 查询 API)" +``` + +--- + +## 本阶段完成检查 + +- [ ] Orchestrator 状态机所有转换路径测试通过 +- [ ] 5 个 Agent 能独立执行并返回正确结果 +- [ ] LLM Service 能调用大模型并解析 JSON 响应 +- [ ] 审计日志在所有关键路径都有记录 +- [ ] `POST /api/v1/reimbursement/tasks/{id}/agent/run` 能启动完整 Agent 流程 +- [ ] 所有测试 `pytest tests/ -v` 全部通过 diff --git a/docs/plans/phase-4-frontend-pages/README.md b/docs/plans/phase-4-frontend-pages/README.md new file mode 100644 index 0000000..5ff0431 --- /dev/null +++ b/docs/plans/phase-4-frontend-pages/README.md @@ -0,0 +1,500 @@ +# Phase 4: 前端核心页面(W4-W5) + +> **目标:** 实现所有核心前端页面和组件,完成用户交互界面。 +> **周期:** 第 4 ~ 5 周 +> **任务数:** 4 个 +> **可并行:** 4 个任务可由 1-2 名前端工程师并行开发 +> **前置依赖:** Phase 1(前端骨架) +> **备注:** 可与 Phase 3 后半段并行开始 + +--- + +## 本阶段交付物 + +| 交付物 | 说明 | +|---|---| +| 报销入口页 | 对话式报销入口 + 快捷操作 | +| 票据上传页 | 文件上传组件 + 票据类型选择 | +| 报销草稿页 | 费用明细表格 + 可编辑字段 | +| 预审结果页 | 风险展示 + 规则命中详情 | +| 补件交互页 | 补件清单 + 上传/回复 | +| 提交确认页 | 最终确认 + 同步状态 | +| 审计日志页 | 操作时间线 | + +--- + +## 任务清单 + +### Task 4.1: 报销入口页 + 上传组件 + +**负责人:** 前端工程师 +**预计工时:** 2 天 +**前置依赖:** Phase 1(前端骨架) + +**Files:** +- Create: `frontend/src/views/HomeView.vue` +- Create: `frontend/src/views/UploadView.vue` +- Create: `frontend/src/components/FileUpload.vue` +- Create: `frontend/src/stores/task.ts` +- Create: `frontend/src/api/task.ts` +- Create: `frontend/src/api/document.ts` + +- [ ] **Step 1: 实现 API 调用层** + +`frontend/src/api/task.ts`: + +```typescript +import api from './index' + +export const createTask = (data: { + userId: string + companyId: string + userIntent: string + entryChannel?: string +}) => api.post('/reimbursement/tasks', data) + +export const getTask = (taskId: string) => + api.get(`/reimbursement/tasks/${taskId}`) + +export const listTasks = (params?: { userId?: string; status?: string; page?: number; size?: number }) => + api.get('/reimbursement/tasks', { params }) + +export const runAgent = (taskId: string, startFrom = 'intake', mode = 'precheck') => + api.post(`/reimbursement/tasks/${taskId}/agent/run`, { start_from: startFrom, mode }) +``` + +`frontend/src/api/document.ts`: + +```typescript +import api from './index' + +export const uploadDocument = (taskId: string, file: File, documentType: string) => { + const formData = new FormData() + formData.append('file', file) + formData.append('document_type', documentType) + return api.post(`/reimbursement/tasks/${taskId}/documents`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) +} + +export const listDocuments = (taskId: string) => + api.get(`/reimbursement/tasks/${taskId}/documents`) +``` + +- [ ] **Step 2: 实现 Pinia Store** + +`frontend/src/stores/task.ts`: + +```typescript +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { createTask, getTask, listTasks, runAgent } from '@/api/task' + +export const useTaskStore = defineStore('task', () => { + const currentTask = ref(null) + const taskList = ref([]) + const loading = ref(false) + + async function create(userIntent: string) { + loading.value = true + try { + const { data } = await createTask({ + userId: 'U001', // TODO: 从登录态获取 + companyId: 'C001', + userIntent, + }) + currentTask.value = data + return data + } finally { + loading.value = false + } + } + + async function fetchTask(taskId: string) { + const { data } = await getTask(taskId) + currentTask.value = data + return data + } + + async function startAgent(taskId: string, startFrom = 'intake') { + loading.value = true + try { + const { data } = await runAgent(taskId, startFrom) + return data + } finally { + loading.value = false + } + } + + return { currentTask, taskList, loading, create, fetchTask, startAgent } +}) +``` + +- [ ] **Step 3: 实现报销入口页 HomeView** + +按开发文档 9.2 节: + +- **对话输入框**:用户输入报销意图(如"我要报这次北京出差的费用") +- **上传按钮**:直接跳转到上传页 +- **最近任务列表**:显示用户最近的报销任务和状态 +- **常用报销类型快捷按钮**: + - "报差旅" + - "看发票能不能报" + - "帮我生成报销单" + - "这张发票为什么不合规?" +- **智能引导提示**:根据用户输入实时提示 + +交互流程:用户输入意图 → 调用 `taskStore.create()` → 跳转到 `/task/{taskId}/upload` + +- [ ] **Step 4: 实现文件上传组件 FileUpload** + +`frontend/src/components/FileUpload.vue`: + +Ant Design Vue 的 `a-upload-dragger` 封装: +- 支持拖拽上传 +- 支持多文件选择 +- 文件类型校验(PDF、JPG、PNG、OFD) +- 单文件大小限制(默认 10MB) +- 上传进度条 +- 预览缩略图 +- 已上传文件列表 +- 删除已上传文件 + +Props: +```typescript +interface Props { + taskId: string + accept?: string // 默认 '.pdf,.jpg,.jpeg,.png,.ofd' + maxFileSize?: number // MB,默认 10 + maxCount?: number // 默认 10 +} +``` + +- [ ] **Step 5: 实现票据上传页 UploadView** + +- 引用 FileUpload 组件 +- 票据类型下拉选择(Ant Design Select): + - 增值税发票 (vat_invoice) + - 火车票 (train_ticket) + - 机票行程单 (flight_itinerary) + - 打车票据 (taxi_receipt) + - 酒店流水 (hotel_bill) + - 支付截图 (payment_screenshot) + - 其他附件 (other_attachment) +- 已上传文件列表展示 +- "开始识别" 按钮 → 调用 `taskStore.startAgent()` → 跳转到草稿页 +- 返回按钮 → 回到首页 + +- [ ] **Step 6: Commit** + +```bash +git add frontend/ +git commit -m "feat: 实现报销入口页和票据上传页" +``` + +--- + +### Task 4.2: 报销草稿页 + +**负责人:** 前端工程师 +**预计工时:** 2 天 +**前置依赖:** Task 4.1 +**可并行于:** Task 4.3(如果两人并行) + +**Files:** +- Create: `frontend/src/views/DraftView.vue` +- Create: `frontend/src/components/ExpenseTable.vue` +- Create: `frontend/src/api/precheck.ts` + +- [ ] **Step 1: 添加预审 API** + +`frontend/src/api/precheck.ts`: + +```typescript +import api from './index' + +export const getDraft = (taskId: string) => + api.get(`/reimbursement/tasks/${taskId}/draft`) + +export const getPrecheckResult = (taskId: string) => + api.get(`/reimbursement/tasks/${taskId}/precheck-result`) +``` + +- [ ] **Step 2: 实现 ExpenseTable 组件** + +`frontend/src/components/ExpenseTable.vue`: + +Ant Design Table 展示费用明细: + +| 列名 | 字段 | 可编辑 | 说明 | +|---|---|---|---| +| 费用类型 | expense_type | ✅ | 下拉选择 | +| 金额 | amount | ✅ | 数字输入 | +| 税额 | tax_amount | ✅ | 数字输入 | +| 发生日期 | occurred_at | ✅ | 日期选择 | +| 城市 | city | ✅ | 文本输入 | +| 商户 | vendor_name | ✅ | 文本输入 | +| 风险等级 | risk_level | ❌ | 彩色标签 | + +风险等级标签颜色映射: +- `low` → 绿色 `green` +- `medium` → 橙色 `orange` +- `high` → 红色 `red` +- `blocked` → 深红 `#cf1322` + +支持行内编辑(点击单元格进入编辑模式)。 + +- [ ] **Step 3: 实现报销草稿页 DraftView** + +按开发文档 9.3 节布局: + +``` +┌─────────────────────────────────────────────┐ +│ 报销草稿 [预审按钮] │ +├─────────────────────────────────────────────┤ +│ 基本信息 │ +│ ┌──────────┬──────────┬──────────┐ │ +│ │ 报销人 │ 部门 │ 成本中心 │ │ +│ └──────────┴──────────┴──────────┘ │ +│ ┌──────────┬──────────┐ │ +│ │ 项目 │ 报销事由 │ │ +│ └──────────┴──────────┘ │ +├─────────────────────────────────────────────┤ +│ 费用明细 │ +│ ┌─────────────────────────────────────────┐ │ +│ │ ExpenseTable 组件 │ │ +│ └─────────────────────────────────────────┘ │ +│ 总金额:¥ 2,380.00 │ +├─────────────────────────────────────────────┤ +│ 票据附件 │ +│ ┌────┐ ┌────┐ ┌────┐ │ +│ │ 📄 │ │ 📄 │ │ 📄 │ (缩略图 + 文件名) │ +│ └────┘ └────┘ └────┘ │ +├─────────────────────────────────────────────┤ +│ 预审状态:⏳ 待预审 │ +│ [执行预审] │ +└─────────────────────────────────────────────┘ +``` + +交互: +- 页面加载时调用 `getDraft(taskId)` 获取草稿数据 +- 编辑字段后暂存到本地 state +- 点击"执行预审" → 调用 `taskStore.startAgent(taskId, 'precheck')` → 跳转到预审结果页 + +- [ ] **Step 4: Commit** + +```bash +git add frontend/ +git commit -m "feat: 实现报销草稿页和费用明细表格组件" +``` + +--- + +### Task 4.3: 预审结果页 + 补件交互页 + +**负责人:** 前端工程师 +**预计工时:** 2.5 天 +**前置依赖:** Task 4.1 +**可并行于:** Task 4.2(如果两人并行) + +**Files:** +- Create: `frontend/src/views/PrecheckView.vue` +- Create: `frontend/src/views/SupplementView.vue` +- Create: `frontend/src/components/RuleHitCard.vue` +- Create: `frontend/src/api/supplement.ts` + +- [ ] **Step 1: 添加补件 API** + +`frontend/src/api/supplement.ts`: + +```typescript +import api from './index' + +export const respondSupplement = (taskId: string, supplementRequestId: string, data: { + responseText: string + documentIds?: string[] +}) => api.post(`/reimbursement/tasks/${taskId}/supplements`, { + supplement_request_id: supplementRequestId, + ...data, +}) + +export const submitTask = (taskId: string, submitTo: string = 'expense_system') => + api.post(`/reimbursement/tasks/${taskId}/submit`, { confirmed: true, submit_to: submitTo }) + +export const getSyncStatus = (taskId: string) => + api.get(`/reimbursement/tasks/${taskId}/sync-status`) +``` + +- [ ] **Step 2: 实现 RuleHitCard 组件** + +`frontend/src/components/RuleHitCard.vue`: + +Ant Design Card 展示单条规则命中: + +``` +┌─────────────────────────────────────────┐ +│ 🔴 住宿费超标 TRAVEL_HOTEL_LIMIT │ +├─────────────────────────────────────────┤ +│ 问题:住宿费超出当前城市和职级标准 │ +│ 制度依据:差旅报销制度-住宿标准 │ +│ 建议:请补充超标说明或发起特殊审批 │ +│ [展开详情] │ +└─────────────────────────────────────────┘ +``` + +Props: +```typescript +interface Props { + ruleCode: string + ruleName: string + severity: string // low / medium / high / blocked + action: string + message: string + suggestion: string + policyRef: string + hitDetail?: object +} +``` + +- [ ] **Step 3: 实现预审结果页 PrecheckView** + +按开发文档 9.4 节: + +- **总体结论卡片**: + - ✅ 通过 → 绿色 + - ⚠️ 需补件 → 橙色 + - 🚫 有阻断 → 红色 +- **风险等级指示**:彩色 Badge +- **通过项列表**:绿色勾选图标 + 规则名称 +- **风险项列表**:使用 RuleHitCard 组件 +- **缺件项列表**:橙色提示 + 补件按钮 +- **操作按钮**: + - "一键补件" → 跳转到补件页(仅在有缺件时显示) + - "确认提交" → 跳转到确认页(仅预审通过时可用) + +交互: +- 页面加载时调用 `getPrecheckResult(taskId)` 获取预审结果 +- 根据结果渲染不同状态 + +- [ ] **Step 4: 实现补件交互页 SupplementView** + +- **待补件清单**:从预审结果的 rule_hits 中过滤出 `require_attachment` / `require_explanation` 类型 +- **每个补件项**: + - 类型标签(补充附件 / 补充说明 / 修改字段) + - 提示文案 + - 操作区域: + - 补充附件:调用 FileUpload 组件 + - 补充说明:文本输入框 +- **提交补件按钮** → 调用 `respondSupplement()` → 跳转回预审页重新预审 + +- [ ] **Step 5: Commit** + +```bash +git add frontend/ +git commit -m "feat: 实现预审结果页和补件交互页" +``` + +--- + +### Task 4.4: 提交确认页 + 审计日志页 + +**负责人:** 前端工程师 +**预计工时:** 1.5 天 +**前置依赖:** Task 4.3 + +**Files:** +- Create: `frontend/src/views/ConfirmView.vue` +- Create: `frontend/src/views/AuditView.vue` +- Create: `frontend/src/api/audit.ts` + +- [ ] **Step 1: 添加审计 API** + +`frontend/src/api/audit.ts`: + +```typescript +import api from './index' + +export const getAuditLogs = (params?: { + target_type?: string + target_id?: string + actor?: string + page?: number + size?: number +}) => api.get('/audit/logs', { params }) +``` + +- [ ] **Step 2: 实现提交确认页 ConfirmView** + +``` +┌─────────────────────────────────────────────┐ +│ 提交确认 │ +├─────────────────────────────────────────────┤ +│ 报销单摘要(不可编辑) │ +│ ┌───────────────────────────────────────┐ │ +│ │ 报销人:张三 部门:技术部 │ │ +│ │ 事由:北京出差 总金额:¥2,380.00 │ │ +│ └───────────────────────────────────────┘ │ +│ │ +│ 费用明细汇总: │ +│ ┌───────────────────────────────────────┐ │ +│ │ 差旅住宿费 ¥1,200.00 │ │ +│ │ 差旅交通费 ¥ 553.00 │ │ +│ │ 差旅餐补 ¥ 627.00 │ │ +│ └───────────────────────────────────────┘ │ +│ │ +│ 附件清单:3 个文件 │ +│ 同步目标:费控系统 │ +│ │ +│ [返回修改] [确认提交] │ +├─────────────────────────────────────────────┤ +│ 同步状态:⏳ 提交中... │ +└─────────────────────────────────────────────┘ +``` + +交互: +- "确认提交" → 调用 `submitTask(taskId)` +- 提交后轮询 `getSyncStatus(taskId)`,展示同步进度 +- 同步成功 → 显示后端单据号 +- 同步失败 → 显示错误信息 + 重试按钮 + +- [ ] **Step 3: 实现审计日志页 AuditView** + +按开发文档 9.5 节(简化版): + +- **Ant Design Timeline** 展示操作记录 +- **筛选栏**:按任务ID、操作类型、时间范围筛选 +- **每条日志**: + - 时间戳 + - 操作人 + - 操作类型(彩色标签) + - 详情(可展开) + +操作类型颜色映射: +- `file_upload` → 蓝色 +- `ocr_recognize` → 青色 +- `agent_call` → 紫色 +- `rule_hit` → 橙色 +- `supplement_request` / `supplement_respond` → 绿色 +- `user_confirm` → 金色 +- `backend_sync` → 灰色 + +- [ ] **Step 4: Commit** + +```bash +git add frontend/ +git commit -m "feat: 实现提交确认页和审计日志页" +``` + +--- + +## 本阶段完成检查 + +- [ ] 首页能创建任务并跳转到上传页 +- [ ] 上传页能上传文件并开始识别 +- [ ] 草稿页能展示费用明细并支持编辑 +- [ ] 预审结果页能展示风险项和缺件项 +- [ ] 补件页能上传附件和填写说明 +- [ ] 确认页能展示摘要和同步状态 +- [ ] 审计日志页能展示操作时间线 +- [ ] 所有页面响应式布局正常 +- [ ] 前端 `npm run build` 无报错 diff --git a/docs/plans/phase-5-integration/README.md b/docs/plans/phase-5-integration/README.md new file mode 100644 index 0000000..1b3eaaa --- /dev/null +++ b/docs/plans/phase-5-integration/README.md @@ -0,0 +1,259 @@ +# Phase 5: 联调与集成(W5-W6) + +> **目标:** 前后端联调跑通完整流程,配置规则种子数据,确保全链路畅通。 +> **周期:** 第 5 ~ 6 周 +> **任务数:** 2 个 +> **可并行:** 联调和种子数据可由不同人并行 +> **前置依赖:** Phase 3 + Phase 4 + +--- + +## 本阶段交付物 + +| 交付物 | 说明 | +|---|---| +| 完整流程跑通 | 从创建任务到同步成功的端到端流程 | +| 规则种子数据 | 差旅报销制度 + 6 条核心规则 + 城市等级标准 | + +--- + +## 任务清单 + +### Task 5.1: 前后端联调 + +**负责人:** 全员参与 +**预计工时:** 3-4 天 +**前置依赖:** Phase 3 + Phase 4 + +**Files:** +- Modify: 多个前后端文件(修复联调问题) + +- [ ] **Step 1: 启动前后端全栈环境** + +```bash +# 启动基础设施 +docker-compose up -d + +# 启动后端 +cd backend +alembic upgrade head +uvicorn app.main:app --reload --port 8000 + +# 启动前端 +cd frontend +npm run dev +``` + +确认: +- PostgreSQL 可连接:`psql -h localhost -U postgres -d x_financial` +- Redis 可连接:`redis-cli ping` +- MinIO 可访问:http://localhost:9001 +- 后端健康检查:http://localhost:8000/health +- 前端可访问:http://localhost:5173 + +- [ ] **Step 2: 跑通完整报销流程(10 步)** + +按开发文档 3.1 节的完整流程: + +**步骤 1 - 创建任务** +1. 访问首页 http://localhost:5173 +2. 在输入框输入"我要报这次北京出差的费用" +3. 点击提交 +4. 验证:任务创建成功,跳转到上传页 + +**步骤 2 - 上传票据** +1. 选择票据类型:增值税发票 +2. 上传模拟发票文件(可用任意 PDF/PNG) +3. 选择票据类型:火车票 +4. 上传模拟火车票文件 +5. 验证:文件列表显示 2 个文件 + +**步骤 3 - 启动 Agent 识别** +1. 点击"开始识别" +2. 等待 Agent 处理(观察后端日志) +3. 验证:跳转到草稿页,显示识别结果 + +**步骤 4 - 查看报销草稿** +1. 确认费用明细已自动填充 +2. 检查金额、商户、日期等字段 +3. 验证:可编辑字段能修改 + +**步骤 5 - 执行预审** +1. 点击"执行预审" +2. 等待规则引擎执行 +3. 验证:跳转到预审结果页 + +**步骤 6 - 查看预审结果** +1. 检查总体结论 +2. 查看风险项(如有) +3. 查看缺件项(如有) +4. 验证:规则命中详情展示正确 + +**步骤 7 - 补件(如需要)** +1. 点击"一键补件" +2. 在补件页上传缺失附件 +3. 提交补件 +4. 验证:跳转回预审页,重新预审 + +**步骤 8 - 确认提交** +1. 确认报销单摘要 +2. 点击"确认提交" +3. 等待同步状态更新 +4. 验证:同步状态变为 success + +**步骤 9 - 查看审计日志** +1. 访问审计日志页 +2. 按任务ID筛选 +3. 验证:所有操作步骤都有记录 + +**步骤 10 - 查看后端 Swagger** +1. 访问 http://localhost:8000/docs +2. 验证所有 API 文档正确 + +- [ ] **Step 3: 修复联调过程中发现的问题** + +常见问题检查清单: +- [ ] API 响应格式前后端一致(字段名、嵌套结构) +- [ ] 日期格式统一(ISO 8601) +- [ ] 金额精度(Decimal vs Number) +- [ ] 错误处理(前端能正确显示后端错误信息) +- [ ] 文件上传大小限制(前端 + 后端 + MinIO) +- [ ] 跨域配置正确 +- [ ] 路由跳转正常 +- [ ] Loading 状态显示 +- [ ] 空状态展示 + +- [ ] **Step 4: 压力测试关键接口** + +- `POST /tasks` 创建 100 个任务 +- `GET /tasks` 列表查询响应时间 < 500ms +- `POST /tasks/{id}/documents` 上传 10 个文件 +- `POST /tasks/{id}/agent/run` Agent 执行时间 < 30s + +- [ ] **Step 5: Commit** + +```bash +git add . +git commit -m "fix: 前后端联调修复(完整流程跑通)" +``` + +--- + +### Task 5.2: 规则配置与种子数据 + +**负责人:** 全栈/Agent 工程师 +**预计工时:** 2 天 +**前置依赖:** Phase 2(规则引擎) +**可并行于:** Task 5.1 + +**Files:** +- Create: `backend/alembic/seed/expense_policies.sql` +- Create: `backend/alembic/seed/expense_rules.sql` +- Create: `backend/alembic/seed/city_levels.sql` +- Create: `backend/alembic/seed/hotel_limits.sql` +- Create: `backend/scripts/seed_data.py` + +- [ ] **Step 1: 编写城市等级数据** + +`backend/alembic/seed/city_levels.sql`: + +按典型企业标准配置: +- **一线城市**:北京、上海、广州、深圳 +- **二线城市**:杭州、南京、成都、武汉、重庆、天津、苏州、西安 +- **三线城市**:其他城市 + +```sql +CREATE TABLE IF NOT EXISTS city_level ( + city_name VARCHAR(50) PRIMARY KEY, + level VARCHAR(10) NOT NULL -- tier1 / tier2 / tier3 +); + +INSERT INTO city_level (city_name, level) VALUES +('北京', 'tier1'), ('上海', 'tier1'), ('广州', 'tier1'), ('深圳', 'tier1'), +('杭州', 'tier2'), ('南京', 'tier2'), ('成都', 'tier2'), ('武汉', 'tier2'), +('重庆', 'tier2'), ('天津', 'tier2'), ('苏州', 'tier2'), ('西安', 'tier2'); +``` + +- [ ] **Step 2: 编写住宿标准数据** + +`backend/alembic/seed/hotel_limits.sql`: + +```sql +CREATE TABLE IF NOT EXISTS hotel_limit ( + city_level VARCHAR(10) NOT NULL, + job_level VARCHAR(20) NOT NULL, -- manager / senior / staff + limit_per_night DECIMAL(10,2) NOT NULL, + PRIMARY KEY (city_level, job_level) +); + +INSERT INTO hotel_limit (city_level, job_level, limit_per_night) VALUES +('tier1', 'manager', 800.00), ('tier1', 'senior', 600.00), ('tier1', 'staff', 500.00), +('tier2', 'manager', 600.00), ('tier2', 'senior', 450.00), ('tier2', 'staff', 350.00), +('tier3', 'manager', 500.00), ('tier3', 'senior', 350.00), ('tier3', 'staff', 300.00); +``` + +- [ ] **Step 3: 编写规则种子数据** + +`backend/alembic/seed/expense_rules.sql` — 预置开发文档 7.2 节的 3 条示例规则 + MVP 需要的 6 条核心规则: + +1. `TRAVEL_HOTEL_LIMIT` — 住宿费标准校验(severity: medium, action: require_explanation) +2. `HOTEL_BILL_REQUIRED` — 住宿费必须上传酒店流水(severity: medium, action: require_attachment) +3. `DUPLICATE_INVOICE_CHECK` — 重复发票检查(severity: blocked, action: block) +4. `REQUIRED_FIELDS_CHECK` — 必填字段校验(severity: medium, action: warn) +5. `AMOUNT_ABNORMAL_CHECK` — 金额异常检查(severity: high, action: require_explanation) +6. `DATE_VALIDITY_CHECK` — 日期合理性校验(severity: low, action: warn) +7. `EXPENSE_TYPE_MATCH_CHECK` — 费用类型匹配校验(severity: low, action: warn) +8. `INVOICE_TITLE_CHECK` — 发票抬头校验(severity: high, action: require_explanation) +9. `TRIP_PERIOD_MATCH_CHECK` — 出差期间匹配校验(severity: medium, action: warn) + +每条规则包含完整的 condition_json、action、severity、message_template、policy_ref。 + +- [ ] **Step 4: 编写数据初始化脚本** + +`backend/scripts/seed_data.py` — 一键初始化所有种子数据: + +```python +"""初始化种子数据""" +import asyncio +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from app.core.config import settings +# ... 读取 SQL 文件并执行 + +async def main(): + engine = create_async_engine(settings.DATABASE_URL) + async with engine.begin() as conn: + # 按顺序执行 seed SQL + for sql_file in ['city_levels.sql', 'hotel_limits.sql', 'expense_rules.sql']: + with open(f'alembic/seed/{sql_file}') as f: + await conn.execute(f.read()) + print("Seed data loaded successfully!") + +if __name__ == "__main__": + asyncio.run(main()) +``` + +- [ ] **Step 5: 验证种子数据** + +Run: `cd backend && python scripts/seed_data.py` +验证: +- 城市等级表有数据 +- 住宿标准表有数据 +- 规则表有 9 条规则且全部 enabled + +- [ ] **Step 6: Commit** + +```bash +git add backend/ +git commit -m "feat: 添加差旅报销制度和规则种子数据" +``` + +--- + +## 本阶段完成检查 + +- [ ] 完整报销流程(创建→上传→识别→草稿→预审→补件→提交→同步)端到端跑通 +- [ ] 前后端 API 格式一致,无字段不匹配 +- [ ] 错误场景有正确提示(上传失败、OCR 失败、同步失败) +- [ ] 种子数据加载成功,规则引擎使用种子数据执行预审 +- [ ] Swagger 文档 http://localhost:8000/docs 可访问 +- [ ] 审计日志记录了完整操作链路 diff --git a/docs/plans/phase-6-testing-polish/README.md b/docs/plans/phase-6-testing-polish/README.md new file mode 100644 index 0000000..78b1a49 --- /dev/null +++ b/docs/plans/phase-6-testing-polish/README.md @@ -0,0 +1,553 @@ +# Phase 6: 测试与打磨(W7-W8) + +> **目标:** 完善集成测试、E2E 测试、修复 Bug、UI 打磨、编写部署文档,准备 Demo 演示。 +> **周期:** 第 7 ~ 8 周 +> **任务数:** 4 个 +> **可并行:** Task 6.1 / 6.2 / 6.3 可并行 +> **前置依赖:** Phase 5 + +--- + +## 本阶段交付物 + +| 交付物 | 说明 | +|---|---| +| 后端集成测试 | 完整报销流程的自动化测试 | +| 前端 E2E 测试 | Playwright 自动化测试(可选) | +| Bug 修复 + UI 打磨 | 视觉和交互优化 | +| 部署文档 | README + 部署指南 + API 文档 | + +--- + +## 任务清单 + +### Task 6.1: 后端集成测试 + +**负责人:** 后端工程师 A +**预计工时:** 3 天 +**前置依赖:** Phase 5 + +**Files:** +- Create: `backend/tests/test_integration_flow.py` +- Create: `backend/tests/helpers.py`(测试辅助函数) +- Modify: `backend/tests/conftest.py`(添加测试数据库 fixture) + +- [ ] **Step 1: 更新 conftest.py 添加测试数据库 fixture** + +`backend/tests/conftest.py`: + +```python +import pytest +import asyncio +from httpx import AsyncClient, ASGITransport +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from app.models.base import Base +from app.main import app +from app.core.database import get_db + +# 测试数据库 URL +TEST_DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/x_financial_test" + +test_engine = create_async_engine(TEST_DATABASE_URL, echo=False) +test_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + +@pytest.fixture(scope="session") +def event_loop(): + loop = asyncio.new_event_loop() + yield loop + loop.close() + +@pytest.fixture(autouse=True) +async def setup_database(): + """每个测试前创建表,测试后清理""" + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + +@pytest.fixture +async def db(): + async with test_session() as session: + yield session + +@pytest.fixture +async def client(db): + async def override_get_db(): + yield db + + app.dependency_overrides[get_db] = override_get_db + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + app.dependency_overrides.clear() +``` + +- [ ] **Step 2: 编写测试辅助函数** + +`backend/tests/helpers.py`: + +```python +from httpx import AsyncClient + +async def create_task(client: AsyncClient, user_id="U001", company_id="C001", intent="报北京出差费用") -> dict: + resp = await client.post("/api/v1/reimbursement/tasks", json={ + "user_id": user_id, "company_id": company_id, "user_intent": intent + }) + assert resp.status_code == 201 + return resp.json() + +async def upload_document(client: AsyncClient, task_id: str, document_type: str, filename: str = "test.pdf") -> dict: + files = {"file": (filename, b"fake file content", "application/pdf")} + resp = await client.post( + f"/api/v1/reimbursement/tasks/{task_id}/documents", + files=files, + data={"document_type": document_type} + ) + assert resp.status_code == 201 + return resp.json() + +async def run_agent(client: AsyncClient, task_id: str, start_from="intake") -> dict: + resp = await client.post( + f"/api/v1/reimbursement/tasks/{task_id}/agent/run", + json={"start_from": start_from, "mode": "precheck"} + ) + assert resp.status_code == 200 + return resp.json() + +async def get_draft(client: AsyncClient, task_id: str) -> dict: + resp = await client.get(f"/api/v1/reimbursement/tasks/{task_id}/draft") + assert resp.status_code == 200 + return resp.json() + +async def get_precheck_result(client: AsyncClient, task_id: str) -> dict: + resp = await client.get(f"/api/v1/reimbursement/tasks/{task_id}/precheck-result") + assert resp.status_code == 200 + return resp.json() + +async def respond_supplement(client: AsyncClient, task_id: str, supplement_id: str, text: str) -> dict: + resp = await client.post( + f"/api/v1/reimbursement/tasks/{task_id}/supplements", + json={"supplement_request_id": supplement_id, "response_text": text} + ) + assert resp.status_code == 200 + return resp.json() + +async def submit_task(client: AsyncClient, task_id: str) -> dict: + resp = await client.post( + f"/api/v1/reimbursement/tasks/{task_id}/submit", + json={"confirmed": True, "submit_to": "expense_system"} + ) + assert resp.status_code == 200 + return resp.json() + +async def get_sync_status(client: AsyncClient, task_id: str) -> dict: + resp = await client.get(f"/api/v1/reimbursement/tasks/{task_id}/sync-status") + assert resp.status_code == 200 + return resp.json() +``` + +- [ ] **Step 3: 编写完整流程集成测试** + +`backend/tests/test_integration_flow.py`: + +```python +import pytest +from tests.helpers import * + +@pytest.mark.asyncio +async def test_full_reimbursement_flow(client): + """完整报销流程:创建→上传→识别→草稿→预审→补件→提交→同步""" + + # 1. 创建任务 + task = await create_task(client, intent="我要报这次北京出差的费用") + task_id = task["task_id"] + assert task["status"] == "material_collecting" + + # 2. 上传票据 + doc1 = await upload_document(client, task_id, "vat_invoice", "invoice.pdf") + assert doc1["ocr_status"] == "pending" + + doc2 = await upload_document(client, task_id, "train_ticket", "train.pdf") + assert doc2["ocr_status"] == "pending" + + doc3 = await upload_document(client, task_id, "hotel_bill", "hotel.pdf") + + # 3. 启动 Agent(使用 mock OCR) + result = await run_agent(client, task_id, start_from="intake") + assert result["status"] in ["draft_generated", "prechecking", "need_supplement", "pending_user_confirm"] + + # 4. 获取草稿 + draft = await get_draft(client, task_id) + assert draft["reimbursement_id"] is not None + assert len(draft["items"]) > 0 + assert draft["total_amount"] > 0 + + # 5. 获取预审结果 + precheck = await get_precheck_result(client, task_id) + assert "risk_level" in precheck + assert "precheck_status" in precheck + assert "rule_hits" in precheck + + # 6. 如果需要补件 + if precheck["precheck_status"] == "need_supplement": + # 找到需要补件的规则 + for hit in precheck["rule_hits"]: + if hit["action"] == "require_attachment": + # 补充附件 + await upload_document(client, task_id, "hotel_bill", "hotel_supplement.pdf") + await respond_supplement(client, task_id, hit.get("id", "S001"), "已补充酒店流水") + + # 重新预审 + await run_agent(client, task_id, start_from="precheck") + precheck2 = await get_precheck_result(client, task_id) + + # 7. 确认提交 + submit = await submit_task(client, task_id) + assert submit["status"] == "submitting" + + # 8. 检查同步状态 + sync = await get_sync_status(client, task_id) + assert sync["sync_status"] in ["success", "pending"] + +@pytest.mark.asyncio +async def test_create_task_without_intent(client): + """测试不提供意图时创建任务""" + resp = await client.post("/api/v1/reimbursement/tasks", json={ + "user_id": "U001", "company_id": "C001" + }) + assert resp.status_code == 422 # Validation error + +@pytest.mark.asyncio +async def test_get_nonexistent_task(client): + """测试查询不存在的任务""" + resp = await client.get("/api/v1/reimbursement/tasks/nonexistent-id") + assert resp.status_code == 404 + +@pytest.mark.asyncio +async def test_list_tasks_pagination(client): + """测试任务列表分页""" + # 创建多个任务 + for i in range(5): + await create_task(client, intent=f"test task {i}") + + # 测试分页 + resp = await client.get("/api/v1/reimbursement/tasks?page=1&size=3") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] >= 5 + assert len(data["items"]) <= 3 +``` + +- [ ] **Step 4: 编写规则引擎集成测试** + +测试每条规则对真实报销数据的命中情况: +- 住宿费超标 → 命中 `TRAVEL_HOTEL_LIMIT` +- 缺少酒店流水 → 命中 `HOTEL_BILL_REQUIRED` +- 重复发票 → 命中 `DUPLICATE_INVOICE_CHECK` +- 合规报销 → 无命中 + +- [ ] **Step 5: 确保所有测试通过** + +Run: `cd backend && pytest tests/ -v --tb=short` +Expected: All PASS + +- [ ] **Step 6: Commit** + +```bash +git add backend/ +git commit -m "test: 添加完整报销流程集成测试" +``` + +--- + +### Task 6.2: 前端 E2E 测试(可选) + +**负责人:** 前端工程师 +**预计工时:** 2 天 +**前置依赖:** Phase 5 +**可并行于:** Task 6.1、6.3 + +**Files:** +- Create: `frontend/e2e/reimbursement.spec.ts` +- Create: `frontend/playwright.config.ts` + +- [ ] **Step 1: 安装 Playwright** + +```bash +cd frontend +npm install -D @playwright/test +npx playwright install chromium +``` + +- [ ] **Step 2: 配置 Playwright** + +`frontend/playwright.config.ts`: + +```typescript +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + baseURL: 'http://localhost:5173', + use: { + headless: true, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, + webServer: { + command: 'npm run dev', + port: 5173, + reuseExistingServer: true, + }, +}) +``` + +- [ ] **Step 3: 编写核心流程 E2E 测试** + +`frontend/e2e/reimbursement.spec.ts`: + +```typescript +import { test, expect } from '@playwright/test' + +test('完整报销流程', async ({ page }) => { + // 1. 访问首页 + await page.goto('/') + await expect(page.locator('h1')).toContainText('报销') + + // 2. 输入报销意图 + await page.fill('input[placeholder*="报销"]', '我要报这次北京出差的费用') + await page.click('button:has-text("提交")') + + // 3. 跳转到上传页 + await expect(page).toHaveURL(/\/task\/.*\/upload/) + + // 4. 上传文件 + await page.setInputFiles('input[type="file"]', 'tests/fixtures/invoice.pdf') + + // 5. 选择票据类型 + await page.selectOption('select', 'vat_invoice') + + // 6. 开始识别 + await page.click('button:has-text("开始识别")') + + // 7. 跳转到草稿页 + await expect(page).toHaveURL(/\/draft/, { timeout: 30000 }) + + // 8. 执行预审 + await page.click('button:has-text("执行预审")') + + // 9. 跳转到预审结果页 + await expect(page).toHaveURL(/\/precheck/, { timeout: 30000 }) +}) +``` + +- [ ] **Step 4: 运行 E2E 测试** + +Run: `cd frontend && npx playwright test` +Expected: All PASS + +- [ ] **Step 5: Commit** + +```bash +git add frontend/ +git commit -m "test: 添加前端 E2E 测试" +``` + +--- + +### Task 6.3: Bug 修复与 UI 打磨 + +**负责人:** 全员参与 +**预计工时:** 3 天 +**前置依赖:** Phase 5 +**可并行于:** Task 6.1、6.2 + +- [ ] **Step 1: UI 走查清单** + +逐页面检查: + +| 页面 | 检查项 | +|---|---| +| 首页 | 布局、输入框交互、快捷按钮、最近任务列表 | +| 上传页 | 拖拽上传、文件预览、票据类型选择、进度条 | +| 草稿页 | 表格编辑、金额汇总、附件预览、预审按钮 | +| 预审结果页 | 结论卡片、风险项展示、规则命中详情 | +| 补件页 | 补件清单、上传/回复交互、提交反馈 | +| 确认页 | 摘要展示、同步状态轮询、成功/失败状态 | +| 审计日志页 | 时间线展示、筛选功能 | + +- [ ] **Step 2: 修复共性问题** + +- [ ] 响应式布局适配(1280px / 1024px / 768px 断点) +- [ ] Loading 状态:所有异步操作加 loading 指示器 +- [ ] 错误提示:API 错误统一使用 Ant Design Message 提示 +- [ ] 空状态:无数据时展示空状态插画和文案 +- [ ] 表单校验:必填项红框提示 + 校验文案 +- [ ] 金额格式化:千分位 + 两位小数 + ¥ 前缀 +- [ ] 日期格式化:YYYY-MM-DD +- [ ] 确认弹窗:删除、提交等危险操作二次确认 + +- [ ] **Step 3: 添加 Demo 展示数据** + +在首页添加"体验 Demo"按钮,一键生成演示数据: +- 创建一个已完成全流程的报销任务 +- 包含 3 条费用明细 +- 有规则命中记录 +- 有审计日志 + +- [ ] **Step 4: 性能优化** + +- [ ] 路由懒加载(已配置) +- [ ] 表格虚拟滚动(如果明细很多) +- [ ] 图片懒加载 +- [ ] API 请求去重/缓存 + +- [ ] **Step 5: Commit** + +```bash +git add . +git commit -m "fix: UI 打磨和 Bug 修复" +``` + +--- + +### Task 6.4: 部署与文档 + +**负责人:** 后端工程师 B + 前端工程师 +**预计工时:** 2 天 +**前置依赖:** Task 6.3 + +**Files:** +- Create: `docker-compose.prod.yml` +- Create: `nginx.conf` +- Modify: `README.md` +- Create: `docs/deployment.md` + +- [ ] **Step 1: 编写生产 Docker Compose** + +`docker-compose.prod.yml`: + +```yaml +version: "3.8" +services: + nginx: + image: nginx:alpine + ports: + - "80:80" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf + - ./frontend/dist:/usr/share/nginx/html + depends_on: + - backend + + backend: + build: ./backend + environment: + - DATABASE_URL=postgresql+asyncpg://postgres:${DB_PASSWORD}@postgres:5432/x_financial + - REDIS_URL=redis://redis:6379/0 + - MINIO_ENDPOINT=minio:9000 + depends_on: + - postgres + - redis + - minio + + postgres: + image: postgres:15 + environment: + POSTGRES_DB: x_financial + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + + minio: + image: minio/minio + command: server /data + environment: + MINIO_ROOT_USER: ${MINIO_USER} + MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD} + volumes: + - minio_data:/data + +volumes: + pgdata: + minio_data: +``` + +- [ ] **Step 2: 编写 Nginx 配置** + +`nginx.conf`: + +```nginx +server { + listen 80; + server_name localhost; + + # 前端静态文件 + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html; + } + + # 后端 API 代理 + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 120s; + } +} +``` + +- [ ] **Step 3: 编写部署文档** + +`docs/deployment.md` — 包含: +- 环境要求(Docker、Docker Compose) +- 配置说明(.env 文件) +- 启动步骤 +- 停止和重启 +- 数据库迁移 +- 种子数据初始化 +- 日志查看 +- 常见问题排查 + +- [ ] **Step 4: 更新 README** + +项目 README 包含: +- 项目简介和架构图 +- 快速启动(开发环境) +- 技术栈说明 +- 目录结构 +- 开发指南 +- API 文档链接 + +- [ ] **Step 5: 确认 Swagger 文档完整** + +访问 http://localhost:8000/docs,确认: +- 所有 API 端点都有描述 +- 请求/响应示例完整 +- 错误码说明完整 + +- [ ] **Step 6: Commit** + +```bash +git add . +git commit -m "docs: 添加部署文档、Nginx 配置、生产 Docker Compose" +``` + +--- + +## 本阶段完成检查 + +- [ ] `cd backend && pytest tests/ -v` 全部通过 +- [ ] `cd frontend && npx playwright test` 全部通过(如配置) +- [ ] `cd frontend && npm run build` 无报错 +- [ ] 完整报销流程在浏览器中手动测试无问题 +- [ ] 所有页面响应式布局正常 +- [ ] `docker-compose -f docker-compose.prod.yml up -d` 能启动 +- [ ] README 和部署文档完整 +- [ ] Swagger API 文档完整 +- [ ] Demo 数据展示正常 diff --git a/docs/superpowers/plans/2026-04-24-ai-reimbursement-mvp.md b/docs/superpowers/plans/2026-04-24-ai-reimbursement-mvp.md new file mode 100644 index 0000000..18dfec0 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-ai-reimbursement-mvp.md @@ -0,0 +1,1602 @@ +# AI 报销预审中台 MVP 实施计划 + +> **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:** 在 8 周内完成 AI 报销预审中台的 MVP,跑通「上传材料 → OCR 识别 → 草稿生成 → 规则预审 → 补件交互 → 用户确认 → 模拟同步」完整闭环,优先支持差旅报销场景。 + +**Architecture:** 采用模块化单体架构,前端 Vue3 + Ant Design Vue,后端 Python FastAPI,数据库 PostgreSQL + Redis,文件存储 MinIO,OCR 先用百度/腾讯云 API 封装。Agent 编排用自研轻量状态机 + LangGraph(可选),规则引擎自研 JSON Rule Engine。6 层架构:用户入口 → AI 操作层 → Agent 层 → 影子账本 → Policy & Evidence → System Adapter。 + +**Tech Stack:** +- 前端:Vue 3 + TypeScript + Ant Design Vue + Vite + Pinia +- 后端:Python 3.11+ / FastAPI + SQLAlchemy + Alembic + Pydantic v2 +- 数据库:PostgreSQL 15 + Redis 7 +- 文件存储:MinIO(S3 兼容) +- OCR:百度云 OCR API(增值税发票、火车票、机票行程单) +- 规则引擎:自研 JSON Rule Engine +- Agent:自研 Orchestrator 状态机 + 大模型 API(OpenAI / 国内模型) +- 部署:Docker Compose + +--- + +## 团队分工建议(3-5 人) + +| 角色 | 人数 | 职责 | +|---|---|---| +| 后端工程师 A | 1 | 核心后端:任务管理、影子账本、Agent 编排、规则引擎 | +| 后端工程师 B | 1 | OCR 集成、文件服务、适配器层、审计日志 | +| 前端工程师 | 1-2 | 所有页面与组件(可拆分为两人并行) | +| 全栈/Agent 工程师 | 1 | Agent Prompt 设计、大模型集成、规则配置 | + +--- + +## 总体里程碑 + +| 周 | 里程碑 | 核心交付 | +|---|---|---| +| W1 | 项目基建 + 数据模型 | 项目骨架、数据库 schema、开发环境 | +| W2-W3 | 后端核心服务 | 任务/票据/OCR/规则引擎 API | +| W3-W4 | Agent 编排 + 影子账本 | Orchestrator 状态机、5 个 Agent | +| W4-W5 | 前端核心页面 | 入口/上传/草稿/预审/补件/确认 | +| W5-W6 | 前后端联调 + 规则配置 | 完整流程跑通 | +| W7-W8 | 集成测试 + 打磨 + 部署 | E2E 测试、修复、演示 Demo | + +--- + +## Phase 1: 项目基建(W1) + +### Task 1.1: 后端项目骨架搭建 + +**Files:** +- Create: `backend/app/__init__.py` +- Create: `backend/app/main.py` +- Create: `backend/app/core/config.py` +- Create: `backend/app/core/database.py` +- Create: `backend/app/core/dependencies.py` +- Create: `backend/app/api/__init__.py` +- Create: `backend/app/api/v1/__init__.py` +- Create: `backend/app/api/v1/router.py` +- Create: `backend/app/models/__init__.py` +- Create: `backend/app/schemas/__init__.py` +- Create: `backend/app/services/__init__.py` +- Create: `backend/requirements.txt` +- Create: `backend/pyproject.toml` +- Create: `backend/Dockerfile` +- Create: `backend/alembic.ini` +- Create: `backend/alembic/env.py` +- Test: `backend/tests/__init__.py` +- Test: `backend/tests/conftest.py` +- Test: `backend/tests/test_health.py` + +- [ ] **Step 1: 初始化后端项目结构** + +创建 FastAPI 项目骨架,使用以下目录结构: + +``` +backend/ +├── app/ +│ ├── __init__.py +│ ├── main.py # FastAPI 应用入口 +│ ├── core/ +│ │ ├── config.py # Settings(Pydantic BaseSettings) +│ │ ├── database.py # SQLAlchemy async engine + session +│ │ └── dependencies.py # 通用依赖注入(db session, 当前用户等) +│ ├── api/ +│ │ └── v1/ +│ │ ├── __init__.py +│ │ ├── router.py # v1 路由聚合 +│ │ ├── tasks.py # 报销任务 API +│ │ ├── documents.py # 票据附件 API +│ │ ├── precheck.py # 预审结果 API +│ │ └── supplements.py # 补件 API +│ ├── models/ # SQLAlchemy ORM models +│ │ ├── __init__.py +│ │ ├── task.py +│ │ ├── reimbursement.py +│ │ ├── document.py +│ │ ├── rule.py +│ │ └── audit.py +│ ├── schemas/ # Pydantic schemas +│ │ ├── __init__.py +│ │ ├── task.py +│ │ ├── reimbursement.py +│ │ ├── document.py +│ │ └── rule.py +│ ├── services/ # 业务逻辑层 +│ │ ├── __init__.py +│ │ ├── task_service.py +│ │ ├── document_service.py +│ │ ├── ocr_service.py +│ │ ├── rule_engine.py +│ │ └── sync_service.py +│ └── agents/ # Agent 编排层 +│ ├── __init__.py +│ ├── orchestrator.py +│ ├── intake_agent.py +│ ├── parse_agent.py +│ ├── rule_check_agent.py +│ ├── explain_agent.py +│ └── sync_agent.py +├── alembic/ +│ ├── env.py +│ └── versions/ +├── alembic.ini +├── requirements.txt +├── pyproject.toml +├── Dockerfile +└── tests/ + ├── __init__.py + ├── conftest.py + └── test_health.py +``` + +- [ ] **Step 2: 编写核心配置文件** + +`backend/app/core/config.py` 使用 Pydantic BaseSettings: + +```python +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + # App + APP_NAME: str = "AI Reimbursement Agent" + APP_VERSION: str = "0.1.0" + DEBUG: bool = True + + # Database + DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/x_financial" + + # Redis + REDIS_URL: str = "redis://localhost:6379/0" + + # MinIO / S3 + MINIO_ENDPOINT: str = "localhost:9000" + MINIO_ACCESS_KEY: str = "minioadmin" + MINIO_SECRET_KEY: str = "minioadmin" + MINIO_BUCKET: str = "reimbursement" + + # OCR + OCR_PROVIDER: str = "baidu" # baidu | tencent | mock + BAIDU_OCR_API_KEY: str = "" + BAIDU_OCR_SECRET_KEY: str = "" + + # LLM + LLM_PROVIDER: str = "openai" + LLM_API_KEY: str = "" + LLM_MODEL: str = "gpt-4o-mini" + LLM_BASE_URL: str = "" + + class Config: + env_file = ".env" + +settings = Settings() +``` + +- [ ] **Step 3: 编写数据库连接和 FastAPI 入口** + +`backend/app/core/database.py`: + +```python +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from sqlalchemy.orm import DeclarativeBase +from app.core.config import settings + +engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG) +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + +class Base(DeclarativeBase): + pass + +async def get_db() -> AsyncSession: + async with async_session() as session: + yield session +``` + +`backend/app/main.py`: + +```python +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.core.config import settings +from app.api.v1.router import api_router + +app = FastAPI(title=settings.APP_NAME, version=settings.APP_VERSION) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(api_router, prefix="/api/v1") + +@app.get("/health") +async def health(): + return {"status": "ok", "version": settings.APP_VERSION} +``` + +- [ ] **Step 4: 编写 requirements.txt** + +``` +fastapi==0.115.0 +uvicorn[standard]==0.30.0 +sqlalchemy[asyncio]==2.0.35 +asyncpg==0.30.0 +alembic==1.13.0 +pydantic==2.9.0 +pydantic-settings==2.5.0 +python-multipart==0.0.9 +httpx==0.27.0 +redis==5.1.0 +minio==7.2.9 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +pytest==8.3.0 +pytest-asyncio==0.24.0 +httpx # for TestClient +``` + +- [ ] **Step 5: 编写健康检查测试** + +`backend/tests/conftest.py`: + +```python +import pytest +from httpx import AsyncClient, ASGITransport +from app.main import app + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac +``` + +`backend/tests/test_health.py`: + +```python +import pytest + +@pytest.mark.asyncio +async def test_health(client): + response = await client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" +``` + +- [ ] **Step 6: 运行测试确认骨架可用** + +Run: `cd backend && pip install -r requirements.txt && pytest tests/test_health.py -v` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add backend/ +git commit -m "feat: 初始化后端项目骨架(FastAPI + SQLAlchemy + Alembic)" +``` + +--- + +### Task 1.2: 数据库 Schema + 迁移 + +**Files:** +- Create: `backend/app/models/base.py` +- Create: `backend/app/models/task.py` +- Create: `backend/app/models/reimbursement.py` +- Create: `backend/app/models/document.py` +- Create: `backend/app/models/rule.py` +- Create: `backend/app/models/audit.py` +- Create: `backend/app/models/enums.py` +- Modify: `backend/app/models/__init__.py` +- Test: `backend/tests/test_models.py` + +- [ ] **Step 1: 定义枚举类型** + +`backend/app/models/enums.py`: + +```python +import enum + +class TaskStatus(str, enum.Enum): + CREATED = "created" + MATERIAL_COLLECTING = "material_collecting" + PARSING = "parsing" + DRAFT_GENERATED = "draft_generated" + PRECHECKING = "prechecking" + NEED_SUPPLEMENT = "need_supplement" + PENDING_USER_CONFIRM = "pending_user_confirm" + SUBMITTING = "submitting" + SYNCED = "synced" + SYNC_FAILED = "sync_failed" + CLOSED = "closed" + +class ExpenseType(str, enum.Enum): + TRAVEL_TRANSPORT = "travel_transport" + TRAVEL_HOTEL = "travel_hotel" + TRAVEL_MEAL = "travel_meal" + LOCAL_TRANSPORT = "local_transport" + BUSINESS_MEAL = "business_meal" + OFFICE_SUPPLY = "office_supply" + COMMUNICATION = "communication" + OTHER = "other" + +class DocumentType(str, enum.Enum): + VAT_INVOICE = "vat_invoice" + TRAIN_TICKET = "train_ticket" + FLIGHT_ITINERARY = "flight_itinerary" + TAXI_RECEIPT = "taxi_receipt" + HOTEL_BILL = "hotel_bill" + PAYMENT_SCREENSHOT = "payment_screenshot" + TRAVEL_ORDER = "travel_order" + OTHER_ATTACHMENT = "other_attachment" + +class RiskLevel(str, enum.Enum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + BLOCKED = "blocked" + +class RuleAction(str, enum.Enum): + PASS = "pass" + WARN = "warn" + REQUIRE_EXPLANATION = "require_explanation" + REQUIRE_ATTACHMENT = "require_attachment" + REQUIRE_APPROVAL = "require_approval" + BLOCK = "block" + +class SyncStatus(str, enum.Enum): + SUCCESS = "success" + FAILED = "failed" + RETRYING = "retrying" + PENDING = "pending" +``` + +- [ ] **Step 2: 定义所有 ORM 模型** + +`backend/app/models/base.py`: + +```python +import uuid +from datetime import datetime +from sqlalchemy import String, DateTime, func +from sqlalchemy.orm import Mapped, mapped_column + +def generate_id(): + return str(uuid.uuid4()) + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) + +class IDMixin: + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_id) +``` + +`backend/app/models/task.py` — 包含 `ReimbursementTask`,字段按文档 5.2.1 节: + +```python +from sqlalchemy import String, Text, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.models.base import IDMixin, TimestampMixin, Base +from app.models.enums import TaskStatus + +class ReimbursementTask(IDMixin, TimestampMixin, Base): + __tablename__ = "reimbursement_task" + + user_id: Mapped[str] = mapped_column(String(36), index=True) + company_id: Mapped[str] = mapped_column(String(36), index=True) + task_type: Mapped[str] = mapped_column(String(50), default="travel_expense") + status: Mapped[TaskStatus] = mapped_column(default=TaskStatus.CREATED) + user_intent: Mapped[str | None] = mapped_column(Text, nullable=True) + current_agent: Mapped[str | None] = mapped_column(String(50), nullable=True) + + # relationships + documents = relationship("ExpenseDocument", back_populates="task", lazy="selectin") + reimbursement = relationship("ShadowReimbursement", back_populates="task", uselist=False, lazy="selectin") +``` + +`backend/app/models/reimbursement.py` — 包含 `ShadowReimbursement`, `ReimbursementItem`, `SupplementRequest`, `SyncRecord`,字段按文档 5.2.2 ~ 5.2.4, 5.2.7, 5.2.8 节定义。 + +`backend/app/models/document.py` — `ExpenseDocument`,字段按文档 5.2.4 节。 + +`backend/app/models/rule.py` — `ExpenseRule`, `RuleHit`,字段按文档 5.2.5, 5.2.6 节。 + +`backend/app/models/audit.py` — `AuditLog`(通用审计日志表,记录所有关键操作)。 + +- [ ] **Step 3: 更新 `models/__init__.py` 导出所有模型** + +```python +from app.models.task import ReimbursementTask +from app.models.reimbursement import ShadowReimbursement, ReimbursementItem, SupplementRequest, SyncRecord +from app.models.document import ExpenseDocument +from app.models.rule import ExpenseRule, RuleHit +from app.models.audit import AuditLog +from app.models.base import Base + +__all__ = [ + "ReimbursementTask", "ShadowReimbursement", "ReimbursementItem", + "ExpenseDocument", "ExpenseRule", "RuleHit", + "SupplementRequest", "SyncRecord", "AuditLog", "Base", +] +``` + +- [ ] **Step 4: 生成 Alembic 迁移** + +Run: `cd backend && alembic revision --autogenerate -m "init schema"` +Run: `cd backend && alembic upgrade head` + +- [ ] **Step 5: 编写模型测试** + +验证所有表能正确创建和插入数据。 + +- [ ] **Step 6: Commit** + +```bash +git add backend/ +git commit -m "feat: 完成所有数据模型定义和数据库迁移" +``` + +--- + +### Task 1.3: 前端项目骨架搭建 + +**Files:** +- Create: 使用 Vite 初始化 Vue3 + TypeScript 项目 +- Create: `frontend/src/router/index.ts` +- Create: `frontend/src/stores/` — Pinia stores +- Create: `frontend/src/api/` — API 调用封装 +- Create: `frontend/src/views/` — 页面 +- Create: `frontend/src/components/` — 组件 +- Create: `frontend/src/layouts/` — 布局 +- Test: `frontend/src/App.vue` + +- [ ] **Step 1: 初始化 Vue3 项目** + +```bash +npm create vite@latest frontend -- --template vue-ts +cd frontend +npm install ant-design-vue @ant-design/icons-vue vue-router pinia axios dayjs +``` + +目录结构: + +``` +frontend/ +├── src/ +│ ├── api/ # API 调用 +│ │ ├── index.ts # axios 实例 +│ │ ├── task.ts # 报销任务 API +│ │ ├── document.ts # 票据 API +│ │ └── precheck.ts # 预审 API +│ ├── components/ # 通用组件 +│ │ ├── FileUpload.vue +│ │ ├── ExpenseTable.vue +│ │ └── RuleHitCard.vue +│ ├── layouts/ +│ │ └── MainLayout.vue +│ ├── router/ +│ │ └── index.ts +│ ├── stores/ +│ │ ├── task.ts +│ │ └── user.ts +│ ├── views/ +│ │ ├── HomeView.vue # 报销入口 +│ │ ├── UploadView.vue # 票据上传 +│ │ ├── DraftView.vue # 报销草稿 +│ │ ├── PrecheckView.vue # 预审结果 +│ │ ├── SupplementView.vue # 补件交互 +│ │ ├── ConfirmView.vue # 提交确认 +│ │ └── AuditView.vue # 审计日志 +│ ├── App.vue +│ └── main.ts +├── index.html +├── vite.config.ts +├── tsconfig.json +└── package.json +``` + +- [ ] **Step 2: 配置路由和布局** + +`frontend/src/router/index.ts`: + +```typescript +import { createRouter, createWebHistory } from 'vue-router' + +const routes = [ + { path: '/', name: 'home', component: () => import('@/views/HomeView.vue') }, + { path: '/task/:taskId/upload', name: 'upload', component: () => import('@/views/UploadView.vue') }, + { path: '/task/:taskId/draft', name: 'draft', component: () => import('@/views/DraftView.vue') }, + { path: '/task/:taskId/precheck', name: 'precheck', component: () => import('@/views/PrecheckView.vue') }, + { path: '/task/:taskId/supplement', name: 'supplement', component: () => import('@/views/SupplementView.vue') }, + { path: '/task/:taskId/confirm', name: 'confirm', component: () => import('@/views/ConfirmView.vue') }, + { path: '/audit', name: 'audit', component: () => import('@/views/AuditView.vue') }, +] + +export default createRouter({ history: createWebHistory(), routes }) +``` + +- [ ] **Step 3: 配置 API 封装** + +`frontend/src/api/index.ts`: + +```typescript +import axios from 'axios' + +const api = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1', + timeout: 30000, +}) + +export default api +``` + +- [ ] **Step 4: 确认前端能正常启动** + +Run: `cd frontend && npm run dev` +Expected: 浏览器访问 http://localhost:5173 能看到页面 + +- [ ] **Step 5: Commit** + +```bash +git add frontend/ +git commit -m "feat: 初始化前端项目骨架(Vue3 + TypeScript + Ant Design Vue)" +``` + +--- + +### Task 1.4: Docker Compose 开发环境 + +**Files:** +- Create: `docker-compose.yml` +- Create: `backend/Dockerfile` +- Create: `frontend/Dockerfile` +- Create: `.env.example` +- Create: `backend/init.sql`(初始数据) + +- [ ] **Step 1: 编写 docker-compose.yml** + +```yaml +version: "3.8" +services: + postgres: + image: postgres:15 + environment: + POSTGRES_DB: x_financial + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + + minio: + image: minio/minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + +volumes: + pgdata: + minio_data: +``` + +- [ ] **Step 2: 编写 .env.example** + +按 config.py 中的字段列出所有环境变量,标注必填/选填。 + +- [ ] **Step 3: 验证环境启动** + +Run: `docker-compose up -d` +Run: `docker-compose ps` +Expected: postgres, redis, minio 均为 running + +- [ ] **Step 4: Commit** + +```bash +git add docker-compose.yml .env.example backend/Dockerfile frontend/Dockerfile +git commit -m "feat: 添加 Docker Compose 开发环境(PostgreSQL + Redis + MinIO)" +``` + +--- + +## Phase 2: 后端核心服务(W2-W3) + +### Task 2.1: 报销任务管理 API + +**Files:** +- Create: `backend/app/schemas/task.py` +- Create: `backend/app/services/task_service.py` +- Create: `backend/app/api/v1/tasks.py` +- Modify: `backend/app/api/v1/router.py` +- Test: `backend/tests/test_task_api.py` + +- [ ] **Step 1: 定义 Pydantic schemas** + +`backend/app/schemas/task.py` — 包含 `TaskCreateRequest`, `TaskResponse`, `TaskListResponse` 等请求/响应模型,字段按文档第 8 节 API 定义。 + +- [ ] **Step 2: 实现 TaskService 业务逻辑** + +包含方法: +- `create_task(user_id, company_id, user_intent)` → 创建任务,状态设为 `CREATED` +- `get_task(task_id)` → 查询任务详情 +- `list_tasks(user_id, status, page, size)` → 分页查询 +- `update_status(task_id, status, current_agent)` → 更新任务状态 + +- [ ] **Step 3: 实现 API 路由** + +`POST /api/v1/reimbursement/tasks` — 创建报销任务(对应文档 8.1) +`GET /api/v1/reimbursement/tasks/{task_id}` — 查询任务详情 +`GET /api/v1/reimbursement/tasks` — 列表查询 + +- [ ] **Step 4: 编写测试** + +```python +@pytest.mark.asyncio +async def test_create_task(client): + response = await client.post("/api/v1/reimbursement/tasks", json={ + "user_id": "U001", + "company_id": "C001", + "user_intent": "我要报这次北京出差的费用", + "entry_channel": "web" + }) + assert response.status_code == 201 + data = response.json() + assert "task_id" in data + assert data["status"] == "material_collecting" +``` + +- [ ] **Step 5: Commit** + +```bash +git add backend/ +git commit -m "feat: 实现报销任务管理 API(创建/查询/列表)" +``` + +--- + +### Task 2.2: 文件上传与票据管理 API + +**Files:** +- Create: `backend/app/schemas/document.py` +- Create: `backend/app/services/document_service.py` +- Create: `backend/app/services/storage_service.py` +- Create: `backend/app/api/v1/documents.py` +- Modify: `backend/app/api/v1/router.py` +- Test: `backend/tests/test_document_api.py` + +- [ ] **Step 1: 实现 MinIO 存储服务** + +`storage_service.py` — 封装 MinIO 操作: +- `upload_file(bucket, file_name, file_data, content_type)` → 上传文件 +- `get_file_url(bucket, file_name)` → 获取文件 URL +- `delete_file(bucket, file_name)` → 删除文件 + +- [ ] **Step 2: 实现文档服务** + +`document_service.py`: +- `upload_document(task_id, file, document_type)` → 保存文件到 MinIO,创建 DB 记录 +- `get_documents(task_id)` → 查询任务下所有票据 +- `update_ocr_result(document_id, ocr_result)` → 更新 OCR 识别结果 + +- [ ] **Step 3: 实现 API 路由** + +`POST /api/v1/reimbursement/tasks/{task_id}/documents` — 上传票据(对应文档 8.2) +`GET /api/v1/reimbursement/tasks/{task_id}/documents` — 查询票据列表 + +支持 multipart/form-data 文件上传。 + +- [ ] **Step 4: 编写测试** + +使用 mock MinIO,测试文件上传和记录创建。 + +- [ ] **Step 5: Commit** + +```bash +git add backend/ +git commit -m "feat: 实现文件上传与票据管理 API(MinIO 存储)" +``` + +--- + +### Task 2.3: OCR 服务集成 + +**Files:** +- Create: `backend/app/services/ocr_service.py` +- Create: `backend/app/services/ocr_providers/base.py` +- Create: `backend/app/services/ocr_providers/baidu.py` +- Create: `backend/app/services/ocr_providers/mock.py` +- Test: `backend/tests/test_ocr_service.py` + +- [ ] **Step 1: 定义 OCR Provider 抽象接口** + +`ocr_providers/base.py`: + +```python +from abc import ABC, abstractmethod +from dataclasses import dataclass + +@dataclass +class OCRResult: + document_type: str # 识别出的票据类型 + raw_text: str # 原始文字 + fields: dict # 结构化字段 + confidence: float # 整体置信度 + provider: str # 提供商 + +class OCRProvider(ABC): + @abstractmethod + async def recognize(self, file_url: str, document_type: str | None = None) -> OCRResult: + ... +``` + +- [ ] **Step 2: 实现 Mock OCR Provider(开发测试用)** + +`mock.py` — 根据文件名/类型返回预定义的结构化数据,用于开发阶段不依赖真实 OCR。 + +- [ ] **Step 3: 实现百度 OCR Provider** + +`baidu.py` — 调用百度云 OCR API,支持: +- 增值税发票识别 +- 火车票识别 +- 机票行程单识别 +- 通用票据识别(兜底) + +将百度返回结果标准化为 `OCRResult`。 + +- [ ] **Step 4: 实现 OCR Service 门面** + +`ocr_service.py`: +- `recognize(file_url, document_type)` → 根据 `config.OCR_PROVIDER` 选择 provider +- 自动识别票据类型(如果未指定) +- 返回标准化 `OCRResult` +- 更新 document 表的 `ocr_status` 和 `extracted_json` + +- [ ] **Step 5: 编写测试** + +使用 Mock Provider 测试完整流程。 + +- [ ] **Step 6: Commit** + +```bash +git add backend/ +git commit -m "feat: 实现 OCR 服务(百度云 + Mock Provider)" +``` + +--- + +### Task 2.4: 规则引擎 + +**Files:** +- Create: `backend/app/services/rule_engine.py` +- Create: `backend/app/schemas/rule.py` +- Create: `backend/app/api/v1/rules.py` +- Create: `backend/app/services/rule_checkers/__init__.py` +- Create: `backend/app/services/rule_checkers/required_fields.py` +- Create: `backend/app/services/rule_checkers/attachment_check.py` +- Create: `backend/app/services/rule_checkers/duplicate_invoice.py` +- Create: `backend/app/services/rule_checkers/amount_limit.py` +- Create: `backend/app/services/rule_checkers/date_validity.py` +- Create: `backend/app/services/rule_checkers/expense_type_match.py` +- Test: `backend/tests/test_rule_engine.py` + +- [ ] **Step 1: 定义规则引擎核心接口** + +`rule_engine.py` — `RuleEngine` 类: + +```python +class RuleCheckResult: + rule_code: str + severity: RiskLevel + action: RuleAction + message: str + suggestion: str + policy_ref: str + hit_detail: dict + +class RuleEngine: + def __init__(self, db: AsyncSession): + self.db = db + self.checkers: dict[str, RuleChecker] = {} + + async def run_precheck(self, reimbursement_id: str) -> PrecheckResult: + """执行完整预审""" + ... + + async def run_single_rule(self, rule_code: str, context: dict) -> RuleCheckResult | None: + """执行单条规则""" + ... +``` + +- [ ] **Step 2: 实现 MVP 阶段的 6 条核心规则检查器** + +每条规则是一个 `RuleChecker` 类,接收报销数据上下文,返回 `RuleCheckResult | None`: + +1. `RequiredFieldsChecker` — 必填字段校验 +2. `AttachmentCheckChecker` — 附件完整性校验(如住宿费必须上传酒店流水) +3. `DuplicateInvoiceChecker` — 重复发票检查(invoice_code + invoice_number + amount 去重) +4. `AmountLimitChecker` — 金额超标校验(按城市/职级/费用类型检查标准) +5. `DateValidityChecker` — 日期合理性校验(费用日期在出差期间内) +6. `ExpenseTypeMatchChecker` — 费用类型与票据类型匹配校验 + +- [ ] **Step 3: 实现规则管理 API** + +- `GET /api/v1/rules` — 列出所有规则 +- `POST /api/v1/rules` — 创建规则 +- `PUT /api/v1/rules/{rule_id}` — 更新规则 +- `PATCH /api/v1/rules/{rule_id}/toggle` — 启用/禁用规则 + +- [ ] **Step 4: 种子数据** + +`backend/alembic/seed/rules.sql` — 预置文档 7.2 节的 3 条示例规则 + 金额标准表。 + +- [ ] **Step 5: 编写测试** + +对每条规则编写单元测试,使用 mock 数据验证命中/未命中场景。 + +- [ ] **Step 6: Commit** + +```bash +git add backend/ +git commit -m "feat: 实现规则引擎(6 条核心规则 + 管理 API + 种子数据)" +``` + +--- + +### Task 2.5: 影子报销账本 CRUD + +**Files:** +- Create: `backend/app/schemas/reimbursement.py` +- Create: `backend/app/services/ledger_service.py` +- Create: `backend/app/api/v1/ledger.py` +- Modify: `backend/app/api/v1/router.py` +- Test: `backend/tests/test_ledger_api.py` + +- [ ] **Step 1: 定义 Pydantic schemas** + +包含:`ReimbursementDraftResponse`, `ReimbursementItemCreate`, `ReimbursementItemResponse`, `PrecheckResultResponse`。 + +- [ ] **Step 2: 实现 LedgerService** + +核心方法: +- `create_shadow_reimbursement(task_id, data)` → 创建影子报销记录 +- `get_draft(reimbursement_id)` → 获取报销草稿 +- `get_draft_by_task(task_id)` → 通过任务 ID 获取草稿 +- `update_precheck_result(reimbursement_id, result)` → 更新预审结果 +- `add_item(reimbursement_id, item_data)` → 添加报销明细 + +- [ ] **Step 3: 实现 API 路由** + +`GET /api/v1/reimbursement/tasks/{task_id}/draft` — 获取报销草稿(对应文档 8.4) +`GET /api/v1/reimbursement/tasks/{task_id}/precheck-result` — 获取预审结果(对应文档 8.5) + +- [ ] **Step 4: 编写测试** + +- [ ] **Step 5: Commit** + +```bash +git add backend/ +git commit -m "feat: 实现影子报销账本 CRUD API" +``` + +--- + +### Task 2.6: 补件与提交 API + +**Files:** +- Create: `backend/app/api/v1/supplements.py` +- Create: `backend/app/services/supplement_service.py` +- Create: `backend/app/services/sync_service.py` +- Modify: `backend/app/api/v1/router.py` +- Test: `backend/tests/test_supplement_api.py` + +- [ ] **Step 1: 实现补件服务** + +`supplement_service.py`: +- `create_supplement_request(reimbursement_id, items)` → 创建补件请求 +- `respond_supplement(request_id, response_text, document_ids)` → 用户补件响应 +- `get_supplement_requests(task_id)` → 查询补件请求列表 + +- [ ] **Step 2: 实现同步服务(MVP 阶段为模拟)** + +`sync_service.py`: +- `mock_sync_to_backend(reimbursement_id)` → 模拟后端同步,生成假的 backend_bill_id +- `get_sync_status(task_id)` → 查询同步状态 + +- [ ] **Step 3: 实现 API 路由** + +`POST /api/v1/reimbursement/tasks/{task_id}/supplements` — 用户补件(对应文档 8.6) +`POST /api/v1/reimbursement/tasks/{task_id}/submit` — 用户确认提交(对应文档 8.7) +`GET /api/v1/reimbursement/tasks/{task_id}/sync-status` — 查询同步状态(对应文档 8.8) + +- [ ] **Step 4: 编写测试** + +- [ ] **Step 5: Commit** + +```bash +git add backend/ +git commit -m "feat: 实现补件与提交确认 API(含模拟同步)" +``` + +--- + +## Phase 3: Agent 编排(W3-W4) + +### Task 3.1: Agent Orchestrator 状态机 + +**Files:** +- Create: `backend/app/agents/orchestrator.py` +- Create: `backend/app/agents/state.py` +- Create: `backend/app/api/v1/agent.py` +- Modify: `backend/app/api/v1/router.py` +- Test: `backend/tests/test_orchestrator.py` + +- [ ] **Step 1: 定义 Agent 状态和上下文** + +`agents/state.py`: + +```python +from dataclasses import dataclass, field +from app.models.enums import TaskStatus + +@dataclass +class AgentContext: + task_id: str + status: TaskStatus + user_intent: str | None = None + current_agent: str | None = None + ocr_results: list[dict] = field(default_factory=list) + reimbursement_data: dict | None = None + precheck_result: dict | None = None + supplement_requests: list[dict] = field(default_factory=list) + error_message: str | None = None + retry_count: int = 0 +``` + +- [ ] **Step 2: 实现 Orchestrator 状态机** + +`agents/orchestrator.py` — 核心编排逻辑: + +状态转换图(对应文档 4.2 节): +``` +CREATED → MATERIAL_COLLECTING → PARSING → DRAFT_GENERATED → PRECHECKING +→ NEED_SUPPLEMENT → MATERIAL_COLLECTING(循环) +→ PENDING_USER_CONFIRM → SUBMITTING → SYNCED +→ SYNC_FAILED → SUBMITTING(重试) +``` + +方法: +- `run(task_id, start_from)` → 启动编排 +- `_transition_to(context, new_status, agent_name)` → 状态转换 +- `_run_agent(context, agent_name)` → 执行单个 Agent +- `_handle_agent_result(context, result)` → 处理 Agent 返回结果 + +- [ ] **Step 3: 实现 Agent 启动 API** + +`POST /api/v1/reimbursement/tasks/{task_id}/agent/run` — 启动 Agent 处理(对应文档 8.3) + +- [ ] **Step 4: 编写状态机转换测试** + +覆盖所有正常路径和异常路径(解析失败、预审需补件、同步失败重试等)。 + +- [ ] **Step 5: Commit** + +```bash +git add backend/ +git commit -m "feat: 实现 Agent Orchestrator 状态机编排" +``` + +--- + +### Task 3.2: 5 个 Agent 实现 + +**Files:** +- Create: `backend/app/agents/base_agent.py` +- Create: `backend/app/agents/intake_agent.py` +- Create: `backend/app/agents/parse_agent.py` +- Create: `backend/app/agents/rule_check_agent.py` +- Create: `backend/app/agents/explain_agent.py` +- Create: `backend/app/agents/sync_agent.py` +- Test: `backend/tests/test_agents.py` + +- [ ] **Step 1: 定义 Agent 基类** + +`agents/base_agent.py`: + +```python +from abc import ABC, abstractmethod +from app.agents.state import AgentContext + +class AgentResult: + success: bool + data: dict + next_action: str | None # 继续编排 / 等待用户 / 需要补件 + error: str | None + +class BaseAgent(ABC): + name: str + + @abstractmethod + async def execute(self, context: AgentContext, db: AsyncSession) -> AgentResult: + ... +``` + +- [ ] **Step 2: 实现受理 Agent(IntakeAgent)** + +职责:理解用户意图,收集上下文。 +- 分析 user_intent 文本,提取报销类型、出差信息 +- 调用 LLM 做 intent classification +- 返回结构化的任务信息 + +- [ ] **Step 3: 实现单据解析 Agent(ParseAgent)** + +职责:调用 OCR,生成报销草稿。 +- 遍历任务下的所有 document,调用 ocr_service +- 将 OCR 结果汇总为报销明细 +- 创建 ShadowReimbursement + ReimbursementItem +- 自动识别费用类型 + +- [ ] **Step 4: 实现规则校验 Agent(RuleCheckAgent)** + +职责:调用规则引擎完成预审。 +- 从 DB 加载所有 enabled 规则 +- 传入报销数据上下文执行规则引擎 +- 收集所有 RuleHit +- 计算 overall risk_level +- 更新预审状态 + +- [ ] **Step 5: 实现解释与补件 Agent(ExplainAgent)** + +职责:将规则命中结果转化为用户可理解的解释。 +- 遍历 rule_hits,使用 LLM 生成自然语言解释 +- 创建 supplement_requests(缺件类型的自动创建补件请求) +- 生成修改建议 + +- [ ] **Step 6: 实现同步执行 Agent(SyncAgent)** + +职责:生成标准报销单,调用后端同步。 +- 将 ShadowReimbursement 数据映射为标准报销单格式 +- 调用 sync_service.mock_sync_to_backend +- 记录 SyncRecord +- 处理同步失败重试 + +- [ ] **Step 7: 编写每个 Agent 的单元测试** + +使用 mock DB 和 mock OCR/LLM 测试每个 Agent 的输入输出。 + +- [ ] **Step 8: Commit** + +```bash +git add backend/ +git commit -m "feat: 实现 5 个 Agent(受理/解析/规则校验/解释补件/同步)" +``` + +--- + +### Task 3.3: LLM 集成层 + +**Files:** +- Create: `backend/app/services/llm_service.py` +- Create: `backend/app/services/llm_prompts/__init__.py` +- Create: `backend/app/services/llm_prompts/intent_classification.py` +- Create: `backend/app/services/llm_prompts/risk_explanation.py` +- Create: `backend/app/services/llm_prompts/expense_type_mapping.py` +- Test: `backend/tests/test_llm_service.py` + +- [ ] **Step 1: 实现 LLM Service 封装** + +`llm_service.py`: +- `chat(system_prompt, user_message, json_mode)` → 调用 LLM API +- 支持多 provider(OpenAI 兼容接口,适配国内模型) +- 统一错误处理和重试 +- 响应解析(JSON mode) + +- [ ] **Step 2: 编写 Prompt 模板** + +3 个核心 Prompt: +- `intent_classification` — 分析用户意图,识别报销类型 +- `risk_explanation` — 将规则命中结果转为自然语言解释 +- `expense_type_mapping` — 根据 OCR 结果匹配费用类型 + +- [ ] **Step 3: 编写测试(使用 mock LLM 响应)** + +- [ ] **Step 4: Commit** + +```bash +git add backend/ +git commit -m "feat: 实现 LLM 集成层(多 Provider + Prompt 模板)" +``` + +--- + +### Task 3.4: 审计日志 + +**Files:** +- Create: `backend/app/services/audit_service.py` +- Create: `backend/app/api/v1/audit.py` +- Test: `backend/tests/test_audit.py` + +- [ ] **Step 1: 实现 AuditService** + +核心方法: +- `log(action, actor, target_type, target_id, detail)` → 记录审计日志 +- `query_logs(target_type, target_id, actor, date_range)` → 查询日志 + +需要记录的动作(对应文档 12.1 节):文件上传、OCR 识别、Agent 调用、规则命中、用户补件、用户确认、后端同步。 + +- [ ] **Step 2: 在所有关键路径埋点** + +在 task_service、document_service、ocr_service、rule_engine、orchestrator 的关键操作中调用 `audit_service.log()`。 + +- [ ] **Step 3: 实现审计日志查询 API** + +`GET /api/v1/audit/logs` — 查询审计日志(支持按 target_type、target_id、date_range 过滤) + +- [ ] **Step 4: 编写测试** + +- [ ] **Step 5: Commit** + +```bash +git add backend/ +git commit -m "feat: 实现审计日志服务(记录 + 查询 API)" +``` + +--- + +## Phase 4: 前端核心页面(W4-W5) + +### Task 4.1: 报销入口页 + 上传组件 + +**Files:** +- Create: `frontend/src/views/HomeView.vue` +- Create: `frontend/src/views/UploadView.vue` +- Create: `frontend/src/components/FileUpload.vue` +- Create: `frontend/src/stores/task.ts` +- Create: `frontend/src/api/task.ts` +- Create: `frontend/src/api/document.ts` + +- [ ] **Step 1: 实现 API 调用层** + +`api/task.ts`: +```typescript +import api from './index' + +export const createTask = (data: { userId: string; companyId: string; userIntent: string }) => + api.post('/reimbursement/tasks', data) + +export const getTask = (taskId: string) => + api.get(`/reimbursement/tasks/${taskId}`) + +export const runAgent = (taskId: string, startFrom = 'intake') => + api.post(`/reimbursement/tasks/${taskId}/agent/run`, { start_from: startFrom, mode: 'precheck' }) +``` + +`api/document.ts`: +```typescript +export const uploadDocument = (taskId: string, file: File, documentType: string) => { + const formData = new FormData() + formData.append('file', file) + formData.append('document_type', documentType) + return api.post(`/reimbursement/tasks/${taskId}/documents`, formData) +} +``` + +- [ ] **Step 2: 实现报销入口页 HomeView** + +按文档 9.2 节: +- 对话输入框(用户输入报销意图) +- 上传按钮 +- 最近任务列表 +- 常用报销类型快捷按钮("报差旅"、"看发票能不能报"等) +- 智能引导提示 + +交互流程:用户输入意图 → 调用 createTask → 跳转到上传页 + +- [ ] **Step 3: 实现文件上传组件 FileUpload** + +- 支持拖拽上传 +- 支持多文件 +- 文件类型校验(PDF、JPG、PNG) +- 文件大小限制 +- 上传进度条 +- 预览缩略图 + +- [ ] **Step 4: 实现票据上传页 UploadView** + +- 引用 FileUpload 组件 +- 选择票据类型下拉框(增值税发票、火车票、机票行程单等) +- 已上传文件列表 +- "开始识别" 按钮 → 调用 runAgent → 跳转到草稿页 + +- [ ] **Step 5: Commit** + +```bash +git add frontend/ +git commit -m "feat: 实现报销入口页和票据上传页" +``` + +--- + +### Task 4.2: 报销草稿页 + +**Files:** +- Create: `frontend/src/views/DraftView.vue` +- Create: `frontend/src/components/ExpenseTable.vue` +- Create: `frontend/src/api/precheck.ts` + +- [ ] **Step 1: 实现报销草稿页 DraftView** + +按文档 9.3 节展示: +- 报销人信息(姓名、部门、成本中心、项目) +- 报销事由 +- 费用明细表格(ExpenseTable 组件) +- 票据附件缩略图列表 +- AI 自动识别结果标注 +- 可编辑字段(金额、事由等可修改) +- 预审状态指示器 +- "预审" 按钮 → 跳转到预审结果页 + +- [ ] **Step 2: 实现 ExpenseTable 组件** + +- Ant Design Table 展示费用明细 +- 列:费用类型、金额、税额、发生日期、城市、商户、风险等级标签 +- 支持行内编辑 +- 风险等级彩色标签(绿/黄/橙/红) + +- [ ] **Step 3: Commit** + +```bash +git add frontend/ +git commit -m "feat: 实现报销草稿页和费用明细表格组件" +``` + +--- + +### Task 4.3: 预审结果页 + 补件交互页 + +**Files:** +- Create: `frontend/src/views/PrecheckView.vue` +- Create: `frontend/src/views/SupplementView.vue` +- Create: `frontend/src/components/RuleHitCard.vue` + +- [ ] **Step 1: 实现预审结果页 PrecheckView** + +按文档 9.4 节: +- 总体结论卡片(通过/需补件/有风险) +- 风险等级指示(彩色徽章) +- 通过项列表(绿色勾选) +- 风险项列表(RuleHitCard 组件) +- 缺件项列表(橙色提示) +- 每条规则命中显示:问题说明、制度依据、修改建议 +- "一键补件" 按钮 → 跳转到补件页 +- "确认提交" 按钮(仅预审通过时可用) + +- [ ] **Step 2: 实现 RuleHitCard 组件** + +- 规则名称和编码 +- 风险等级标签 +- 问题描述 +- 制度依据链接 +- 修改建议 +- 展开详情 + +- [ ] **Step 3: 实现补件交互页 SupplementView** + +- 显示待补件清单 +- 每个补件项:类型(补充附件/补充说明/修改字段)、提示文案 +- 上传附件(调用 FileUpload) +- 文本回复输入框 +- 提交补件 → 调用 supplement API → 跳转回预审页 + +- [ ] **Step 4: Commit** + +```bash +git add frontend/ +git commit -m "feat: 实现预审结果页和补件交互页" +``` + +--- + +### Task 4.4: 提交确认页 + 审计日志页 + +**Files:** +- Create: `frontend/src/views/ConfirmView.vue` +- Create: `frontend/src/views/AuditView.vue` +- Create: `frontend/src/api/audit.ts` + +- [ ] **Step 1: 实现提交确认页 ConfirmView** + +- 最终报销单摘要(不可编辑) +- 总金额确认 +- 费用明细汇总 +- 附件清单 +- 同步目标系统选择(MVP 默认 expense_system) +- "确认提交" 按钮 → 调用 submit API +- 同步状态轮询展示(提交中 → 已同步/同步失败) + +- [ ] **Step 2: 实现审计日志页 AuditView** + +按文档 9.5 节(简化版): +- 时间线展示所有操作记录 +- 筛选:按任务、操作类型、时间范围 +- 每条日志:时间、操作人、操作类型、详情 + +- [ ] **Step 3: Commit** + +```bash +git add frontend/ +git commit -m "feat: 实现提交确认页和审计日志页" +``` + +--- + +## Phase 5: 联调与集成(W5-W6) + +### Task 5.1: 前后端联调 + +**Files:** +- Modify: 多个前后端文件(修复联调问题) + +- [ ] **Step 1: 启动前后端全栈** + +后端:`cd backend && uvicorn app.main:app --reload` +前端:`cd frontend && npm run dev` + +- [ ] **Step 2: 跑通完整报销流程** + +按文档 3.1 节的 10 步流程: +1. 在首页创建报销任务 +2. 上传 2-3 张模拟票据(增值税发票、火车票、酒店流水) +3. 点击"开始识别" → Agent 启动 +4. 查看草稿页 → 确认自动识别结果 +5. 执行预审 → 查看预审结果 +6. 如有缺件/风险 → 补件 +7. 确认提交 → 查看同步状态 +8. 查看审计日志 + +- [ ] **Step 3: 修复联调过程中发现的问题** + +API 响应格式不一致、字段缺失、前后端类型不匹配等。 + +- [ ] **Step 4: Commit** + +```bash +git add . +git commit -m "fix: 前后端联调修复" +``` + +--- + +### Task 5.2: 规则配置与种子数据 + +**Files:** +- Create: `backend/alembic/seed/expense_policies.sql` +- Create: `backend/alembic/seed/expense_rules.sql` +- Create: `backend/alembic/seed/city_levels.sql` +- Create: `backend/alembic/seed/hotel_limits.sql` + +- [ ] **Step 1: 编写差旅报销制度种子数据** + +按典型企业差旅报销制度配置: +- 城市等级(一线/二线/三线) +- 住宿标准(按城市等级 × 职级) +- 交通标准(高铁/飞机按职级) +- 餐补标准 +- 必须上传的附件类型映射 + +- [ ] **Step 2: 编写规则种子数据** + +至少配置文档 7.2 节的 3 条示例规则 + MVP 需要的 6 条核心规则。 + +- [ ] **Step 3: 编写数据初始化脚本** + +`backend/scripts/seed_data.py` — 一键初始化所有种子数据。 + +- [ ] **Step 4: Commit** + +```bash +git add backend/ +git commit -m "feat: 添加差旅报销制度和规则种子数据" +``` + +--- + +## Phase 6: 测试与打磨(W7-W8) + +### Task 6.1: 后端集成测试 + +**Files:** +- Create: `backend/tests/test_integration_flow.py` +- Create: `backend/tests/conftest.py`(更新,添加测试数据库 fixture) + +- [ ] **Step 1: 编写完整流程集成测试** + +```python +@pytest.mark.asyncio +async def test_full_reimbursement_flow(db, client): + # 1. 创建任务 + task = await create_task(client, user_id="U001", intent="报北京出差费用") + assert task["status"] == "material_collecting" + + # 2. 上传票据 + doc1 = await upload_document(client, task["task_id"], "vat_invoice", "invoice.pdf") + doc2 = await upload_document(client, task["task_id"], "train_ticket", "train.pdf") + + # 3. 启动 Agent + result = await run_agent(client, task["task_id"]) + assert result["status"] in ["draft_generated", "prechecking"] + + # 4. 获取草稿 + draft = await get_draft(client, task["task_id"]) + assert len(draft["items"]) > 0 + + # 5. 获取预审结果 + precheck = await get_precheck_result(client, task["task_id"]) + assert "risk_level" in precheck + + # 6. 如果需要补件 + if precheck["precheck_status"] == "need_supplement": + supplements = precheck["rule_hits"] + for s in supplements: + if s["action"] == "require_attachment": + await respond_supplement(client, task["task_id"], s["id"], "已补充") + # 重新预审 + await run_agent(client, task["task_id"], start_from="precheck") + + # 7. 确认提交 + submit = await submit_task(client, task["task_id"]) + assert submit["status"] == "submitting" + + # 8. 检查同步状态 + sync = await get_sync_status(client, task["task_id"]) + assert sync["sync_status"] == "success" +``` + +- [ ] **Step 2: 确保所有测试通过** + +Run: `cd backend && pytest tests/ -v --tb=short` +Expected: All PASS + +- [ ] **Step 3: Commit** + +```bash +git add backend/ +git commit -m "test: 添加完整报销流程集成测试" +``` + +--- + +### Task 6.2: 前端 E2E 测试(可选) + +**Files:** +- Create: `frontend/e2e/reimbursement.spec.ts`(如选用 Playwright) + +- [ ] **Step 1: 安装 Playwright** + +```bash +cd frontend && npm install -D @playwright/test +npx playwright install +``` + +- [ ] **Step 2: 编写核心流程 E2E 测试** + +模拟用户从创建任务到提交确认的完整操作。 + +- [ ] **Step 3: 确保测试通过** + +- [ ] **Step 4: Commit** + +```bash +git add frontend/ +git commit -m "test: 添加前端 E2E 测试" +``` + +--- + +### Task 6.3: Bug 修复与 UI 打磨 + +- [ ] **Step 1: 走查所有页面,修复视觉和交互问题** + +- 响应式布局适配 +- Loading 状态 +- 错误提示 +- 空状态 +- 表单校验 + +- [ ] **Step 2: 添加 Demo 数据展示效果** + +- [ ] **Step 3: Commit** + +```bash +git add . +git commit -m "fix: UI 打磨和 Bug 修复" +``` + +--- + +### Task 6.4: 部署与文档 + +**Files:** +- Create: `docker-compose.prod.yml` +- Modify: `README.md` +- Create: `docs/api.md` +- Create: `docs/deployment.md` + +- [ ] **Step 1: 编写生产 Docker Compose** + +包含前后端 + DB + Redis + MinIO + Nginx 反向代理。 + +- [ ] **Step 2: 编写部署文档** + +环境要求、配置说明、启动步骤、常用运维命令。 + +- [ ] **Step 3: 编写 API 文档** + +FastAPI 自动生成 Swagger,补充说明和示例。 + +- [ ] **Step 4: 更新 README** + +项目简介、架构图、快速启动、开发指南。 + +- [ ] **Step 5: Commit** + +```bash +git add . +git commit -m "docs: 添加部署文档和 README" +``` + +--- + +## 任务总览 + +| Phase | 周数 | 任务数 | 可并行 | +|---|---|---|---| +| Phase 1: 项目基建 | W1 | 4 | 前端骨架 + 后端骨架 + Docker 可并行 | +| Phase 2: 后端核心 | W2-W3 | 6 | 任务API + 文件上传 + OCR 可并行 | +| Phase 3: Agent 编排 | W3-W4 | 4 | Orchestrator → Agents → LLM → 审计(部分串行) | +| Phase 4: 前端页面 | W4-W5 | 4 | 草稿/预审/补件/确认页可并行 | +| Phase 5: 联调集成 | W5-W6 | 2 | 联调 + 种子数据 | +| Phase 6: 测试打磨 | W7-W8 | 4 | 测试 + 修复 + 部署 | +| **总计** | **8 周** | **26 个任务** | | + +--- + +## 风险与缓解 + +| 风险 | 影响 | 缓解措施 | +|---|---|---| +| OCR 识别准确率不够 | 草稿数据错误 | 允许用户手动修改,低置信度高亮提示 | +| LLM 响应慢或幻觉 | 用户体验差 | Prompt 严格约束输出格式,超时 fallback | +| 规则引擎复杂度超预期 | 延期 | MVP 先做 6 条硬编码规则,JSON 配置化后续迭代 | +| 前后端联调问题多 | 延期 | W5 提前开始联调,边开发边对齐 API | +| 3-5 人不够 | 交付延期 | 优先砍规则管理页和审计页(W8 补) | + +--- + +## 验收标准 + +MVP 完成的标志: + +- [x] 用户能通过 Web 界面创建差旅报销任务 +- [x] 上传 3 种以上票据类型(增值税发票、火车票、酒店流水) +- [x] OCR 自动识别票据信息并生成报销草稿 +- [x] 规则引擎执行 6 条核心预审规则 +- [x] 预审结果以可视化方式展示(风险等级、命中规则、修改建议) +- [x] 用户能补件并重新预审 +- [x] 用户确认后模拟同步成功 +- [x] 影子报销账本完整记录业务数据 +- [x] 审计日志记录所有关键操作 +- [x] 完整流程端到端测试通过 diff --git a/document/AI报销操作层_影子报销账本_开发文档.md b/document/AI报销操作层_影子报销账本_开发文档.md new file mode 100644 index 0000000..7849e20 --- /dev/null +++ b/document/AI报销操作层_影子报销账本_开发文档.md @@ -0,0 +1,1493 @@ +# AI 报销操作层 / 影子报销账本开发文档 + +> 版本:v0.1 +> 场景范围:仅聚焦“报销”场景,不包含合同审核、采购付款、总账、税务申报等后续场景。 +> 核心思路:以 Agent 层为业务中枢,在现有 OA / ERP / 费控系统之上构建 AI 原生的报销操作层与影子报销账本。 + +--- + +## 1. 项目定位 + +### 1.1 产品名称 + +暂定名称: + +- AI 报销操作层 +- 智能报销预审 Agent +- 影子报销账本 +- 报销政策 Agent + +推荐对外名称: + +> **AI 报销预审中台** + +推荐对内技术名称: + +> **AI Reimbursement Operation Layer** + +--- + +### 1.2 一句话定义 + +在企业现有 OA、ERP、费控系统之上,构建一个以 Agent 为核心的 AI 报销操作层,让员工不用直接面对复杂系统,只需上传票据和说明事项,由 Agent 自动完成材料整理、报销草稿生成、制度预审、缺件提醒、风险解释和后端系统同步。 + +--- + +### 1.3 核心目标 + +本系统不是替代 ERP,也不是重新做一套完整费控系统,而是作为现有系统之上的智能操作层。 + +核心目标包括: + +1. 降低员工填单难度。 +2. 减少财务审核打回。 +3. 提高报销制度执行一致性。 +4. 沉淀报销过程证据链。 +5. 为后续扩展付款、采购、合同付款等财务 Agent 场景打基础。 + +--- + +### 1.4 产品边界 + +#### 本期做 + +- 票据 / 附件上传 +- OCR 识别 +- 报销草稿生成 +- 费用类型识别 +- 制度规则预审 +- 缺件提醒 +- 风险解释 +- 用户补充确认 +- 影子报销账本记录 +- 后端系统同步接口预留 +- 审计日志与规则命中记录 + +#### 本期不做 + +- 不做正式总账 +- 不做凭证过账 +- 不做税务申报 +- 不做银行支付 +- 不做完整预算控制 +- 不做合同审核 +- 不替换原 OA / ERP / 费控系统 + +--- + +## 2. 总体架构 + +### 2.1 架构原则 + +系统采用“前台 AI 操作层 + 中台 Agent 编排 + 影子报销账本 + 后端系统同步”的架构。 + +核心原则: + +> **用户体验在 AI 报销操作层,正式账务仍在 ERP。** + +> **Agent 负责理解、编排与交互;规则负责判断;系统负责留痕;后端负责正式审批和入账。** + +--- + +### 2.2 架构分层 + +系统分为 6 层: + +1. 用户与入口层 +2. AI 报销操作层 +3. Agent 层 +4. 影子报销账本层 +5. Policy & Evidence 层 +6. System Adapter / 后端集成层 + +--- + +### 2.3 分层说明 + +#### 2.3.1 用户与入口层 + +面向对象: + +- 员工 +- 财务审核人员 +- 制度管理员 +- 系统管理员 + +入口方式: + +- Web +- 企业微信 +- 钉钉 +- OA 工作台 +- 移动端 H5 + +职责: + +- 接收用户报销意图 +- 上传票据和附件 +- 展示预审结果 +- 支持补件交互 +- 支持确认提交 + +--- + +#### 2.3.2 AI 报销操作层 + +这是用户直接感知的业务操作层。 + +主要功能: + +- 对话式报销入口 +- 票据 / 附件上传 +- 报销草稿展示 +- 风险与缺件提示 +- 预审结果解释 +- 用户确认提交 + +该层的目标是让用户不再直接面对复杂 ERP 字段,而是通过自然语言、文件上传和确认操作完成报销。 + +--- + +#### 2.3.3 Agent 层 + +Agent 层是系统核心中枢,所有业务流程围绕 Agent 层展开。 + +Agent 层负责: + +- 理解用户意图 +- 编排任务流程 +- 调用 OCR 工具 +- 调用规则引擎 +- 查询主数据 +- 生成报销草稿 +- 发起补件对话 +- 输出风险解释 +- 同步后端系统 + +核心组件: + +- 受理 Agent +- 单据解析 Agent +- 规则校验 Agent +- 解释与补件 Agent +- 同步执行 Agent +- Agent Orchestrator / Workflow + +--- + +#### 2.3.4 影子报销账本层 + +影子报销账本层用于沉淀报销业务事实,但不是正式总账。 + +它记录: + +- 报销申请 +- 报销明细 +- 票据索引 +- 附件索引 +- 风险记录 +- 规则命中记录 +- 补件会话记录 +- 后端同步状态 + +作用: + +- 为前台提供统一业务视图 +- 为 Agent 提供流程状态 +- 为审计提供过程留痕 +- 为后端系统同步提供中间状态 + +注意: + +> 影子报销账本不是会计账,不承担正式记账职责。 + +--- + +#### 2.3.5 Policy & Evidence 层 + +该层负责规则判断和证据归档。 + +包含: + +- 报销制度库 +- 费用标准库 +- 费用类型字典 +- 城市等级 / 补贴规则 +- 发票验真结果 +- 发票查重结果 +- 审计日志 +- 证据归档库 + +作用: + +- 为 Agent 提供制度依据 +- 为规则引擎提供结构化规则 +- 为财务审核提供可追溯证据 +- 为后续审计提供完整证据链 + +--- + +#### 2.3.6 System Adapter / 后端集成层 + +负责连接企业已有系统。 + +可对接系统: + +- OA / BPM 审批系统 +- ERP / 财务系统 +- 费控报销系统 +- 预算系统 +- 主数据系统 +- 发票平台 +- 税务接口 +- 财务共享中心 +- 企业微信 / 钉钉消息系统 + +职责: + +- 查询主数据 +- 查询预算信息 +- 查询员工信息 +- 写入报销草稿 +- 发起审批流程 +- 同步审批状态 +- 同步归档结果 + +--- + +## 3. 核心业务流程 + +### 3.1 流程总览 + +完整流程如下: + +1. 用户上传票据与出差信息 +2. Agent 受理并理解任务 +3. Agent 调用 OCR 和解析工具 +4. Agent 识别费用类型并生成影子报销记录 +5. Agent 调用规则引擎完成制度预审 +6. Agent 输出风险解释和缺件提醒 +7. 用户补充材料并确认 +8. Agent 生成标准报销单 +9. Agent 同步 OA / ERP / 费控系统 +10. 后端完成正式审批、入账与归档 + +--- + +### 3.2 主流程时序 + +```mermaid +sequenceDiagram + participant U as 用户 + participant UI as AI报销操作层 + participant AO as Agent Orchestrator + participant OCR as OCR/票据识别 + participant Rule as 规则引擎 + participant Ledger as 影子报销账本 + participant Adapter as 后端适配器 + participant ERP as OA/ERP/费控系统 + + U->>UI: 上传票据/说明报销事项 + UI->>AO: 创建报销任务 + AO->>OCR: 调用票据识别 + OCR-->>AO: 返回结构化票据信息 + AO->>Ledger: 创建影子报销记录 + AO->>Rule: 调用制度预审 + Rule-->>AO: 返回规则命中与风险结果 + AO->>UI: 展示风险/缺件/草稿 + U->>UI: 补充材料并确认 + UI->>AO: 提交确认 + AO->>Ledger: 更新报销状态 + AO->>Adapter: 生成标准报销单并同步 + Adapter->>ERP: 写入报销单/发起审批 + ERP-->>Adapter: 返回审批单号/状态 + Adapter-->>Ledger: 回写后端同步状态 +``` + +--- + +### 3.3 Agent 驱动运行逻辑 + +```mermaid +flowchart TD + A[用户上传票据与出差信息] --> B[受理 Agent 理解任务] + B --> C[单据解析 Agent 识别票据与附件] + C --> D[生成影子报销记录] + D --> E[规则校验 Agent 调用制度规则] + E --> F{是否存在风险或缺件} + F -- 是 --> G[解释与补件 Agent 发起补件交互] + G --> H[用户补充材料并确认] + H --> E + F -- 否 --> I[同步执行 Agent 生成标准报销单] + I --> J[同步 OA / ERP / 费控系统] + J --> K[后端正式审批、入账与归档] +``` + +--- + +## 4. Agent 设计 + +### 4.1 Agent 总体分工 + +| Agent | 职责 | 输入 | 输出 | +|---|---|---|---| +| 受理 Agent | 理解用户报销意图,收集上下文 | 用户自然语言、上传文件、历史任务 | 报销任务、材料清单、缺失信息 | +| 单据解析 Agent | OCR 识别、字段抽取、费用归类 | 发票、行程单、酒店单、支付截图 | 结构化票据信息、费用明细建议 | +| 规则校验 Agent | 制度预审、超标、查重、合规判断 | 报销草稿、规则库、主数据 | 规则命中记录、风险等级 | +| 解释与补件 Agent | 解释风险、提示缺件、引导修改 | 风险结果、缺件列表、制度依据 | 用户可理解的解释、补件任务 | +| 同步执行 Agent | 生成标准报销单,调用后端接口 | 最终确认数据、影子账本记录 | 后端报销单号、审批状态 | + +--- + +### 4.2 Agent Orchestrator + +Agent Orchestrator 是流程编排器,负责管理任务状态和 Agent 调度。 + +职责: + +- 创建任务 +- 维护任务状态 +- 决定下一步调用哪个 Agent +- 管理多轮对话上下文 +- 管理工具调用 +- 处理失败重试 +- 记录流程日志 +- 管理人工确认节点 + +建议状态机: + +```mermaid +stateDiagram-v2 + [*] --> Created: 创建任务 + Created --> MaterialCollecting: 收集材料 + MaterialCollecting --> Parsing: 解析单据 + Parsing --> DraftGenerated: 生成草稿 + DraftGenerated --> PreChecking: 制度预审 + PreChecking --> NeedSupplement: 存在缺件/风险 + NeedSupplement --> MaterialCollecting: 用户补件 + PreChecking --> PendingUserConfirm: 预审通过 + PendingUserConfirm --> Submitting: 用户确认 + Submitting --> Synced: 同步后端成功 + Submitting --> SyncFailed: 同步失败 + SyncFailed --> Submitting: 重试 + Synced --> [*] +``` + +--- + +### 4.3 Agent 调用原则 + +Agent 不直接决定最终财务结果,只负责预审和建议。 + +原则: + +1. 低风险问题可自动提示。 +2. 中风险问题需要用户确认。 +3. 高风险问题进入人工审核。 +4. 所有规则命中必须留痕。 +5. 所有 AI 解释必须能追溯到制度或证据。 +6. 正式审批和入账仍以后端系统为准。 + +--- + +## 5. 核心数据模型 + +### 5.1 主要实体 + +| 实体 | 说明 | +|---|---| +| ReimbursementTask | 报销任务,Agent 处理的最小任务单元 | +| ShadowReimbursement | 影子报销记录,表示一次报销业务事实 | +| ReimbursementItem | 报销明细 | +| ExpenseDocument | 票据 / 附件 | +| ExpensePolicy | 报销制度 | +| ExpenseRule | 报销规则 | +| RuleHit | 规则命中记录 | +| RiskFinding | 风险提示 | +| SupplementRequest | 补件请求 | +| ConversationRecord | 会话记录 | +| SyncRecord | 后端同步记录 | +| AuditLog | 审计日志 | + +--- + +### 5.2 表结构建议 + +#### 5.2.1 reimbursement_task 报销任务表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | string | 任务ID | +| user_id | string | 发起人ID | +| company_id | string | 企业ID | +| task_type | string | 任务类型,如 travel_expense | +| status | string | 任务状态 | +| user_intent | text | 用户原始意图 | +| current_agent | string | 当前处理 Agent | +| created_at | datetime | 创建时间 | +| updated_at | datetime | 更新时间 | + +--- + +#### 5.2.2 shadow_reimbursement 影子报销记录表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | string | 影子报销ID | +| task_id | string | 关联任务ID | +| applicant_id | string | 报销人 | +| department_id | string | 部门 | +| cost_center_id | string | 成本中心 | +| project_id | string | 项目ID | +| reimbursement_type | string | 报销类型 | +| reason | text | 报销事由 | +| total_amount | decimal | 总金额 | +| currency | string | 币种 | +| risk_level | string | 风险等级 | +| precheck_status | string | 预审状态 | +| backend_system | string | 目标后端系统 | +| backend_bill_id | string | 后端单据ID | +| sync_status | string | 同步状态 | +| created_at | datetime | 创建时间 | +| updated_at | datetime | 更新时间 | + +--- + +#### 5.2.3 reimbursement_item 报销明细表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | string | 明细ID | +| reimbursement_id | string | 影子报销ID | +| expense_type | string | 费用类型 | +| amount | decimal | 金额 | +| tax_amount | decimal | 税额 | +| occurred_at | date | 费用发生日期 | +| city | string | 发生城市 | +| vendor_name | string | 商户名称 | +| invoice_id | string | 关联票据ID | +| policy_status | string | 规则校验状态 | +| risk_level | string | 风险等级 | +| remark | text | 备注 | + +--- + +#### 5.2.4 expense_document 票据附件表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | string | 文件ID | +| reimbursement_id | string | 影子报销ID | +| item_id | string | 明细ID | +| document_type | string | 发票、行程单、酒店流水、支付截图等 | +| file_url | string | 文件地址 | +| ocr_status | string | OCR 状态 | +| extracted_json | json | OCR 抽取结果 | +| invoice_code | string | 发票代码 | +| invoice_number | string | 发票号码 | +| invoice_date | date | 开票日期 | +| amount | decimal | 金额 | +| tax_amount | decimal | 税额 | +| seller_name | string | 销售方 | +| buyer_name | string | 购买方 | +| verify_status | string | 验真状态 | +| duplicate_status | string | 查重状态 | +| created_at | datetime | 创建时间 | + +--- + +#### 5.2.5 expense_rule 规则表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | string | 规则ID | +| rule_code | string | 规则编码 | +| rule_name | string | 规则名称 | +| expense_type | string | 适用费用类型 | +| condition_json | json | 条件表达式 | +| action | string | 命中动作 | +| severity | string | 风险等级 | +| message_template | text | 提示文案模板 | +| policy_ref | string | 制度依据 | +| enabled | boolean | 是否启用 | +| version | string | 规则版本 | + +--- + +#### 5.2.6 rule_hit 规则命中表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | string | 命中ID | +| reimbursement_id | string | 报销ID | +| item_id | string | 明细ID | +| rule_id | string | 规则ID | +| severity | string | 风险等级 | +| hit_result | json | 命中详情 | +| explanation | text | 解释说明 | +| suggestion | text | 修改建议 | +| created_at | datetime | 创建时间 | + +--- + +#### 5.2.7 supplement_request 补件请求表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | string | 补件请求ID | +| reimbursement_id | string | 报销ID | +| request_type | string | 补充附件、补充说明、修改字段 | +| target_item_id | string | 关联明细ID | +| message | text | 补件提示 | +| status | string | 待补充、已补充、已关闭 | +| user_response | text | 用户回复 | +| created_at | datetime | 创建时间 | +| resolved_at | datetime | 完成时间 | + +--- + +#### 5.2.8 sync_record 后端同步记录表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | string | 同步ID | +| reimbursement_id | string | 报销ID | +| target_system | string | 目标系统 | +| request_payload | json | 请求报文 | +| response_payload | json | 响应报文 | +| sync_status | string | 成功、失败、重试中 | +| backend_bill_id | string | 后端单据ID | +| error_message | text | 错误信息 | +| created_at | datetime | 创建时间 | + +--- + +## 6. 枚举设计 + +### 6.1 报销任务状态 + +| 编码 | 名称 | +|---|---| +| created | 已创建 | +| material_collecting | 收集中 | +| parsing | 解析中 | +| draft_generated | 草稿已生成 | +| prechecking | 预审中 | +| need_supplement | 需要补件 | +| pending_user_confirm | 待用户确认 | +| submitting | 提交中 | +| synced | 已同步 | +| sync_failed | 同步失败 | +| closed | 已关闭 | + +--- + +### 6.2 费用类型 + +| 编码 | 名称 | +|---|---| +| travel_transport | 差旅交通费 | +| travel_hotel | 差旅住宿费 | +| travel_meal | 差旅餐补 | +| local_transport | 市内交通费 | +| business_meal | 业务招待费 | +| office_supply | 办公用品费 | +| communication | 通讯费 | +| other | 其他 | + +--- + +### 6.3 票据类型 + +| 编码 | 名称 | +|---|---| +| vat_invoice | 增值税发票 | +| train_ticket | 火车票 | +| flight_itinerary | 航空行程单 | +| taxi_receipt | 打车票据 | +| hotel_bill | 酒店流水 | +| payment_screenshot | 支付截图 | +| travel_order | 行程单 | +| other_attachment | 其他附件 | + +--- + +### 6.4 风险等级 + +| 编码 | 名称 | 处理方式 | +|---|---|---| +| low | 低风险 | 提醒用户 | +| medium | 中风险 | 需要用户确认或补充说明 | +| high | 高风险 | 建议人工审核 | +| blocked | 阻断 | 不允许提交 | + +--- + +### 6.5 规则动作 + +| 编码 | 名称 | +|---|---| +| pass | 通过 | +| warn | 警告 | +| require_explanation | 要求说明 | +| require_attachment | 要求补附件 | +| require_approval | 要求特殊审批 | +| block | 阻断提交 | + +--- + +## 7. 规则引擎设计 + +### 7.1 规则类型 + +MVP 阶段建议支持以下规则: + +1. 必填字段校验 +2. 附件完整性校验 +3. 票据验真校验 +4. 重复报销校验 +5. 金额超标校验 +6. 日期合理性校验 +7. 出差期间匹配校验 +8. 费用类型与票据类型匹配校验 +9. 发票抬头校验 +10. 项目 / 成本中心校验 + +--- + +### 7.2 规则示例 + +#### 规则 1:住宿费超标 + +```json +{ + "rule_code": "TRAVEL_HOTEL_LIMIT", + "rule_name": "住宿费标准校验", + "expense_type": "travel_hotel", + "condition": { + "compare": "amount_per_night", + "operator": ">", + "target": "policy.hotel_limit" + }, + "action": "require_explanation", + "severity": "medium", + "message_template": "住宿费超出当前城市和职级标准,请补充超标说明或发起特殊审批。", + "policy_ref": "差旅报销制度-住宿标准" +} +``` + +#### 规则 2:缺少酒店流水 + +```json +{ + "rule_code": "HOTEL_BILL_REQUIRED", + "rule_name": "住宿费必须上传酒店流水", + "expense_type": "travel_hotel", + "condition": { + "required_document_type": "hotel_bill" + }, + "action": "require_attachment", + "severity": "medium", + "message_template": "当前住宿费缺少酒店流水,请补充上传。", + "policy_ref": "差旅报销制度-附件要求" +} +``` + +#### 规则 3:票据疑似重复 + +```json +{ + "rule_code": "DUPLICATE_INVOICE_CHECK", + "rule_name": "重复发票检查", + "expense_type": "*", + "condition": { + "duplicate_keys": ["invoice_code", "invoice_number", "amount", "invoice_date"] + }, + "action": "block", + "severity": "blocked", + "message_template": "检测到该发票可能已被报销,请确认或联系财务处理。", + "policy_ref": "费用报销管理办法-重复报销" +} +``` + +--- + +### 7.3 规则结果结构 + +```json +{ + "precheck_status": "need_supplement", + "risk_level": "medium", + "rule_hits": [ + { + "rule_code": "HOTEL_BILL_REQUIRED", + "severity": "medium", + "message": "当前住宿费缺少酒店流水,请补充上传。", + "suggestion": "请上传酒店结账单或住宿明细流水。", + "policy_ref": "差旅报销制度-附件要求" + } + ] +} +``` + +--- + +## 8. API 设计 + +### 8.1 创建报销任务 + +```http +POST /api/reimbursement/tasks +``` + +请求: + +```json +{ + "user_id": "U001", + "company_id": "C001", + "user_intent": "我要报这次北京出差的费用", + "entry_channel": "web" +} +``` + +响应: + +```json +{ + "task_id": "T202604240001", + "status": "material_collecting" +} +``` + +--- + +### 8.2 上传票据附件 + +```http +POST /api/reimbursement/tasks/{task_id}/documents +``` + +请求: + +```json +{ + "document_type": "vat_invoice", + "file_url": "https://example.com/file/invoice001.pdf" +} +``` + +响应: + +```json +{ + "document_id": "D001", + "ocr_status": "pending" +} +``` + +--- + +### 8.3 启动 Agent 处理 + +```http +POST /api/reimbursement/tasks/{task_id}/agent/run +``` + +请求: + +```json +{ + "start_from": "intake", + "mode": "precheck" +} +``` + +响应: + +```json +{ + "task_id": "T202604240001", + "status": "parsing", + "current_agent": "document_parse_agent" +} +``` + +--- + +### 8.4 获取报销草稿 + +```http +GET /api/reimbursement/tasks/{task_id}/draft +``` + +响应: + +```json +{ + "reimbursement_id": "R001", + "reason": "北京出差", + "total_amount": 2380.00, + "items": [ + { + "item_id": "I001", + "expense_type": "travel_hotel", + "amount": 1200.00, + "occurred_at": "2026-04-20", + "risk_level": "medium" + } + ], + "precheck_status": "need_supplement" +} +``` + +--- + +### 8.5 获取预审结果 + +```http +GET /api/reimbursement/tasks/{task_id}/precheck-result +``` + +响应: + +```json +{ + "precheck_status": "need_supplement", + "risk_level": "medium", + "summary": "当前报销单存在 1 个缺件问题和 1 个超标风险。", + "rule_hits": [ + { + "rule_code": "HOTEL_BILL_REQUIRED", + "severity": "medium", + "message": "住宿费缺少酒店流水。", + "suggestion": "请上传酒店流水。" + } + ] +} +``` + +--- + +### 8.6 用户补件 + +```http +POST /api/reimbursement/tasks/{task_id}/supplements +``` + +请求: + +```json +{ + "supplement_request_id": "S001", + "response_text": "已补充酒店流水", + "document_ids": ["D003"] +} +``` + +响应: + +```json +{ + "status": "received", + "next_action": "rerun_precheck" +} +``` + +--- + +### 8.7 用户确认提交 + +```http +POST /api/reimbursement/tasks/{task_id}/submit +``` + +请求: + +```json +{ + "confirmed": true, + "submit_to": "expense_system" +} +``` + +响应: + +```json +{ + "status": "submitting", + "sync_id": "SYNC001" +} +``` + +--- + +### 8.8 查询同步状态 + +```http +GET /api/reimbursement/tasks/{task_id}/sync-status +``` + +响应: + +```json +{ + "sync_status": "success", + "target_system": "expense_system", + "backend_bill_id": "BX202604240001" +} +``` + +--- + +## 9. 前端页面设计 + +### 9.1 页面清单 + +MVP 阶段建议包含以下页面: + +1. 报销入口页 +2. 票据上传页 +3. 报销草稿页 +4. 预审结果页 +5. 补件交互页 +6. 提交确认页 +7. 任务详情页 +8. 财务审核辅助页 +9. 制度规则管理页 +10. 审计日志页 + +--- + +### 9.2 报销入口页 + +核心元素: + +- 对话输入框 +- 上传按钮 +- 最近任务 +- 常用报销类型 +- 智能引导问题 + +示例提示: + +- “我要报一次差旅” +- “帮我看这些票能不能报” +- “帮我生成报销单” +- “这张发票为什么不合规?” + +--- + +### 9.3 报销草稿页 + +展示内容: + +- 报销人 +- 部门 +- 成本中心 +- 项目 +- 报销事由 +- 费用明细 +- 票据附件 +- 自动识别结果 +- 可编辑字段 +- 预审状态 + +--- + +### 9.4 预审结果页 + +展示内容: + +- 总体结论 +- 风险等级 +- 通过项 +- 风险项 +- 缺件项 +- 命中规则 +- 制度依据 +- 修改建议 +- 一键补件按钮 +- 人工确认按钮 + +--- + +### 9.5 财务审核辅助页 + +展示内容: + +- 报销单摘要 +- AI 预审结论 +- 规则命中列表 +- 用户补件记录 +- 附件证据链 +- 同步状态 +- 审计日志 + +--- + +## 10. 后端服务设计 + +### 10.1 服务拆分建议 + +MVP 阶段可采用模块化单体,后续再微服务化。 + +建议模块: + +| 模块 | 说明 | +|---|---| +| user-service | 用户与权限 | +| task-service | 报销任务管理 | +| document-service | 文件与票据管理 | +| agent-service | Agent 编排 | +| ocr-service | OCR 调用封装 | +| rule-service | 规则引擎 | +| ledger-service | 影子账本 | +| evidence-service | 证据归档 | +| adapter-service | 后端系统适配 | +| audit-service | 审计日志 | + +--- + +### 10.2 推荐技术栈 + +可选方案: + +#### 前端 + +- React / Vue +- Ant Design / Element Plus +- 企业微信 / 钉钉 H5 SDK + +#### 后端 + +- Java Spring Boot +- Python FastAPI +- Node.js NestJS + +#### 数据库 + +- PostgreSQL / MySQL +- Redis +- MinIO / OSS 文件存储 +- Elasticsearch / OpenSearch,可选,用于检索 + +#### AI / Agent + +- Agent Orchestrator 自研 +- LangGraph / CrewAI / AutoGen / 自定义 Workflow,可选 +- 大模型 API +- OCR API +- 向量数据库,可选 + +#### 规则引擎 + +- JSON Rule Engine +- Drools +- 自研轻量规则引擎 + +--- + +## 11. 权限与安全 + +### 11.1 权限角色 + +| 角色 | 权限 | +|---|---| +| 员工 | 创建报销、上传材料、查看自己的任务 | +| 财务审核人员 | 查看待审核报销、查看预审结果、处理风险 | +| 制度管理员 | 维护制度、规则、费用标准 | +| 系统管理员 | 管理组织、用户、接口配置 | +| 审计人员 | 查看日志和证据链 | + +--- + +### 11.2 安全要求 + +- 文件上传需做病毒扫描。 +- 发票、身份证、银行卡等敏感信息需脱敏展示。 +- 所有 Agent 操作需记录日志。 +- 所有规则版本需保留历史。 +- 所有用户确认动作需留痕。 +- 后端接口需有签名、鉴权和重放保护。 +- 不允许 Agent 绕过人工确认直接提交高风险单据。 + +--- + +## 12. 审计与可解释性 + +### 12.1 必须留痕的动作 + +- 用户上传文件 +- OCR 识别结果 +- Agent 调用记录 +- 规则命中记录 +- 用户补件记录 +- 用户确认记录 +- 后端同步请求 +- 后端同步响应 +- 审核人处理记录 + +--- + +### 12.2 解释输出模板 + +风险解释建议采用统一格式: + +```text +问题类型:住宿费超标 +命中规则:TRAVEL_HOTEL_LIMIT +制度依据:差旅报销制度-住宿标准 +问题说明:当前住宿费为 680 元/晚,超过该城市标准 500 元/晚。 +建议处理:请补充超标说明,或发起特殊审批。 +风险等级:中风险 +``` + +--- + +## 13. MVP 版本规划 + +### 13.1 MVP 目标 + +用最小范围跑通“上传材料 → 识别 → 草稿 → 预审 → 补件 → 确认 → 同步”的完整闭环。 + +--- + +### 13.2 MVP 功能范围 + +#### 必做 + +- 用户创建报销任务 +- 上传票据 / 附件 +- OCR 识别 +- 自动生成报销草稿 +- 费用类型识别 +- 基础规则预审 +- 风险与缺件提示 +- 用户补件 +- 用户确认提交 +- 影子报销账本 +- 审计日志 +- 模拟后端同步 + +#### 可延后 + +- 多企业多租户 +- 复杂审批流 +- 深度 ERP 接口 +- 发票实时验真 +- 预算强控制 +- 移动端完整体验 +- 多语言 +- 行业模板市场 + +--- + +### 13.3 MVP 优先支持的报销类型 + +建议优先支持: + +> 差旅报销 + +原因: + +- 票据类型丰富 +- 规则典型 +- 缺件场景多 +- 价值容易演示 +- 客户容易理解 + +优先支持票据: + +- 增值税发票 +- 火车票 +- 机票行程单 +- 酒店流水 +- 打车票据 +- 支付截图 + +--- + +## 14. 里程碑计划 + +### 阶段 1:原型验证 + +目标: + +- 完成核心流程 Demo +- 完成影子账本基础表 +- 完成 OCR 接入 +- 完成 5 条基础规则 + +输出: + +- Web Demo +- 报销任务流 +- 草稿生成页面 +- 预审结果页面 + +--- + +### 阶段 2:MVP 可用版本 + +目标: + +- 完整跑通差旅报销 +- 支持多轮补件 +- 支持规则命中留痕 +- 支持模拟后端同步 + +输出: + +- MVP 系统 +- 规则配置后台 +- 审计日志 +- 财务审核辅助页 + +--- + +### 阶段 3:试点企业接入 + +目标: + +- 接入一家试点企业 +- 对接其 OA / 费控 / ERP 中至少一个系统 +- 根据真实制度配置规则 +- 验证打回率降低效果 + +输出: + +- 试点报告 +- 规则模板 +- 接口适配经验 +- ROI 数据 + +--- + +### 阶段 4:产品化 + +目标: + +- 多企业配置 +- 多制度模板 +- 多后端适配器 +- 管理后台完善 +- 权限与安全完善 + +输出: + +- 标准产品版本 +- 部署手册 +- 运维手册 +- 行业模板 + +--- + +## 15. 关键指标 + +### 15.1 业务指标 + +| 指标 | 说明 | +|---|---| +| 自动草稿生成率 | 上传材料后成功生成草稿的比例 | +| 预审命中率 | 规则识别出问题的比例 | +| 打回率降低 | 接入前后报销打回率变化 | +| 平均填单时间 | 员工完成报销所需时间 | +| 平均审核时间 | 财务审核单据所需时间 | +| 补件一次完成率 | 用户一次补齐材料的比例 | + +--- + +### 15.2 技术指标 + +| 指标 | 说明 | +|---|---| +| OCR 准确率 | 关键字段识别准确率 | +| 规则执行耗时 | 单次预审耗时 | +| Agent 成功完成率 | Agent 完整跑完流程的比例 | +| 后端同步成功率 | 同步 OA / ERP / 费控成功率 | +| 系统响应时间 | 主要接口响应时延 | +| 任务异常率 | Agent 流程失败比例 | + +--- + +## 16. 风险与应对 + +### 16.1 规则不准确 + +风险: + +- 制度复杂,规则配置错误会导致误判。 + +应对: + +- 规则上线前必须人工确认。 +- 规则命中要显示依据。 +- 高风险结果只建议,不自动阻断。 + +--- + +### 16.2 OCR 识别错误 + +风险: + +- 票据字段识别不准,导致草稿错误。 + +应对: + +- 关键字段允许人工修改。 +- 低置信度字段高亮提示。 +- 原图与识别结果并排展示。 + +--- + +### 16.3 后端系统难对接 + +风险: + +- 企业 ERP / OA 接口不统一。 + +应对: + +- 先做模拟同步。 +- 后端适配器标准化。 +- 支持 API 与 RPA 两种方式。 + +--- + +### 16.4 AI 幻觉 + +风险: + +- Agent 可能生成不符合制度的解释。 + +应对: + +- 所有解释必须基于规则命中和制度片段。 +- 没有依据时必须显示“未找到明确制度依据”。 +- 高风险判断进入人工审核。 + +--- + +### 16.5 客户担心替换原系统 + +风险: + +- 客户认为系统会冲击现有 ERP / 费控系统。 + +应对: + +- 明确定位为“前台 AI 操作层”。 +- 正式审批、入账、归档仍在后端系统完成。 +- 强调不替换原系统。 + +--- + +## 17. 后续扩展方向 + +本期只做报销,后续可扩展到: + +1. 付款申请 +2. 采购申请 +3. 合同付款 +4. 供应商对账 +5. 发票入账 +6. 预算执行分析 +7. 财务共享审核 Agent + +长期目标: + +> 构建企业财务运营 Agent Layer。 + +--- + +## 18. 附录:推荐目录结构 + +```text +ai-reimbursement-agent/ +├── frontend/ +│ ├── pages/ +│ ├── components/ +│ └── services/ +├── backend/ +│ ├── task-service/ +│ ├── document-service/ +│ ├── agent-service/ +│ ├── rule-service/ +│ ├── ledger-service/ +│ ├── evidence-service/ +│ ├── adapter-service/ +│ └── audit-service/ +├── database/ +│ ├── migrations/ +│ └── seed/ +├── docs/ +│ ├── api.md +│ ├── rules.md +│ ├── data-model.md +│ └── deployment.md +└── README.md +``` + +--- + +## 19. 附录:最小开发任务拆分 + +### 后端任务 + +- [ ] 创建报销任务接口 +- [ ] 上传附件接口 +- [ ] OCR 调用封装 +- [ ] 影子报销账本表 +- [ ] 报销明细表 +- [ ] 规则引擎基础能力 +- [ ] 规则命中记录 +- [ ] 补件请求接口 +- [ ] 后端同步模拟接口 +- [ ] 审计日志 + +### 前端任务 + +- [ ] 报销入口页 +- [ ] 上传附件组件 +- [ ] 报销草稿页 +- [ ] 预审结果页 +- [ ] 补件交互页 +- [ ] 提交确认页 +- [ ] 财务审核辅助页 + +### Agent 任务 + +- [ ] 受理 Agent +- [ ] 单据解析 Agent +- [ ] 规则校验 Agent +- [ ] 解释与补件 Agent +- [ ] 同步执行 Agent +- [ ] Orchestrator 状态机 + +### 规则任务 + +- [ ] 必填字段规则 +- [ ] 缺附件规则 +- [ ] 重复发票规则 +- [ ] 金额超标规则 +- [ ] 日期异常规则 +- [ ] 费用类型匹配规则 + +--- + +## 20. 总结 + +本系统的核心不是重新做一套报销系统,也不是替换 ERP,而是在企业现有系统之上构建一个以 Agent 为核心的 AI 报销操作层。 + +它的关键价值在于: + +- 员工只面对简单前台。 +- Agent 负责理解、编排和补件。 +- 影子报销账本沉淀业务事实。 +- Policy & Evidence 层保证判断可解释、证据可追溯。 +- 后端 OA / ERP / 费控系统继续负责正式审批、入账和归档。 + +一句话总结: + +> **前台体验在 AI 报销操作层,正式账务仍在 ERP;所有业务流程围绕 Agent 层展开。** diff --git a/index.html b/index.html new file mode 100644 index 0000000..a1906f6 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + ReimburseOps - 企业报销智能运营台 + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..bce2a49 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "x-financial-reimbursement-admin", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "start": "vite --host 127.0.0.1", + "dev": "vite --host 127.0.0.1", + "build": "vite build", + "preview": "vite preview --host 127.0.0.1" + }, + "dependencies": { + "@vitejs/plugin-vue": "^5.2.4", + "@vueuse/motion": "^3.0.3", + "vite": "^5.4.19", + "vue": "^3.5.13" + } +} diff --git a/reimbursement-admin-prototype.html b/reimbursement-admin-prototype.html new file mode 100644 index 0000000..a1906f6 --- /dev/null +++ b/reimbursement-admin-prototype.html @@ -0,0 +1,12 @@ + + + + + + ReimburseOps - 企业报销智能运营台 + + +
+ + + diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..ce03541 --- /dev/null +++ b/src/App.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/src/assets/styles/global.css b/src/assets/styles/global.css new file mode 100644 index 0000000..85a8b31 --- /dev/null +++ b/src/assets/styles/global.css @@ -0,0 +1,94 @@ +:root { + --bg: #f6f8fb; + --surface: #fff; + --surface-soft: #f9fbff; + --ink: #101828; + --text: #344054; + --muted: #667085; + --line: #e4e7ec; + --line-strong: #d0d5dd; + --primary: #335cff; + --primary-soft: #eef3ff; + --success: #0e9384; + --success-soft: #e7f8f5; + --warning: #b54708; + --warning-soft: #fff4e5; + --danger: #b42318; + --danger-soft: #ffebe9; + --nav: #0b1220; + --nav-muted: #7d89a5; + --radius: 8px; + --ease: cubic-bezier(.2, .8, .2, 1); + font-family: Inter, "SF Pro Display", "Segoe UI", "PingFang SC", "Microsoft YaHei", Arial, sans-serif; +} + +* { box-sizing: border-box; } +body { margin: 0; min-height: 100dvh; color: var(--text); background: var(--bg); } +button, input, select, textarea { font: inherit; } +button { cursor: pointer; } +button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 3px solid rgba(51,92,255,.24); outline-offset: 2px; } + +.eyebrow { color: var(--primary); font-size: 11px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; } +h1, h2, h3, p { margin: 0; } +h1 { margin-top: 4px; color: var(--ink); font-size: clamp(25px, 3vw, 36px); line-height: 1.1; } + +.btn { + min-height: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 0 14px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fff; + color: var(--text); + font-weight: 700; + transition: transform 180ms var(--ease), box-shadow 180ms var(--ease), background 180ms var(--ease), border-color 180ms var(--ease); +} +.btn:hover { transform: translateY(-1px); box-shadow: 0 10px 24px rgba(16,24,40,.08); } +.btn:active, .mini-btn:active, .chip:active, .nav-btn:active { transform: scale(.97); } +.btn.primary { border-color: transparent; background: var(--primary); color: #fff; box-shadow: 0 12px 24px rgba(51,92,255,.22); } +.btn.success { border-color: transparent; background: var(--success); color: #fff; } +.btn.danger { border-color: rgba(180,35,24,.18); background: var(--danger-soft); color: var(--danger); } +.btn.ghost { background: transparent; } + +.badge { display: inline-flex; min-height: 26px; align-items: center; padding: 4px 9px; border-radius: 999px; background: var(--primary-soft); color: var(--primary); font-size: 12px; font-weight: 780; white-space: nowrap; } +.badge.success { background: var(--success-soft); color: var(--success); } +.badge.warning { background: var(--warning-soft); color: var(--warning); } +.badge.danger { background: var(--danger-soft); color: var(--danger); } + +.panel { + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); + box-shadow: 0 1px 2px rgba(16,24,40,.04); + transition: transform 220ms var(--ease), box-shadow 220ms var(--ease), border-color 220ms var(--ease); +} +.panel:hover { transform: translateY(-2px); border-color: rgba(51,92,255,.22); box-shadow: 0 16px 42px rgba(16,24,40,.08); } + +.mini-btn { min-height: 34px; padding: 0 10px; border: 1px solid var(--line); border-radius: 7px; background: #fff; color: var(--text); font-size: 12px; font-weight: 750; } + +@keyframes grow { from { transform: scaleX(0); transform-origin: left; } to { transform: scaleX(1); transform-origin: left; } } +@keyframes fadeUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } + +.message-list-enter-active, .message-list-leave-active { transition: opacity 220ms var(--ease), transform 220ms var(--ease); } +.message-list-enter-from { opacity: 0; transform: translateY(8px) scale(.98); } +.message-list-leave-to { opacity: 0; transform: translateY(-6px) scale(.98); } + +@media (max-width: 1180px) { + .metric-strip, .overview-grid { grid-template-columns: 1fr 1fr; } +} +@media (max-width: 760px) { + .topbar { flex-direction: column; padding: 18px 16px; } + .top-actions, .search, .btn { width: 100%; } + .filters, .metric-strip, .overview-grid, .donut-layout, .dialog-body, .dialog-foot, .review-summary, .chat-hero { grid-template-columns: 1fr; } + .filters, .workarea { padding-inline: 16px; } + .bar-row { grid-template-columns: 1fr; gap: 6px; } + .bar-row strong { text-align: left; } + .case-panel { border-left: 0; border-top: 1px solid var(--line); } + .review-summary { grid-column: auto; } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; scroll-behavior: auto !important; } +} diff --git a/src/components/business/RequestTable.vue b/src/components/business/RequestTable.vue new file mode 100644 index 0000000..ade67e9 --- /dev/null +++ b/src/components/business/RequestTable.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/src/components/layout/FilterBar.vue b/src/components/layout/FilterBar.vue new file mode 100644 index 0000000..3c0492d --- /dev/null +++ b/src/components/layout/FilterBar.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/src/components/layout/SidebarRail.vue b/src/components/layout/SidebarRail.vue new file mode 100644 index 0000000..f865eaf --- /dev/null +++ b/src/components/layout/SidebarRail.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/src/components/layout/TopBar.vue b/src/components/layout/TopBar.vue new file mode 100644 index 0000000..ee11548 --- /dev/null +++ b/src/components/layout/TopBar.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/src/components/shared/InfoRow.vue b/src/components/shared/InfoRow.vue new file mode 100644 index 0000000..b524896 --- /dev/null +++ b/src/components/shared/InfoRow.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/src/components/shared/PanelHead.vue b/src/components/shared/PanelHead.vue new file mode 100644 index 0000000..b682653 --- /dev/null +++ b/src/components/shared/PanelHead.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/src/components/shared/ToastNotification.vue b/src/components/shared/ToastNotification.vue new file mode 100644 index 0000000..01961c5 --- /dev/null +++ b/src/components/shared/ToastNotification.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/src/composables/useChat.js b/src/composables/useChat.js new file mode 100644 index 0000000..f4328eb --- /dev/null +++ b/src/composables/useChat.js @@ -0,0 +1,63 @@ +import { nextTick, ref } from 'vue' +import { initialMessages, prompts } from '../data/requests.js' + +export function useChat(activeView) { + const messages = ref([...initialMessages]) + const draft = ref('') + const uploadedFiles = ref([]) + const messageList = ref(null) + const activeCase = ref(null) + + function agentReply(text) { + const c = activeCase.value + if (!c) return '建议先核对政策阈值和附件完整性,再决定通过、退回补件或转人工复核。' + if (text.includes('审批')) return `${c.id} 建议审批意见:发票验真通过,费用归属与预算中心匹配;${c.risk} 已触发规则提示,建议保留业务说明后通过。` + if (text.includes('补件')) return '补件优先级:业务目的说明、行程或客户名单、直属经理确认记录。' + if (text.includes('拦截')) return `拦截原因是 ${c.risk},该风险需要财务复核并留下制度依据。` + if (text.includes('审计')) return `审计摘要:${c.person} 提交 ${c.amount} 报销,命中 ${c.risk},系统已保留 AI 判断。` + return '建议先核对政策阈值和附件完整性,再决定通过、退回补件或转人工复核。' + } + + function scrollToBottom() { + nextTick(() => messageList.value?.scrollTo({ top: messageList.value.scrollHeight, behavior: 'smooth' })) + } + + function sendMessage() { + const text = draft.value.trim() + if (!text) return false + messages.value.push({ id: Date.now(), role: 'user', text }) + draft.value = '' + setTimeout(() => { + messages.value.push({ id: Date.now() + 1, role: 'agent', text: agentReply(text) }) + scrollToBottom() + }, 260) + return true + } + + function handleUpload(event) { + uploadedFiles.value = Array.from(event.target.files ?? []).map((file) => ({ + name: file.name, + size: file.size + })) + if (uploadedFiles.value.length) { + const names = uploadedFiles.value.map((file) => file.name).join('、') + messages.value.push({ + id: Date.now(), + role: 'agent', + text: `已接收 ${uploadedFiles.value.length} 个附件:${names}。我会优先核对发票验真、费用标准、预算归属和必备审批材料。` + }) + scrollToBottom() + } + } + + function openChat(request) { + activeCase.value = request + activeView.value = 'chat' + nextTick(() => messageList.value?.scrollTo({ top: messageList.value.scrollHeight })) + } + + return { + messages, draft, uploadedFiles, messageList, activeCase, prompts, + sendMessage, handleUpload, openChat, scrollToBottom + } +} diff --git a/src/composables/useNavigation.js b/src/composables/useNavigation.js new file mode 100644 index 0000000..5ffdb75 --- /dev/null +++ b/src/composables/useNavigation.js @@ -0,0 +1,24 @@ +import { computed, ref } from 'vue' +import { icons } from '../data/icons.js' + +export const navItems = [ + { id: 'overview', label: '运营总览', icon: icons.dashboard, title: '企业报销智能运营台', desc: '面向财务共享中心的审批、风控、SLA 与智能体协同工作台。' }, + { id: 'chat', label: '合规对话', icon: icons.message, title: 'AI 合规对话', desc: '上传单据、追问制度依据,并生成可留痕的审核建议。' }, + { id: 'requests', label: '报销队列', icon: icons.list, title: '报销申请队列', desc: '按风险、补件状态和 AI 建议处理待审单据。' }, + { id: 'policies', label: '政策规则', icon: icons.file, title: '政策规则中心', desc: '维护差旅、招待、采购和发票校验规则。' }, + { id: 'audit', label: '审计追踪', icon: icons.audit, title: '审计追踪', desc: '查看关键审批动作、AI 建议和制度命中记录。' } +] + +export function useNavigation() { + const activeView = ref('overview') + + const currentView = computed( + () => navItems.find((item) => item.id === activeView.value) ?? navItems[0] + ) + + function setView(view) { + activeView.value = view + } + + return { activeView, currentView, setView, navItems } +} diff --git a/src/composables/useRequests.js b/src/composables/useRequests.js new file mode 100644 index 0000000..3d583cf --- /dev/null +++ b/src/composables/useRequests.js @@ -0,0 +1,37 @@ +import { computed, reactive, ref } from 'vue' +import { initialRequests } from '../data/requests.js' + +export function useRequests() { + const requests = ref(initialRequests) + const search = ref('') + const filters = reactive({ entity: '全部主体', category: '全部费用', risk: '全部风险' }) + const ranges = ['今日', '本周', '本月'] + const activeRange = ref('今日') + + const filteredRequests = computed(() => { + const key = search.value.trim().toLowerCase() + return requests.value.filter((item) => { + const matchesSearch = !key || `${item.id}${item.person}${item.category}${item.risk}`.toLowerCase().includes(key) + const matchesCategory = filters.category === '全部费用' || item.category.includes(filters.category.replace('交通', '')) + const matchesRisk = filters.risk === '全部风险' || (filters.risk === '高风险' ? item.status === 'danger' : item.verdict.includes(filters.risk.replace('低风险', '通过'))) + return matchesSearch && matchesCategory && matchesRisk + }) + }) + + function approveRequest(request) { + request.verdict = '已通过' + request.status = 'success' + return `${request.id} 已标记为通过,审计日志已更新。` + } + + function rejectRequest(request) { + request.verdict = '已退回补件' + request.status = 'danger' + return `${request.id} 已退回,系统将通知申请人补充材料。` + } + + return { + requests, search, filters, ranges, activeRange, + filteredRequests, approveRequest, rejectRequest + } +} diff --git a/src/composables/useToast.js b/src/composables/useToast.js new file mode 100644 index 0000000..9b853de --- /dev/null +++ b/src/composables/useToast.js @@ -0,0 +1,13 @@ +import { ref } from 'vue' + +export function useToast() { + const toastText = ref('') + + function toast(text) { + toastText.value = text + clearTimeout(toast.timer) + toast.timer = setTimeout(() => { toastText.value = '' }, 3200) + } + + return { toastText, toast } +} diff --git a/src/data/auditTrail.js b/src/data/auditTrail.js new file mode 100644 index 0000000..85d51b7 --- /dev/null +++ b/src/data/auditTrail.js @@ -0,0 +1,5 @@ +export const auditTrail = [ + { time: '09:40', title: '规则 A1 被财务复核放行', note: '保留会议说明并写入审批意见。', badge: '完成', tone: 'success' }, + { time: '09:18', title: '重复发票拦截', note: 'REQ-2026-0416 已转人工核查。', badge: '阻断', tone: 'danger' }, + { time: '08:52', title: '自动补件提醒发送', note: '11 位员工收到业务招待纪要提醒。', badge: '执行中', tone: 'primary' } +] diff --git a/src/data/icons.js b/src/data/icons.js new file mode 100644 index 0000000..2cad30b --- /dev/null +++ b/src/data/icons.js @@ -0,0 +1,11 @@ +const iconPath = (content) => `` + +export const icons = { + dashboard: iconPath(''), + list: iconPath(''), + file: iconPath(''), + audit: iconPath(''), + search: iconPath(''), + check: iconPath(''), + message: iconPath('') +} diff --git a/src/data/metrics.js b/src/data/metrics.js new file mode 100644 index 0000000..64d0cc8 --- /dev/null +++ b/src/data/metrics.js @@ -0,0 +1,20 @@ +export const metrics = [ + { label: '本月报销额', value: '¥1,286,400', delta: '+8.4%', note: '待审批 ¥361,600', color: '#335cff', tone: '' }, + { label: '平均处理周期', value: '18.6h', delta: '-2.1h', note: '业务补件等待下降', color: '#0e9384', tone: '' }, + { label: '超 SLA 单据', value: '37', delta: '需处理', note: '12 单超过 48 小时', color: '#f79009', tone: 'warn' }, + { label: '高风险拦截', value: '16', delta: '+5', note: '重复发票、异常供应商', color: '#d92d20', tone: 'bad' } +] + +export const spendByCategory = [ + { name: '差旅交通', value: '¥390k', width: '92%', color: 'linear-gradient(90deg,#335cff,#6f8cff)' }, + { name: '住宿', value: '¥310k', width: '73%', color: 'linear-gradient(90deg,#0e9384,#56b8aa)' }, + { name: '业务招待', value: '¥240k', width: '57%', color: 'linear-gradient(90deg,#f79009,#ffb64d)' }, + { name: '办公采购', value: '¥180k', width: '42%', color: 'linear-gradient(90deg,#6941c6,#8d68de)' } +] + +export const auditMix = [ + { name: '建议通过', value: '42%', color: '#335cff' }, + { name: '自动通过', value: '26%', color: '#0e9384' }, + { name: '需补件', value: '18%', color: '#f79009' }, + { name: '高风险', value: '14%', color: '#d92d20' } +] diff --git a/src/data/policies.js b/src/data/policies.js new file mode 100644 index 0000000..7494461 --- /dev/null +++ b/src/data/policies.js @@ -0,0 +1,5 @@ +export const policies = [ + { code: 'A1', title: '差旅住宿标准', note: '按城市、职级、会议峰值期动态判断。', badge: '启用', tone: 'success' }, + { code: 'A2', title: '发票查重与验真', note: '票号、税号、金额、抬头四重校验。', badge: '启用', tone: 'success' }, + { code: 'A3', title: '业务招待材料前置', note: '客户名单、拜访纪要、审批单缺一不可。', badge: '建议强化', tone: 'warning' } +] diff --git a/src/data/requests.js b/src/data/requests.js new file mode 100644 index 0000000..d2e38d8 --- /dev/null +++ b/src/data/requests.js @@ -0,0 +1,14 @@ +export const initialRequests = [ + { id: 'REQ-2026-0418', person: '刘倩', dept: '销售 · 华东区域', category: '差旅报销', amount: '¥8,460', verdict: '可通过但需备注', status: 'warning', sla: '51h', risk: '住宿超标 17.4%' }, + { id: 'REQ-2026-0422', person: '韩阳', dept: '解决方案 · 北区', category: '业务招待', amount: '¥1,980', verdict: '等待补件', status: 'warning', sla: '22h', risk: '缺少客户拜访纪要' }, + { id: 'REQ-2026-0431', person: '王鑫', dept: '运营管理 · 总部', category: '通勤交通', amount: '¥1,224', verdict: '规则全通过', status: 'success', sla: '4h', risk: '无明显风险' }, + { id: 'REQ-2026-0436', person: '陈嘉', dept: '市场 · 品牌活动', category: '活动采购', amount: '¥12,680', verdict: '建议人工复核', status: 'danger', sla: '36h', risk: '供应商与历史黑名单相似' } +] + +export const prompts = ['生成审批意见', '列出补件清单', '解释为什么拦截', '生成审计摘要'] + +export const initialMessages = [ + { id: 1, role: 'agent', text: '我已读取单据、发票、行程和公司差旅制度。当前建议:可通过,但需要保留会议说明。' }, + { id: 2, role: 'user', text: '请列出这张单据的主要风险。' }, + { id: 3, role: 'agent', text: '主要风险:住宿单晚均价超标准 17.4%,并且需要人工确认会议附件。' } +] diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..5e536f4 --- /dev/null +++ b/src/main.js @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import { MotionPlugin } from '@vueuse/motion' +import App from './App.vue' + +createApp(App).use(MotionPlugin).mount('#app') diff --git a/src/views/AuditView.vue b/src/views/AuditView.vue new file mode 100644 index 0000000..0f7d603 --- /dev/null +++ b/src/views/AuditView.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/src/views/ChatView.vue b/src/views/ChatView.vue new file mode 100644 index 0000000..1fcc696 --- /dev/null +++ b/src/views/ChatView.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/src/views/OverviewView.vue b/src/views/OverviewView.vue new file mode 100644 index 0000000..bfde078 --- /dev/null +++ b/src/views/OverviewView.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/src/views/PoliciesView.vue b/src/views/PoliciesView.vue new file mode 100644 index 0000000..1ddc7fd --- /dev/null +++ b/src/views/PoliciesView.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/src/views/RequestsView.vue b/src/views/RequestsView.vue new file mode 100644 index 0000000..53d4ca4 --- /dev/null +++ b/src/views/RequestsView.vue @@ -0,0 +1,20 @@ + + + + + diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..bf20d1f --- /dev/null +++ b/start.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================ +# X-Financial Reimbursement Admin - Start Script +# ============================================================ + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } + +# ---------------------------------------------------------- +# Check Node.js +# ---------------------------------------------------------- +if ! command -v node &>/dev/null; then + error "Node.js is not installed. Install it first: https://nodejs.org" +fi + +if ! command -v npm &>/dev/null; then + error "npm is not installed. It should come with Node.js." +fi + +info "Node.js $(node -v) | npm $(npm -v)" + +# ---------------------------------------------------------- +# Detect WSL + Windows node_modules platform mismatch +# ---------------------------------------------------------- +is_wsl() { + grep -qi microsoft /proc/version 2>/dev/null +} + +check_platform_mismatch() { + local rollup_dir="node_modules/@rollup" + if [ ! -d "$rollup_dir" ]; then + return 1 + fi + # List installed rollup platform packages + local platforms + platforms="$(ls -1 "$rollup_dir" 2>/dev/null | grep -E '^rollup-(win|linux)')" + if [ -z "$platforms" ]; then + return 1 + fi + # Running on WSL/Linux but has Windows rollup bindings + if echo "$platforms" | grep -q "win32"; then + return 0 + fi + return 1 +} + +# ---------------------------------------------------------- +# Install dependencies if node_modules is missing or mismatched +# ---------------------------------------------------------- +NEED_INSTALL=false + +if [ ! -d "node_modules" ]; then + warn "node_modules not found" + NEED_INSTALL=true +elif is_wsl && check_platform_mismatch; then + warn "Detected WSL with Windows node_modules (rollup platform mismatch)" + warn "Removing node_modules to reinstall with correct platform bindings..." + # WSL can't delete locked Windows .exe/.node files, use PowerShell instead + WIN_PATH="$(wslpath -w "$SCRIPT_DIR")" + if command -v powershell.exe &>/dev/null; then + powershell.exe -NoProfile -Command "Remove-Item -Recurse -Force '${WIN_PATH}\\node_modules','${WIN_PATH}\\package-lock.json'" 2>/dev/null || true + elif command -v cmd.exe &>/dev/null; then + cmd.exe /c "rd /s /q \"${WIN_PATH}\\node_modules\"" 2>/dev/null || true + cmd.exe /c "del /f /q \"${WIN_PATH}\\package-lock.json\"" 2>/dev/null || true + else + rm -rf node_modules package-lock.json 2>/dev/null || true + fi + # Fallback: clean up anything remaining via WSL + rm -rf node_modules package-lock.json 2>/dev/null || true + NEED_INSTALL=true +fi + +if [ "$NEED_INSTALL" = true ]; then + info "Running npm install..." + npm install +fi + +# ---------------------------------------------------------- +# Start dev server +# ---------------------------------------------------------- +info "Starting X-Financial Reimbursement Admin..." +info "Access: http://127.0.0.1:5173" +echo "" + +exec npm start diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..2e3d257 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()] +})