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
This commit is contained in:
117
docs/plans/00-overview.md
Normal file
117
docs/plans/00-overview.md
Normal file
@@ -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 条核心预审规则
|
||||
- [ ] 预审结果以可视化方式展示(风险等级、命中规则、修改建议)
|
||||
- [ ] 用户能补件并重新预审
|
||||
- [ ] 用户确认后模拟同步成功
|
||||
- [ ] 影子报销账本完整记录业务数据
|
||||
- [ ] 审计日志记录所有关键操作
|
||||
- [ ] 完整流程端到端测试通过
|
||||
802
docs/plans/phase-1-project-infra/README.md
Normal file
802
docs/plans/phase-1-project-infra/README.md
Normal file
@@ -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` 已创建,配置项说明完整
|
||||
834
docs/plans/phase-2-backend-core/README.md
Normal file
834
docs/plans/phase-2-backend-core/README.md
Normal file
@@ -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` 全部通过
|
||||
568
docs/plans/phase-3-agent-orchestration/README.md
Normal file
568
docs/plans/phase-3-agent-orchestration/README.md
Normal file
@@ -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` 全部通过
|
||||
500
docs/plans/phase-4-frontend-pages/README.md
Normal file
500
docs/plans/phase-4-frontend-pages/README.md
Normal file
@@ -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<any>(null)
|
||||
const taskList = ref<any[]>([])
|
||||
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` 无报错
|
||||
259
docs/plans/phase-5-integration/README.md
Normal file
259
docs/plans/phase-5-integration/README.md
Normal file
@@ -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 可访问
|
||||
- [ ] 审计日志记录了完整操作链路
|
||||
553
docs/plans/phase-6-testing-polish/README.md
Normal file
553
docs/plans/phase-6-testing-polish/README.md
Normal file
@@ -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 数据展示正常
|
||||
Reference in New Issue
Block a user