803 lines
26 KiB
Markdown
803 lines
26 KiB
Markdown
|
|
# 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` 已创建,配置项说明完整
|