- 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
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` 已创建,配置项说明完整
|