diff --git a/.env.example b/.env.example index a5a890f..b911344 100644 --- a/.env.example +++ b/.env.example @@ -23,7 +23,9 @@ SERVER_PORT=8000 VITE_SERVER_HOST=127.0.0.1 VITE_SERVER_PORT=8000 SERVER_STARTUP_TIMEOUT=300 +SERVER_BLOCKING_STARTUP_TIMEOUT=12 VITE_API_BASE_URL=http://127.0.0.1:8000/api/v1 +VITE_AUTH_IDLE_TIMEOUT_MINUTES=30 POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5432 diff --git a/docs/plans/00-overview.md b/docs/plans/00-overview.md deleted file mode 100644 index 622d1a3..0000000 --- a/docs/plans/00-overview.md +++ /dev/null @@ -1,117 +0,0 @@ -# AI 报销预审中台 MVP — 总览 - -> **版本:** v1.0 -> **周期:** 8 周(W1 ~ W8) -> **团队:** 3-5 人 -> **目标:** 跑通「上传材料 → OCR 识别 → 草稿生成 → 规则预审 → 补件交互 → 用户确认 → 模拟同步」完整闭环,优先支持差旅报销场景。 - ---- - -## 技术栈 - -| 层 | 技术 | -|---|---| -| 前端 | Vue 3 + TypeScript + Ant Design Vue + Vite + Pinia | -| 后端 | Python 3.11+ / FastAPI + SQLAlchemy + Alembic + Pydantic v2 | -| 数据库 | PostgreSQL 15 + Redis 7 | -| 文件存储 | MinIO(S3 兼容) | -| OCR | 百度云 OCR API + Mock Provider | -| 规则引擎 | 自研 JSON Rule Engine | -| Agent | 自研 Orchestrator 状态机 + 大模型 API | -| 部署 | Docker Compose | - ---- - -## 团队分工建议 - -| 角色 | 人数 | 职责 | -|---|---|---| -| 后端工程师 A | 1 | 核心后端:任务管理、影子账本、Agent 编排、规则引擎 | -| 后端工程师 B | 1 | OCR 集成、文件服务、适配器层、审计日志 | -| 前端工程师 | 1-2 | 所有页面与组件(可拆分为两人并行) | -| 全栈/Agent 工程师 | 1 | Agent Prompt 设计、大模型集成、规则配置 | - ---- - -## 阶段总览 - -| 阶段 | 周数 | 任务数 | 文档 | 可并行度 | -|---|---|---|---|---| -| Phase 1: 项目基建 | W1 | 4 | [phase-1-project-infra/README.md](phase-1-project-infra/README.md) | 高(前端+后端+Docker并行) | -| Phase 2: 后端核心服务 | W2-W3 | 6 | [phase-2-backend-core/README.md](phase-2-backend-core/README.md) | 高(任务API+文件上传+OCR并行) | -| Phase 3: Agent 编排 | W3-W4 | 4 | [phase-3-agent-orchestration/README.md](phase-3-agent-orchestration/README.md) | 中(Orchestrator先行,Agents并行) | -| Phase 4: 前端核心页面 | W4-W5 | 4 | [phase-4-frontend-pages/README.md](phase-4-frontend-pages/README.md) | 高(页面间独立并行) | -| Phase 5: 联调与集成 | W5-W6 | 2 | [phase-5-integration/README.md](phase-5-integration/README.md) | 中 | -| Phase 6: 测试与打磨 | W7-W8 | 4 | [phase-6-testing-polish/README.md](phase-6-testing-polish/README.md) | 中 | -| **总计** | **8 周** | **24 个任务** | | | - ---- - -## 里程碑时间线 - -``` -W1 W2 W3 W4 W5 W6 W7 W8 -| | | | | | | | -├─Phase 1──┤ | | | | | | -| 基建 | | | | | | | -| ├────────Phase 2──────┤ | | | | -| | 后端核心 API | | | | | -| | ├────────Phase 3──────┤ | | | -| | | Agent 编排 | | | | -| | | ├────────Phase 4──────┤ | | -| | | | 前端页面 | | | -| | | | ├────Phase 5────┤ | | -| | | | | 联调集成 | | | -| | | | | | ├─────Phase 6─────┤ -| | | | | | | 测试打磨 | -``` - ---- - -## 阶段依赖关系 - -``` -Phase 1 (基建) - ↓ -Phase 2 (后端核心) ←── 可与 Phase 3 部分重叠 - ↓ -Phase 3 (Agent 编排) - ↓ -Phase 4 (前端页面) ←── 可与 Phase 3 后半段并行 - ↓ -Phase 5 (联调集成) - ↓ -Phase 6 (测试打磨) -``` - -**关键路径:** Phase 1 → Phase 2 → Phase 3 → Phase 5 → Phase 6 -**可并行路径:** Phase 4 可在 Phase 3 后半段提前开始 - ---- - -## 风险与缓解 - -| 风险 | 影响 | 缓解措施 | -|---|---|---| -| OCR 识别准确率不够 | 草稿数据错误 | 允许用户手动修改,低置信度高亮提示 | -| LLM 响应慢或幻觉 | 用户体验差 | Prompt 严格约束输出格式,超时 fallback | -| 规则引擎复杂度超预期 | 延期 | MVP 先做 6 条硬编码规则,JSON 配置化后续迭代 | -| 前后端联调问题多 | 延期 | W5 提前开始联调,边开发边对齐 API | -| 3-5 人不够 | 交付延期 | 优先砍规则管理页和审计页(W8 补) | - ---- - -## 验收标准 - -MVP 完成的标志: - -- [ ] 用户能通过 Web 界面创建差旅报销任务 -- [ ] 上传 3 种以上票据类型(增值税发票、火车票、酒店流水) -- [ ] OCR 自动识别票据信息并生成报销草稿 -- [ ] 规则引擎执行 6 条核心预审规则 -- [ ] 预审结果以可视化方式展示(风险等级、命中规则、修改建议) -- [ ] 用户能补件并重新预审 -- [ ] 用户确认后模拟同步成功 -- [ ] 影子报销账本完整记录业务数据 -- [ ] 审计日志记录所有关键操作 -- [ ] 完整流程端到端测试通过 diff --git a/docs/plans/phase-1-project-infra/README.md b/docs/plans/phase-1-project-infra/README.md deleted file mode 100644 index ba44d8d..0000000 --- a/docs/plans/phase-1-project-infra/README.md +++ /dev/null @@ -1,802 +0,0 @@ -# Phase 1: 项目基建(W1) - -> **目标:** 搭建前后端项目骨架、定义数据库模型、配置开发环境,确保团队可以立即开始业务开发。 -> **周期:** 第 1 周 -> **任务数:** 4 个 -> **可并行:** 后端骨架 / 前端骨架 / Docker Compose 可完全并行 -> **前置依赖:** 无 - ---- - -## 本阶段交付物 - -| 交付物 | 说明 | -|---|---| -| 后端项目骨架 | FastAPI + SQLAlchemy + Alembic,可运行的健康检查 | -| 数据库 Schema | 全部 12 张表的 ORM 模型 + Alembic 迁移 | -| 前端项目骨架 | Vue3 + TS + Ant Design Vue,路由和 API 层配置 | -| 开发环境 | Docker Compose(PostgreSQL + Redis + MinIO) | - ---- - -## 任务清单 - -### Task 1.1: 后端项目骨架搭建 - -**负责人:** 后端工程师 A -**预计工时:** 1 天 - -**Files:** -- Create: `backend/app/__init__.py` -- Create: `backend/app/main.py` -- Create: `backend/app/core/config.py` -- Create: `backend/app/core/database.py` -- Create: `backend/app/core/dependencies.py` -- Create: `backend/app/api/__init__.py` -- Create: `backend/app/api/v1/__init__.py` -- Create: `backend/app/api/v1/router.py` -- Create: `backend/app/models/__init__.py` -- Create: `backend/app/schemas/__init__.py` -- Create: `backend/app/services/__init__.py` -- Create: `backend/requirements.txt` -- Create: `backend/pyproject.toml` -- Create: `backend/Dockerfile` -- Create: `backend/alembic.ini` -- Create: `backend/alembic/env.py` -- Test: `backend/tests/__init__.py` -- Test: `backend/tests/conftest.py` -- Test: `backend/tests/test_health.py` - -- [ ] **Step 1: 初始化后端项目结构** - -创建 FastAPI 项目骨架,使用以下目录结构: - -``` -backend/ -├── app/ -│ ├── __init__.py -│ ├── main.py # FastAPI 应用入口 -│ ├── core/ -│ │ ├── config.py # Settings(Pydantic BaseSettings) -│ │ ├── database.py # SQLAlchemy async engine + session -│ │ └── dependencies.py # 通用依赖注入(db session, 当前用户等) -│ ├── api/ -│ │ └── v1/ -│ │ ├── __init__.py -│ │ ├── router.py # v1 路由聚合 -│ │ ├── tasks.py # 报销任务 API -│ │ ├── documents.py # 票据附件 API -│ │ ├── precheck.py # 预审结果 API -│ │ └── supplements.py # 补件 API -│ ├── models/ # SQLAlchemy ORM models -│ │ ├── __init__.py -│ │ ├── task.py -│ │ ├── reimbursement.py -│ │ ├── document.py -│ │ ├── rule.py -│ │ └── audit.py -│ ├── schemas/ # Pydantic schemas -│ │ ├── __init__.py -│ │ ├── task.py -│ │ ├── reimbursement.py -│ │ ├── document.py -│ │ └── rule.py -│ ├── services/ # 业务逻辑层 -│ │ ├── __init__.py -│ │ ├── task_service.py -│ │ ├── document_service.py -│ │ ├── ocr_service.py -│ │ ├── rule_engine.py -│ │ └── sync_service.py -│ └── agents/ # Agent 编排层 -│ ├── __init__.py -│ ├── orchestrator.py -│ ├── intake_agent.py -│ ├── parse_agent.py -│ ├── rule_check_agent.py -│ ├── explain_agent.py -│ └── sync_agent.py -├── alembic/ -│ ├── env.py -│ └── versions/ -├── alembic.ini -├── requirements.txt -├── pyproject.toml -├── Dockerfile -└── tests/ - ├── __init__.py - ├── conftest.py - └── test_health.py -``` - -- [ ] **Step 2: 编写核心配置文件** - -`backend/app/core/config.py` 使用 Pydantic BaseSettings: - -```python -from pydantic_settings import BaseSettings - -class Settings(BaseSettings): - # App - APP_NAME: str = "AI Reimbursement Agent" - APP_VERSION: str = "0.1.0" - DEBUG: bool = True - - # Database - DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/x_financial" - - # Redis - REDIS_URL: str = "redis://localhost:6379/0" - - # MinIO / S3 - MINIO_ENDPOINT: str = "localhost:9000" - MINIO_ACCESS_KEY: str = "minioadmin" - MINIO_SECRET_KEY: str = "minioadmin" - MINIO_BUCKET: str = "reimbursement" - - # OCR - OCR_PROVIDER: str = "baidu" # baidu | tencent | mock - BAIDU_OCR_API_KEY: str = "" - BAIDU_OCR_SECRET_KEY: str = "" - - # LLM - LLM_PROVIDER: str = "openai" - LLM_API_KEY: str = "" - LLM_MODEL: str = "gpt-4o-mini" - LLM_BASE_URL: str = "" - - class Config: - env_file = ".env" - -settings = Settings() -``` - -- [ ] **Step 3: 编写数据库连接和 FastAPI 入口** - -`backend/app/core/database.py`: - -```python -from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession -from sqlalchemy.orm import DeclarativeBase -from app.core.config import settings - -engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG) -async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - -class Base(DeclarativeBase): - pass - -async def get_db() -> AsyncSession: - async with async_session() as session: - yield session -``` - -`backend/app/main.py`: - -```python -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from app.core.config import settings -from app.api.v1.router import api_router - -app = FastAPI(title=settings.APP_NAME, version=settings.APP_VERSION) - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -app.include_router(api_router, prefix="/api/v1") - -@app.get("/health") -async def health(): - return {"status": "ok", "version": settings.APP_VERSION} -``` - -- [ ] **Step 4: 编写 requirements.txt** - -``` -fastapi==0.115.0 -uvicorn[standard]==0.30.0 -sqlalchemy[asyncio]==2.0.35 -asyncpg==0.30.0 -alembic==1.13.0 -pydantic==2.9.0 -pydantic-settings==2.5.0 -python-multipart==0.0.9 -httpx==0.27.0 -redis==5.1.0 -minio==7.2.9 -python-jose[cryptography]==3.3.0 -passlib[bcrypt]==1.7.4 -pytest==8.3.0 -pytest-asyncio==0.24.0 -``` - -- [ ] **Step 5: 编写健康检查测试** - -`backend/tests/conftest.py`: - -```python -import pytest -from httpx import AsyncClient, ASGITransport -from app.main import app - -@pytest.fixture -async def client(): - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://test") as ac: - yield ac -``` - -`backend/tests/test_health.py`: - -```python -import pytest - -@pytest.mark.asyncio -async def test_health(client): - response = await client.get("/health") - assert response.status_code == 200 - data = response.json() - assert data["status"] == "ok" -``` - -- [ ] **Step 6: 运行测试确认骨架可用** - -Run: `cd backend && pip install -r requirements.txt && pytest tests/test_health.py -v` -Expected: PASS - -- [ ] **Step 7: Commit** - -```bash -git add backend/ -git commit -m "feat: 初始化后端项目骨架(FastAPI + SQLAlchemy + Alembic)" -``` - ---- - -### Task 1.2: 数据库 Schema + 迁移 - -**负责人:** 后端工程师 A -**预计工时:** 1.5 天 -**前置依赖:** Task 1.1 - -**Files:** -- Create: `backend/app/models/base.py` -- Create: `backend/app/models/task.py` -- Create: `backend/app/models/reimbursement.py` -- Create: `backend/app/models/document.py` -- Create: `backend/app/models/rule.py` -- Create: `backend/app/models/audit.py` -- Create: `backend/app/models/enums.py` -- Modify: `backend/app/models/__init__.py` -- Test: `backend/tests/test_models.py` - -- [ ] **Step 1: 定义枚举类型** - -`backend/app/models/enums.py`: - -```python -import enum - -class TaskStatus(str, enum.Enum): - CREATED = "created" - MATERIAL_COLLECTING = "material_collecting" - PARSING = "parsing" - DRAFT_GENERATED = "draft_generated" - PRECHECKING = "prechecking" - NEED_SUPPLEMENT = "need_supplement" - PENDING_USER_CONFIRM = "pending_user_confirm" - SUBMITTING = "submitting" - SYNCED = "synced" - SYNC_FAILED = "sync_failed" - CLOSED = "closed" - -class ExpenseType(str, enum.Enum): - TRAVEL_TRANSPORT = "travel_transport" - TRAVEL_HOTEL = "travel_hotel" - TRAVEL_MEAL = "travel_meal" - LOCAL_TRANSPORT = "local_transport" - BUSINESS_MEAL = "business_meal" - OFFICE_SUPPLY = "office_supply" - COMMUNICATION = "communication" - OTHER = "other" - -class DocumentType(str, enum.Enum): - VAT_INVOICE = "vat_invoice" - TRAIN_TICKET = "train_ticket" - FLIGHT_ITINERARY = "flight_itinerary" - TAXI_RECEIPT = "taxi_receipt" - HOTEL_BILL = "hotel_bill" - PAYMENT_SCREENSHOT = "payment_screenshot" - TRAVEL_ORDER = "travel_order" - OTHER_ATTACHMENT = "other_attachment" - -class RiskLevel(str, enum.Enum): - LOW = "low" - MEDIUM = "medium" - HIGH = "high" - BLOCKED = "blocked" - -class RuleAction(str, enum.Enum): - PASS = "pass" - WARN = "warn" - REQUIRE_EXPLANATION = "require_explanation" - REQUIRE_ATTACHMENT = "require_attachment" - REQUIRE_APPROVAL = "require_approval" - BLOCK = "block" - -class SyncStatus(str, enum.Enum): - SUCCESS = "success" - FAILED = "failed" - RETRYING = "retrying" - PENDING = "pending" -``` - -- [ ] **Step 2: 定义 Base Mixin** - -`backend/app/models/base.py`: - -```python -import uuid -from datetime import datetime -from sqlalchemy import String, DateTime, func -from sqlalchemy.orm import Mapped, mapped_column - -def generate_id(): - return str(uuid.uuid4()) - -class TimestampMixin: - created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) - -class IDMixin: - id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_id) -``` - -- [ ] **Step 3: 定义 ReimbursementTask 模型** - -`backend/app/models/task.py`: - -```python -from sqlalchemy import String, Text -from sqlalchemy.orm import Mapped, mapped_column, relationship -from app.models.base import IDMixin, TimestampMixin, Base -from app.models.enums import TaskStatus - -class ReimbursementTask(IDMixin, TimestampMixin, Base): - __tablename__ = "reimbursement_task" - - user_id: Mapped[str] = mapped_column(String(36), index=True) - company_id: Mapped[str] = mapped_column(String(36), index=True) - task_type: Mapped[str] = mapped_column(String(50), default="travel_expense") - status: Mapped[TaskStatus] = mapped_column(default=TaskStatus.CREATED) - user_intent: Mapped[str | None] = mapped_column(Text, nullable=True) - current_agent: Mapped[str | None] = mapped_column(String(50), nullable=True) - - documents = relationship("ExpenseDocument", back_populates="task", lazy="selectin") - reimbursement = relationship("ShadowReimbursement", back_populates="task", uselist=False, lazy="selectin") -``` - -- [ ] **Step 4: 定义 ShadowReimbursement + ReimbursementItem + SupplementRequest + SyncRecord** - -`backend/app/models/reimbursement.py` — 字段按开发文档 5.2.2 ~ 5.2.4, 5.2.7, 5.2.8 节: - -```python -from sqlalchemy import String, Text, Numeric, Date, ForeignKey, Enum as SAEnum -from sqlalchemy.orm import Mapped, mapped_column, relationship -from decimal import Decimal -from datetime import date -from app.models.base import IDMixin, TimestampMixin, Base -from app.models.enums import RiskLevel, SyncStatus - -class ShadowReimbursement(IDMixin, TimestampMixin, Base): - __tablename__ = "shadow_reimbursement" - - task_id: Mapped[str] = mapped_column(String(36), ForeignKey("reimbursement_task.id"), unique=True) - applicant_id: Mapped[str] = mapped_column(String(36)) - department_id: Mapped[str | None] = mapped_column(String(36), nullable=True) - cost_center_id: Mapped[str | None] = mapped_column(String(36), nullable=True) - project_id: Mapped[str | None] = mapped_column(String(36), nullable=True) - reimbursement_type: Mapped[str] = mapped_column(String(50)) - reason: Mapped[str | None] = mapped_column(Text, nullable=True) - total_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=Decimal("0")) - currency: Mapped[str] = mapped_column(String(10), default="CNY") - risk_level: Mapped[str | None] = mapped_column(String(20), nullable=True) - precheck_status: Mapped[str | None] = mapped_column(String(30), nullable=True) - backend_system: Mapped[str | None] = mapped_column(String(50), nullable=True) - backend_bill_id: Mapped[str | None] = mapped_column(String(50), nullable=True) - sync_status: Mapped[str | None] = mapped_column(String(20), nullable=True) - - task = relationship("ReimbursementTask", back_populates="reimbursement") - items = relationship("ReimbursementItem", back_populates="reimbursement", lazy="selectin") - -class ReimbursementItem(IDMixin, TimestampMixin, Base): - __tablename__ = "reimbursement_item" - - reimbursement_id: Mapped[str] = mapped_column(String(36), ForeignKey("shadow_reimbursement.id")) - expense_type: Mapped[str] = mapped_column(String(50)) - amount: Mapped[Decimal] = mapped_column(Numeric(12, 2)) - tax_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) - occurred_at: Mapped[date | None] = mapped_column(Date, nullable=True) - city: Mapped[str | None] = mapped_column(String(50), nullable=True) - vendor_name: Mapped[str | None] = mapped_column(String(100), nullable=True) - invoice_id: Mapped[str | None] = mapped_column(String(36), nullable=True) - policy_status: Mapped[str | None] = mapped_column(String(20), nullable=True) - risk_level: Mapped[str | None] = mapped_column(String(20), nullable=True) - remark: Mapped[str | None] = mapped_column(Text, nullable=True) - - reimbursement = relationship("ShadowReimbursement", back_populates="items") - -class SupplementRequest(IDMixin, TimestampMixin, Base): - __tablename__ = "supplement_request" - - reimbursement_id: Mapped[str] = mapped_column(String(36), ForeignKey("shadow_reimbursement.id")) - request_type: Mapped[str] = mapped_column(String(30)) # attachment / explanation / field_modify - target_item_id: Mapped[str | None] = mapped_column(String(36), nullable=True) - message: Mapped[str] = mapped_column(Text) - status: Mapped[str] = mapped_column(String(20), default="pending") # pending / resolved / closed - user_response: Mapped[str | None] = mapped_column(Text, nullable=True) - resolved_at: Mapped[date | None] = mapped_column(Date, nullable=True) - -class SyncRecord(IDMixin, TimestampMixin, Base): - __tablename__ = "sync_record" - - reimbursement_id: Mapped[str] = mapped_column(String(36), ForeignKey("shadow_reimbursement.id")) - target_system: Mapped[str] = mapped_column(String(50)) - request_payload: Mapped[dict | None] = mapped_column(Text, nullable=True) # JSON string - response_payload: Mapped[dict | None] = mapped_column(Text, nullable=True) # JSON string - sync_status: Mapped[str] = mapped_column(String(20), default="pending") - backend_bill_id: Mapped[str | None] = mapped_column(String(50), nullable=True) - error_message: Mapped[str | None] = mapped_column(Text, nullable=True) -``` - -- [ ] **Step 5: 定义 ExpenseDocument 模型** - -`backend/app/models/document.py` — 字段按开发文档 5.2.4 节: - -```python -from sqlalchemy import String, Text, Numeric, Date, ForeignKey -from sqlalchemy.orm import Mapped, mapped_column, relationship -from decimal import Decimal -from datetime import date -from app.models.base import IDMixin, TimestampMixin, Base - -class ExpenseDocument(IDMixin, TimestampMixin, Base): - __tablename__ = "expense_document" - - reimbursement_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("shadow_reimbursement.id"), nullable=True) - item_id: Mapped[str | None] = mapped_column(String(36), nullable=True) - task_id: Mapped[str] = mapped_column(String(36), ForeignKey("reimbursement_task.id")) - document_type: Mapped[str] = mapped_column(String(30)) - file_url: Mapped[str] = mapped_column(String(500)) - ocr_status: Mapped[str] = mapped_column(String(20), default="pending") # pending / processing / done / failed - extracted_json: Mapped[dict | None] = mapped_column(Text, nullable=True) - invoice_code: Mapped[str | None] = mapped_column(String(30), nullable=True) - invoice_number: Mapped[str | None] = mapped_column(String(30), nullable=True) - invoice_date: Mapped[date | None] = mapped_column(Date, nullable=True) - amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) - tax_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) - seller_name: Mapped[str | None] = mapped_column(String(200), nullable=True) - buyer_name: Mapped[str | None] = mapped_column(String(200), nullable=True) - verify_status: Mapped[str | None] = mapped_column(String(20), nullable=True) - duplicate_status: Mapped[str | None] = mapped_column(String(20), nullable=True) - - task = relationship("ReimbursementTask", back_populates="documents") -``` - -- [ ] **Step 6: 定义 ExpenseRule + RuleHit 模型** - -`backend/app/models/rule.py` — 字段按开发文档 5.2.5, 5.2.6 节: - -```python -from sqlalchemy import String, Text, Boolean -from sqlalchemy.orm import Mapped, mapped_column -from app.models.base import IDMixin, TimestampMixin, Base - -class ExpenseRule(IDMixin, TimestampMixin, Base): - __tablename__ = "expense_rule" - - rule_code: Mapped[str] = mapped_column(String(50), unique=True) - rule_name: Mapped[str] = mapped_column(String(100)) - expense_type: Mapped[str] = mapped_column(String(50)) - condition_json: Mapped[dict] = mapped_column(Text) # JSON string - action: Mapped[str] = mapped_column(String(30)) - severity: Mapped[str] = mapped_column(String(20)) - message_template: Mapped[str] = mapped_column(Text) - policy_ref: Mapped[str | None] = mapped_column(String(200), nullable=True) - enabled: Mapped[bool] = mapped_column(Boolean, default=True) - version: Mapped[str] = mapped_column(String(10), default="1.0") - -class RuleHit(IDMixin, TimestampMixin, Base): - __tablename__ = "rule_hit" - - reimbursement_id: Mapped[str] = mapped_column(String(36)) - item_id: Mapped[str | None] = mapped_column(String(36), nullable=True) - rule_id: Mapped[str] = mapped_column(String(36)) - severity: Mapped[str] = mapped_column(String(20)) - hit_result: Mapped[dict | None] = mapped_column(Text, nullable=True) # JSON string - explanation: Mapped[str | None] = mapped_column(Text, nullable=True) - suggestion: Mapped[str | None] = mapped_column(Text, nullable=True) -``` - -- [ ] **Step 7: 定义 AuditLog 模型** - -`backend/app/models/audit.py`: - -```python -from sqlalchemy import String, Text -from sqlalchemy.orm import Mapped, mapped_column -from app.models.base import IDMixin, TimestampMixin, Base - -class AuditLog(IDMixin, TimestampMixin, Base): - __tablename__ = "audit_log" - - action: Mapped[str] = mapped_column(String(50), index=True) # upload / ocr / agent / rule_hit / supplement / confirm / sync - actor: Mapped[str] = mapped_column(String(36), index=True) - target_type: Mapped[str] = mapped_column(String(50)) # task / document / reimbursement / rule - target_id: Mapped[str] = mapped_column(String(36)) - detail: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON string -``` - -- [ ] **Step 8: 更新 `models/__init__.py` 导出所有模型** - -```python -from app.models.task import ReimbursementTask -from app.models.reimbursement import ShadowReimbursement, ReimbursementItem, SupplementRequest, SyncRecord -from app.models.document import ExpenseDocument -from app.models.rule import ExpenseRule, RuleHit -from app.models.audit import AuditLog -from app.models.base import Base - -__all__ = [ - "ReimbursementTask", "ShadowReimbursement", "ReimbursementItem", - "ExpenseDocument", "ExpenseRule", "RuleHit", - "SupplementRequest", "SyncRecord", "AuditLog", "Base", -] -``` - -- [ ] **Step 9: 生成 Alembic 迁移** - -Run: `cd backend && alembic revision --autogenerate -m "init schema"` -Run: `cd backend && alembic upgrade head` - -- [ ] **Step 10: 编写模型测试** - -`backend/tests/test_models.py` — 验证所有表能正确创建和插入数据。 - -- [ ] **Step 11: Commit** - -```bash -git add backend/ -git commit -m "feat: 完成所有数据模型定义和数据库迁移" -``` - ---- - -### Task 1.3: 前端项目骨架搭建 - -**负责人:** 前端工程师 -**预计工时:** 1 天 -**前置依赖:** 无(可与 Task 1.1 并行) - -**Files:** -- Create: Vue3 + TypeScript 项目(Vite 初始化) -- Create: `frontend/src/router/index.ts` -- Create: `frontend/src/stores/` -- Create: `frontend/src/api/` -- Create: `frontend/src/views/` -- Create: `frontend/src/components/` -- Create: `frontend/src/layouts/` - -- [ ] **Step 1: 初始化 Vue3 项目** - -```bash -npm create vite@latest frontend -- --template vue-ts -cd frontend -npm install ant-design-vue @ant-design/icons-vue vue-router pinia axios dayjs -``` - -目录结构: - -``` -frontend/ -├── src/ -│ ├── api/ # API 调用 -│ │ ├── index.ts # axios 实例 -│ │ ├── task.ts # 报销任务 API -│ │ ├── document.ts # 票据 API -│ │ └── precheck.ts # 预审 API -│ ├── components/ # 通用组件 -│ │ ├── FileUpload.vue -│ │ ├── ExpenseTable.vue -│ │ └── RuleHitCard.vue -│ ├── layouts/ -│ │ └── MainLayout.vue -│ ├── router/ -│ │ └── index.ts -│ ├── stores/ -│ │ ├── task.ts -│ │ └── user.ts -│ ├── views/ -│ │ ├── HomeView.vue # 报销入口 -│ │ ├── UploadView.vue # 票据上传 -│ │ ├── DraftView.vue # 报销草稿 -│ │ ├── PrecheckView.vue # 预审结果 -│ │ ├── SupplementView.vue # 补件交互 -│ │ ├── ConfirmView.vue # 提交确认 -│ │ └── AuditView.vue # 审计日志 -│ ├── App.vue -│ └── main.ts -├── index.html -├── vite.config.ts -├── tsconfig.json -└── package.json -``` - -- [ ] **Step 2: 配置路由和布局** - -`frontend/src/router/index.ts`: - -```typescript -import { createRouter, createWebHistory } from 'vue-router' - -const routes = [ - { path: '/', name: 'home', component: () => import('@/views/HomeView.vue') }, - { path: '/task/:taskId/upload', name: 'upload', component: () => import('@/views/UploadView.vue') }, - { path: '/task/:taskId/draft', name: 'draft', component: () => import('@/views/DraftView.vue') }, - { path: '/task/:taskId/precheck', name: 'precheck', component: () => import('@/views/PrecheckView.vue') }, - { path: '/task/:taskId/supplement', name: 'supplement', component: () => import('@/views/SupplementView.vue') }, - { path: '/task/:taskId/confirm', name: 'confirm', component: () => import('@/views/ConfirmView.vue') }, - { path: '/audit', name: 'audit', component: () => import('@/views/AuditView.vue') }, -] - -export default createRouter({ history: createWebHistory(), routes }) -``` - -- [ ] **Step 3: 配置 API 封装** - -`frontend/src/api/index.ts`: - -```typescript -import axios from 'axios' - -const api = axios.create({ - baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1', - timeout: 30000, -}) - -export default api -``` - -- [ ] **Step 4: 确认前端能正常启动** - -Run: `cd frontend && npm run dev` -Expected: 浏览器访问 http://localhost:5173 能看到页面 - -- [ ] **Step 5: Commit** - -```bash -git add frontend/ -git commit -m "feat: 初始化前端项目骨架(Vue3 + TypeScript + Ant Design Vue)" -``` - ---- - -### Task 1.4: Docker Compose 开发环境 - -**负责人:** 后端工程师 B -**预计工时:** 0.5 天 -**前置依赖:** 无(可与 Task 1.1、1.3 并行) - -**Files:** -- Create: `docker-compose.yml` -- Create: `backend/Dockerfile` -- Create: `frontend/Dockerfile` -- Create: `.env.example` - -- [ ] **Step 1: 编写 docker-compose.yml** - -```yaml -version: "3.8" -services: - postgres: - image: postgres:15 - environment: - POSTGRES_DB: x_financial - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - ports: - - "5432:5432" - volumes: - - pgdata:/var/lib/postgresql/data - - redis: - image: redis:7-alpine - ports: - - "6379:6379" - - minio: - image: minio/minio - command: server /data --console-address ":9001" - environment: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin - ports: - - "9000:9000" - - "9001:9001" - volumes: - - minio_data:/data - -volumes: - pgdata: - minio_data: -``` - -- [ ] **Step 2: 编写 .env.example** - -```env -# 数据库 -DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/x_financial - -# Redis -REDIS_URL=redis://localhost:6379/0 - -# MinIO -MINIO_ENDPOINT=localhost:9000 -MINIO_ACCESS_KEY=minioadmin -MINIO_SECRET_KEY=minioadmin -MINIO_BUCKET=reimbursement - -# OCR(必填:正式环境;开发环境可用 mock) -OCR_PROVIDER=mock -BAIDU_OCR_API_KEY= -BAIDU_OCR_SECRET_KEY= - -# LLM(必填:正式环境) -LLM_PROVIDER=openai -LLM_API_KEY= -LLM_MODEL=gpt-4o-mini -LLM_BASE_URL= - -# 前端 -VITE_API_BASE_URL=http://localhost:8000/api/v1 -``` - -- [ ] **Step 3: 编写后端 Dockerfile** - -```dockerfile -FROM python:3.11-slim -WORKDIR /app -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt -COPY . . -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] -``` - -- [ ] **Step 4: 验证环境启动** - -Run: `docker-compose up -d` -Run: `docker-compose ps` -Expected: postgres, redis, minio 均为 running - -- [ ] **Step 5: Commit** - -```bash -git add docker-compose.yml .env.example backend/Dockerfile frontend/Dockerfile -git commit -m "feat: 添加 Docker Compose 开发环境(PostgreSQL + Redis + MinIO)" -``` - ---- - -## 本阶段完成检查 - -- [ ] `cd backend && pytest tests/test_health.py -v` 通过 -- [ ] `cd backend && alembic upgrade head` 无报错,所有表已创建 -- [ ] `cd frontend && npm run dev` 能正常启动 -- [ ] `docker-compose up -d` 三个服务均 running -- [ ] `.env.example` 已创建,配置项说明完整 diff --git a/docs/plans/phase-2-backend-core/README.md b/docs/plans/phase-2-backend-core/README.md deleted file mode 100644 index 532dfb3..0000000 --- a/docs/plans/phase-2-backend-core/README.md +++ /dev/null @@ -1,834 +0,0 @@ -# Phase 2: 后端核心服务(W2-W3) - -> **目标:** 实现所有后端业务 API,包括任务管理、文件上传、OCR 集成、规则引擎、影子账本、补件与提交。 -> **周期:** 第 2 ~ 3 周 -> **任务数:** 6 个 -> **可并行:** Task 2.1 / 2.2 / 2.3 可并行;Task 2.4 依赖 2.1;Task 2.5 依赖 2.2 + 2.4 -> **前置依赖:** Phase 1 完成 - ---- - -## 本阶段交付物 - -| 交付物 | 说明 | -|---|---| -| 报销任务 API | 创建/查询/列表 | -| 文件上传 API | MinIO 存储 + 票据管理 | -| OCR 服务 | 百度云 + Mock Provider | -| 规则引擎 | 6 条核心规则 + 管理 API | -| 影子账本 API | 草稿/预审结果查询 | -| 补件 + 提交 API | 补件交互 + 模拟同步 | - ---- - -## 任务清单 - -### Task 2.1: 报销任务管理 API - -**负责人:** 后端工程师 A -**预计工时:** 1.5 天 -**前置依赖:** Phase 1 - -**Files:** -- Create: `backend/app/schemas/task.py` -- Create: `backend/app/services/task_service.py` -- Create: `backend/app/api/v1/tasks.py` -- Modify: `backend/app/api/v1/router.py` -- Test: `backend/tests/test_task_api.py` - -- [ ] **Step 1: 定义 Pydantic schemas** - -`backend/app/schemas/task.py`: - -```python -from pydantic import BaseModel -from datetime import datetime - -class TaskCreateRequest(BaseModel): - user_id: str - company_id: str - user_intent: str - entry_channel: str = "web" - -class TaskResponse(BaseModel): - task_id: str - status: str - - class Config: - from_attributes = True - -class TaskDetailResponse(BaseModel): - id: str - user_id: str - company_id: str - task_type: str - status: str - user_intent: str | None - current_agent: str | None - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True - -class TaskListResponse(BaseModel): - total: int - items: list[TaskDetailResponse] -``` - -- [ ] **Step 2: 实现 TaskService 业务逻辑** - -`backend/app/services/task_service.py`: - -```python -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func -from app.models.task import ReimbursementTask -from app.models.enums import TaskStatus -from app.schemas.task import TaskCreateRequest - -class TaskService: - def __init__(self, db: AsyncSession): - self.db = db - - async def create_task(self, req: TaskCreateRequest) -> ReimbursementTask: - task = ReimbursementTask( - user_id=req.user_id, - company_id=req.company_id, - user_intent=req.user_intent, - status=TaskStatus.MATERIAL_COLLECTING, - ) - self.db.add(task) - await self.db.commit() - await self.db.refresh(task) - return task - - async def get_task(self, task_id: str) -> ReimbursementTask | None: - result = await self.db.execute( - select(ReimbursementTask).where(ReimbursementTask.id == task_id) - ) - return result.scalar_one_or_none() - - async def list_tasks(self, user_id: str | None = None, status: str | None = None, - page: int = 1, size: int = 20) -> tuple[list[ReimbursementTask], int]: - query = select(ReimbursementTask) - count_query = select(func.count()).select_from(ReimbursementTask) - - if user_id: - query = query.where(ReimbursementTask.user_id == user_id) - count_query = count_query.where(ReimbursementTask.user_id == user_id) - if status: - query = query.where(ReimbursementTask.status == status) - count_query = count_query.where(ReimbursementTask.status == status) - - total_result = await self.db.execute(count_query) - total = total_result.scalar() or 0 - - query = query.offset((page - 1) * size).limit(size).order_by(ReimbursementTask.created_at.desc()) - result = await self.db.execute(query) - return result.scalars().all(), total - - async def update_status(self, task_id: str, status: TaskStatus, current_agent: str | None = None) -> ReimbursementTask | None: - task = await self.get_task(task_id) - if not task: - return None - task.status = status - task.current_agent = current_agent - await self.db.commit() - await self.db.refresh(task) - return task -``` - -- [ ] **Step 3: 实现 API 路由** - -`backend/app/api/v1/tasks.py`: - -```python -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from app.core.database import get_db -from app.schemas.task import TaskCreateRequest, TaskResponse, TaskDetailResponse, TaskListResponse -from app.services.task_service import TaskService - -router = APIRouter(prefix="/reimbursement/tasks", tags=["tasks"]) - -@router.post("", response_model=TaskResponse, status_code=201) -async def create_task(req: TaskCreateRequest, db: AsyncSession = Depends(get_db)): - svc = TaskService(db) - task = await svc.create_task(req) - return TaskResponse(task_id=task.id, status=task.status.value) - -@router.get("/{task_id}", response_model=TaskDetailResponse) -async def get_task(task_id: str, db: AsyncSession = Depends(get_db)): - svc = TaskService(db) - task = await svc.get_task(task_id) - if not task: - raise HTTPException(status_code=404, detail="Task not found") - return task - -@router.get("", response_model=TaskListResponse) -async def list_tasks(user_id: str | None = None, status: str | None = None, - page: int = 1, size: int = 20, db: AsyncSession = Depends(get_db)): - svc = TaskService(db) - items, total = await svc.list_tasks(user_id, status, page, size) - return TaskListResponse(total=total, items=items) -``` - -更新 `backend/app/api/v1/router.py`: - -```python -from fastapi import APIRouter -from app.api.v1.tasks import router as tasks_router - -api_router = APIRouter() -api_router.include_router(tasks_router) -``` - -- [ ] **Step 4: 编写测试** - -```python -# backend/tests/test_task_api.py -import pytest - -@pytest.mark.asyncio -async def test_create_task(client): - response = await client.post("/api/v1/reimbursement/tasks", json={ - "user_id": "U001", - "company_id": "C001", - "user_intent": "我要报这次北京出差的费用", - "entry_channel": "web" - }) - assert response.status_code == 201 - data = response.json() - assert "task_id" in data - assert data["status"] == "material_collecting" - -@pytest.mark.asyncio -async def test_get_task(client): - # 先创建 - create_resp = await client.post("/api/v1/reimbursement/tasks", json={ - "user_id": "U001", "company_id": "C001", "user_intent": "test" - }) - task_id = create_resp.json()["task_id"] - # 再查询 - get_resp = await client.get(f"/api/v1/reimbursement/tasks/{task_id}") - assert get_resp.status_code == 200 - assert get_resp.json()["user_id"] == "U001" - -@pytest.mark.asyncio -async def test_list_tasks(client): - response = await client.get("/api/v1/reimbursement/tasks") - assert response.status_code == 200 - assert "total" in response.json() - assert "items" in response.json() -``` - -- [ ] **Step 5: 运行测试** - -Run: `cd backend && pytest tests/test_task_api.py -v` -Expected: All PASS - -- [ ] **Step 6: Commit** - -```bash -git add backend/ -git commit -m "feat: 实现报销任务管理 API(创建/查询/列表)" -``` - ---- - -### Task 2.2: 文件上传与票据管理 API - -**负责人:** 后端工程师 B -**预计工时:** 1.5 天 -**前置依赖:** Phase 1 - -**Files:** -- Create: `backend/app/schemas/document.py` -- Create: `backend/app/services/document_service.py` -- Create: `backend/app/services/storage_service.py` -- Create: `backend/app/api/v1/documents.py` -- Modify: `backend/app/api/v1/router.py` -- Test: `backend/tests/test_document_api.py` - -- [ ] **Step 1: 实现 MinIO 存储服务** - -`backend/app/services/storage_service.py` — 封装 MinIO 操作: -- `upload_file(bucket, file_name, file_data, content_type)` → 上传文件到 MinIO -- `get_file_url(bucket, file_name)` → 获取文件访问 URL -- `delete_file(bucket, file_name)` → 删除文件 -- `ensure_bucket(bucket)` → 确保 bucket 存在 - -开发阶段可使用 mock 实现(本地文件系统存储)。 - -- [ ] **Step 2: 实现文档服务** - -`backend/app/services/document_service.py`: -- `upload_document(task_id, file, document_type)` → 保存文件到 MinIO,创建 DB 记录 -- `get_documents(task_id)` → 查询任务下所有票据 -- `get_document(document_id)` → 查询单个票据 -- `update_ocr_result(document_id, ocr_result)` → 更新 OCR 识别结果 - -- [ ] **Step 3: 定义 Pydantic schemas** - -`backend/app/schemas/document.py`: - -```python -from pydantic import BaseModel -from datetime import date -from decimal import Decimal - -class DocumentUploadResponse(BaseModel): - document_id: str - ocr_status: str - -class DocumentResponse(BaseModel): - id: str - task_id: str - document_type: str - file_url: str - ocr_status: str - invoice_code: str | None - invoice_number: str | None - invoice_date: date | None - amount: Decimal | None - seller_name: str | None - - class Config: - from_attributes = True -``` - -- [ ] **Step 4: 实现 API 路由** - -`backend/app/api/v1/documents.py`: - -```python -from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from app.core.database import get_db -from app.schemas.document import DocumentUploadResponse, DocumentResponse -from app.services.document_service import DocumentService - -router = APIRouter(prefix="/reimbursement/tasks/{task_id}/documents", tags=["documents"]) - -@router.post("", response_model=DocumentUploadResponse, status_code=201) -async def upload_document( - task_id: str, - file: UploadFile = File(...), - document_type: str = Form(...), - db: AsyncSession = Depends(get_db) -): - svc = DocumentService(db) - doc = await svc.upload_document(task_id, file, document_type) - return DocumentUploadResponse(document_id=doc.id, ocr_status=doc.ocr_status) - -@router.get("", response_model=list[DocumentResponse]) -async def list_documents(task_id: str, db: AsyncSession = Depends(get_db)): - svc = DocumentService(db) - docs = await svc.get_documents(task_id) - return docs -``` - -在 `router.py` 中注册 documents_router。 - -- [ ] **Step 5: 编写测试(使用 mock MinIO)** - -- [ ] **Step 6: Commit** - -```bash -git add backend/ -git commit -m "feat: 实现文件上传与票据管理 API(MinIO 存储)" -``` - ---- - -### Task 2.3: OCR 服务集成 - -**负责人:** 后端工程师 B -**预计工时:** 2 天 -**前置依赖:** Task 2.2(需要 document_service) -**可并行于:** Task 2.1、Task 2.4 - -**Files:** -- Create: `backend/app/services/ocr_service.py` -- Create: `backend/app/services/ocr_providers/__init__.py` -- Create: `backend/app/services/ocr_providers/base.py` -- Create: `backend/app/services/ocr_providers/baidu.py` -- Create: `backend/app/services/ocr_providers/mock.py` -- Test: `backend/tests/test_ocr_service.py` - -- [ ] **Step 1: 定义 OCR Provider 抽象接口** - -`backend/app/services/ocr_providers/base.py`: - -```python -from abc import ABC, abstractmethod -from dataclasses import dataclass, field - -@dataclass -class OCRResult: - document_type: str # 识别出的票据类型 - raw_text: str # 原始文字 - fields: dict = field(default_factory=dict) # 结构化字段 - confidence: float = 0.0 # 整体置信度 0-1 - provider: str = "" # 提供商名称 - -class OCRProvider(ABC): - @abstractmethod - async def recognize(self, file_url: str, document_type: str | None = None) -> OCRResult: - ... - - @abstractmethod - async def recognize_vat_invoice(self, file_url: str) -> OCRResult: - ... - - @abstractmethod - async def recognize_train_ticket(self, file_url: str) -> OCRResult: - ... -``` - -- [ ] **Step 2: 实现 Mock OCR Provider** - -`backend/app/services/ocr_providers/mock.py` — 根据文件名/类型返回预定义的结构化数据: - -```python -from app.services.ocr_providers.base import OCRProvider, OCRResult - -class MockOCRProvider(OCRProvider): - async def recognize(self, file_url: str, document_type: str | None = None) -> OCRResult: - if document_type == "vat_invoice" or "invoice" in file_url: - return await self.recognize_vat_invoice(file_url) - elif document_type == "train_ticket" or "train" in file_url: - return await self.recognize_train_ticket(file_url) - return OCRResult(document_type="unknown", raw_text="", confidence=0.0, provider="mock") - - async def recognize_vat_invoice(self, file_url: str) -> OCRResult: - return OCRResult( - document_type="vat_invoice", - raw_text="增值税电子普通发票", - fields={ - "invoice_code": "050002100311", - "invoice_number": "23912077", - "invoice_date": "2026-04-20", - "amount": "1061.95", - "tax_amount": "61.95", - "total_amount": "1123.90", - "seller_name": "北京XX酒店管理有限公司", - "buyer_name": "XX科技有限公司", - "check_code": "1234567890", - }, - confidence=0.95, - provider="mock" - ) - - async def recognize_train_ticket(self, file_url: str) -> OCRResult: - return OCRResult( - document_type="train_ticket", - raw_text="火车票", - fields={ - "train_number": "G101", - "departure_station": "北京南", - "arrival_station": "上海虹桥", - "departure_date": "2026-04-18", - "departure_time": "07:00", - "seat_type": "二等座", - "amount": "553.00", - "passenger_name": "张三", - "id_number": "****1234", - }, - confidence=0.90, - provider="mock" - ) -``` - -- [ ] **Step 3: 实现百度 OCR Provider** - -`backend/app/services/ocr_providers/baidu.py` — 调用百度云 OCR API: -- `recognize_vat_invoice()` → 调用增值税发票识别接口 -- `recognize_train_ticket()` → 调用火车票识别接口 -- `recognize()` → 自动判断票据类型,调用对应接口 -- 将百度返回结果标准化为 `OCRResult` -- 包含 access_token 获取和缓存逻辑 - -- [ ] **Step 4: 实现 OCR Service 门面** - -`backend/app/services/ocr_service.py`: - -```python -from app.core.config import settings -from app.services.ocr_providers.base import OCRResult -from app.services.ocr_providers.mock import MockOCRProvider -from app.services.ocr_providers.baidu import BaiduOCRProvider - -class OCRService: - def __init__(self): - self._provider = self._create_provider() - - def _create_provider(self): - if settings.OCR_PROVIDER == "mock": - return MockOCRProvider() - elif settings.OCR_PROVIDER == "baidu": - return BaiduOCRProvider( - api_key=settings.BAIDU_OCR_API_KEY, - secret_key=settings.BAIDU_OCR_SECRET_KEY - ) - raise ValueError(f"Unknown OCR provider: {settings.OCR_PROVIDER}") - - async def recognize(self, file_url: str, document_type: str | None = None) -> OCRResult: - return await self._provider.recognize(file_url, document_type) -``` - -- [ ] **Step 5: 编写测试** - -使用 Mock Provider 测试完整 OCR 流程。 - -- [ ] **Step 6: Commit** - -```bash -git add backend/ -git commit -m "feat: 实现 OCR 服务(百度云 + Mock Provider)" -``` - ---- - -### Task 2.4: 规则引擎 - -**负责人:** 后端工程师 A -**预计工时:** 3 天 -**前置依赖:** Task 2.1(需要 task 模型) - -**Files:** -- Create: `backend/app/services/rule_engine.py` -- Create: `backend/app/services/rule_checkers/__init__.py` -- Create: `backend/app/services/rule_checkers/base.py` -- Create: `backend/app/services/rule_checkers/required_fields.py` -- Create: `backend/app/services/rule_checkers/attachment_check.py` -- Create: `backend/app/services/rule_checkers/duplicate_invoice.py` -- Create: `backend/app/services/rule_checkers/amount_limit.py` -- Create: `backend/app/services/rule_checkers/date_validity.py` -- Create: `backend/app/services/rule_checkers/expense_type_match.py` -- Create: `backend/app/schemas/rule.py` -- Create: `backend/app/api/v1/rules.py` -- Modify: `backend/app/api/v1/router.py` -- Test: `backend/tests/test_rule_engine.py` - -- [ ] **Step 1: 定义规则检查器基类** - -`backend/app/services/rule_checkers/base.py`: - -```python -from abc import ABC, abstractmethod -from dataclasses import dataclass - -@dataclass -class RuleCheckResult: - rule_code: str - severity: str # low / medium / high / blocked - action: str # pass / warn / require_explanation / require_attachment / require_approval / block - message: str - suggestion: str - policy_ref: str - hit_detail: dict - -class RuleChecker(ABC): - @abstractmethod - async def check(self, context: dict) -> RuleCheckResult | None: - """检查规则,命中返回结果,未命中返回 None""" - ... -``` - -- [ ] **Step 2: 实现 RuleEngine 核心引擎** - -`backend/app/services/rule_engine.py`: - -```python -from sqlalchemy.ext.asyncio import AsyncSession -from app.services.rule_checkers.base import RuleChecker, RuleCheckResult -from dataclasses import dataclass, field - -@dataclass -class PrecheckResult: - precheck_status: str # pass / need_supplement / blocked - risk_level: str # low / medium / high / blocked - rule_hits: list[RuleCheckResult] = field(default_factory=list) - summary: str = "" - -class RuleEngine: - def __init__(self, db: AsyncSession): - self.db = db - self.checkers: list[RuleChecker] = [] - - def register_checker(self, checker: RuleChecker): - self.checkers.append(checker) - - async def run_precheck(self, context: dict) -> PrecheckResult: - """执行完整预审,遍历所有注册的 checker""" - hits: list[RuleCheckResult] = [] - for checker in self.checkers: - result = await checker.check(context) - if result: - hits.append(result) - - risk_level = self._calculate_overall_risk(hits) - status = self._determine_status(hits) - summary = self._generate_summary(hits) - - return PrecheckResult( - precheck_status=status, - risk_level=risk_level, - rule_hits=hits, - summary=summary - ) - - def _calculate_overall_risk(self, hits: list[RuleCheckResult]) -> str: - if not hits: - return "low" - severity_order = {"blocked": 4, "high": 3, "medium": 2, "low": 1} - max_severity = max(hits, key=lambda h: severity_order.get(h.severity, 0)) - return max_severity.severity - - def _determine_status(self, hits: list[RuleCheckResult]) -> str: - if not hits: - return "pass" - if any(h.action == "block" for h in hits): - return "blocked" - return "need_supplement" - - def _generate_summary(self, hits: list[RuleCheckResult]) -> str: - if not hits: - return "预审通过,未发现风险。" - blocked = sum(1 for h in hits if h.action == "block") - warnings = sum(1 for h in hits if h.action in ("warn", "require_explanation")) - supplements = sum(1 for h in hits if h.action == "require_attachment") - parts = [] - if blocked: - parts.append(f"{blocked} 个阻断项") - if supplements: - parts.append(f"{supplements} 个缺件") - if warnings: - parts.append(f"{warnings} 个风险提示") - return f"当前报销单存在{'、'.join(parts)}。" -``` - -- [ ] **Step 3: 实现 6 条核心规则检查器** - -1. **`required_fields.py`** — `RequiredFieldsChecker` — 必填字段校验 - - 检查报销人、部门、事由、费用明细是否有空值 - - 命中返回 `require_explanation` - -2. **`attachment_check.py`** — `AttachmentCheckChecker` — 附件完整性校验 - - 住宿费必须上传酒店流水 - - 交通费必须上传对应票据 - - 命中返回 `require_attachment` - -3. **`duplicate_invoice.py`** — `DuplicateInvoiceChecker` — 重复发票检查 - - 检查 invoice_code + invoice_number + amount 是否重复 - - 命中返回 `block` - -4. **`amount_limit.py`** — `AmountLimitChecker` — 金额超标校验 - - 按城市等级和费用类型检查标准 - - 住宿费按每晚金额检查 - - 命中返回 `require_explanation` - -5. **`date_validity.py`** — `DateValidityChecker` — 日期合理性校验 - - 费用日期不能晚于今天 - - 费用日期应在出差期间内 - - 命中返回 `warn` - -6. **`expense_type_match.py`** — `ExpenseTypeMatchChecker` — 费用类型匹配校验 - - 住宿费应关联 hotel_bill 类型票据 - - 交通费应关联 train_ticket / flight_itinerary / taxi_receipt - - 命中返回 `warn` - -- [ ] **Step 4: 实现规则管理 API** - -- `GET /api/v1/rules` — 列出所有规则 -- `POST /api/v1/rules` — 创建规则 -- `PUT /api/v1/rules/{rule_id}` — 更新规则 -- `PATCH /api/v1/rules/{rule_id}/toggle` — 启用/禁用规则 - -- [ ] **Step 5: 编写测试** - -对每条规则编写单元测试: -```python -@pytest.mark.asyncio -async def test_duplicate_invoice_checker(): - checker = DuplicateInvoiceChecker() - # 模拟重复发票场景 - context = {"items": [...], "existing_invoices": [...]} - result = await checker.check(context) - assert result is not None - assert result.action == "block" - -@pytest.mark.asyncio -async def test_no_duplicate(): - checker = DuplicateInvoiceChecker() - context = {"items": [...], "existing_invoices": []} # 无重复 - result = await checker.check(context) - assert result is None -``` - -- [ ] **Step 6: Commit** - -```bash -git add backend/ -git commit -m "feat: 实现规则引擎(6 条核心规则 + 管理 API)" -``` - ---- - -### Task 2.5: 影子报销账本 CRUD - -**负责人:** 后端工程师 A -**预计工时:** 1.5 天 -**前置依赖:** Task 2.1 + Task 2.4 - -**Files:** -- Create: `backend/app/schemas/reimbursement.py` -- Create: `backend/app/services/ledger_service.py` -- Create: `backend/app/api/v1/ledger.py` -- Modify: `backend/app/api/v1/router.py` -- Test: `backend/tests/test_ledger_api.py` - -- [ ] **Step 1: 定义 Pydantic schemas** - -`backend/app/schemas/reimbursement.py`: - -```python -from pydantic import BaseModel -from datetime import date, datetime -from decimal import Decimal - -class ReimbursementItemResponse(BaseModel): - id: str - expense_type: str - amount: Decimal - tax_amount: Decimal | None - occurred_at: date | None - city: str | None - vendor_name: str | None - risk_level: str | None - remark: str | None - - class Config: - from_attributes = True - -class ReimbursementDraftResponse(BaseModel): - reimbursement_id: str - reason: str | None - total_amount: Decimal - precheck_status: str | None - risk_level: str | None - items: list[ReimbursementItemResponse] - created_at: datetime - - class Config: - from_attributes = True - -class PrecheckResultResponse(BaseModel): - precheck_status: str - risk_level: str - summary: str - rule_hits: list[dict] -``` - -- [ ] **Step 2: 实现 LedgerService** - -`backend/app/services/ledger_service.py` — 核心方法: -- `create_shadow_reimbursement(task_id, data)` → 创建影子报销记录 -- `get_draft(reimbursement_id)` → 获取报销草稿 -- `get_draft_by_task(task_id)` → 通过任务 ID 获取草稿 -- `update_precheck_result(reimbursement_id, result)` → 更新预审结果 -- `add_item(reimbursement_id, item_data)` → 添加报销明细 - -- [ ] **Step 3: 实现 API 路由** - -`GET /api/v1/reimbursement/tasks/{task_id}/draft` — 获取报销草稿(对应文档 8.4) -`GET /api/v1/reimbursement/tasks/{task_id}/precheck-result` — 获取预审结果(对应文档 8.5) - -- [ ] **Step 4: 编写测试** - -- [ ] **Step 5: Commit** - -```bash -git add backend/ -git commit -m "feat: 实现影子报销账本 CRUD API" -``` - ---- - -### Task 2.6: 补件与提交 API - -**负责人:** 后端工程师 A -**预计工时:** 1.5 天 -**前置依赖:** Task 2.5 - -**Files:** -- Create: `backend/app/api/v1/supplements.py` -- Create: `backend/app/services/supplement_service.py` -- Create: `backend/app/services/sync_service.py` -- Modify: `backend/app/api/v1/router.py` -- Test: `backend/tests/test_supplement_api.py` - -- [ ] **Step 1: 实现补件服务** - -`backend/app/services/supplement_service.py`: -- `create_supplement_request(reimbursement_id, items)` → 创建补件请求 -- `respond_supplement(request_id, response_text, document_ids)` → 用户补件响应 -- `get_supplement_requests(task_id)` → 查询补件请求列表 - -- [ ] **Step 2: 实现同步服务(MVP 阶段为模拟)** - -`backend/app/services/sync_service.py`: - -```python -import uuid -from datetime import datetime -from sqlalchemy.ext.asyncio import AsyncSession - -class SyncService: - def __init__(self, db: AsyncSession): - self.db = db - - async def mock_sync_to_backend(self, reimbursement_id: str) -> dict: - """模拟后端同步,生成假的 backend_bill_id""" - backend_bill_id = f"BX{datetime.now().strftime('%Y%m%d')}{str(uuid.uuid4())[:6]}" - return { - "sync_status": "success", - "target_system": "expense_system", - "backend_bill_id": backend_bill_id, - } - - async def get_sync_status(self, task_id: str) -> dict | None: - """查询同步状态""" - # 从 sync_record 表查询 - ... -``` - -- [ ] **Step 3: 实现 API 路由** - -`POST /api/v1/reimbursement/tasks/{task_id}/supplements` — 用户补件(对应文档 8.6) -`POST /api/v1/reimbursement/tasks/{task_id}/submit` — 用户确认提交(对应文档 8.7) -`GET /api/v1/reimbursement/tasks/{task_id}/sync-status` — 查询同步状态(对应文档 8.8) - -- [ ] **Step 4: 编写测试** - -- [ ] **Step 5: Commit** - -```bash -git add backend/ -git commit -m "feat: 实现补件与提交确认 API(含模拟同步)" -``` - ---- - -## 本阶段完成检查 - -- [ ] `POST /api/v1/reimbursement/tasks` 创建任务返回 201 -- [ ] `POST /api/v1/reimbursement/tasks/{id}/documents` 上传文件返回 201 -- [ ] OCR Service 对 Mock Provider 正常返回结构化数据 -- [ ] 规则引擎对 6 条规则命中/未命中的测试全部通过 -- [ ] `GET /api/v1/reimbursement/tasks/{id}/draft` 返回草稿数据 -- [ ] `POST /api/v1/reimbursement/tasks/{id}/supplements` 补件返回 received -- [ ] `POST /api/v1/reimbursement/tasks/{id}/submit` 提交返回 submitting -- [ ] 所有测试 `pytest tests/ -v` 全部通过 diff --git a/docs/plans/phase-3-agent-orchestration/README.md b/docs/plans/phase-3-agent-orchestration/README.md deleted file mode 100644 index 848c976..0000000 --- a/docs/plans/phase-3-agent-orchestration/README.md +++ /dev/null @@ -1,568 +0,0 @@ -# Phase 3: Agent 编排(W3-W4) - -> **目标:** 实现 Agent Orchestrator 状态机、5 个业务 Agent、LLM 集成层和审计日志,完成核心智能处理能力。 -> **周期:** 第 3 ~ 4 周 -> **任务数:** 4 个 -> **可并行:** Task 3.3 / 3.4 可与 Task 3.2 并行 -> **前置依赖:** Phase 2 完成 - ---- - -## 本阶段交付物 - -| 交付物 | 说明 | -|---|---| -| Orchestrator 状态机 | 任务状态流转 + Agent 调度 | -| 5 个 Agent | 受理 / 解析 / 规则校验 / 解释补件 / 同步 | -| LLM 集成层 | 多 Provider 支持 + Prompt 模板 | -| 审计日志 | 所有关键操作留痕 | - ---- - -## 任务清单 - -### Task 3.1: Agent Orchestrator 状态机 - -**负责人:** 全栈/Agent 工程师 -**预计工时:** 2 天 -**前置依赖:** Phase 2(所有 service 就绪) - -**Files:** -- Create: `backend/app/agents/__init__.py` -- Create: `backend/app/agents/state.py` -- Create: `backend/app/agents/orchestrator.py` -- Create: `backend/app/api/v1/agent.py` -- Modify: `backend/app/api/v1/router.py` -- Test: `backend/tests/test_orchestrator.py` - -- [ ] **Step 1: 定义 Agent 状态和上下文** - -`backend/app/agents/state.py`: - -```python -from dataclasses import dataclass, field -from app.models.enums import TaskStatus - -@dataclass -class AgentContext: - task_id: str - status: TaskStatus - user_intent: str | None = None - current_agent: str | None = None - ocr_results: list[dict] = field(default_factory=list) - reimbursement_data: dict | None = None - precheck_result: dict | None = None - supplement_requests: list[dict] = field(default_factory=list) - error_message: str | None = None - retry_count: int = 0 -``` - -- [ ] **Step 2: 定义 Agent 基类和结果** - -`backend/app/agents/base_agent.py`: - -```python -from abc import ABC, abstractmethod -from dataclasses import dataclass, field -from sqlalchemy.ext.asyncio import AsyncSession -from app.agents.state import AgentContext - -@dataclass -class AgentResult: - success: bool - data: dict = field(default_factory=dict) - next_action: str = "continue" # continue / wait_user / need_supplement / retry - error: str | None = None - -class BaseAgent(ABC): - name: str = "" - - @abstractmethod - async def execute(self, context: AgentContext, db: AsyncSession) -> AgentResult: - ... -``` - -- [ ] **Step 3: 实现 Orchestrator 状态机** - -`backend/app/agents/orchestrator.py` — 核心编排逻辑: - -状态转换图(对应开发文档 4.2 节): -``` -CREATED → MATERIAL_COLLECTING → PARSING → DRAFT_GENERATED → PRECHECKING - ↑ ↓ - └─── MATERIAL_COLLECTING ←── NEED_SUPPLEMENT ←────────────────┘ - ↓ - PENDING_USER_CONFIRM → SUBMITTING → SYNCED - ↓ - SYNC_FAILED → SUBMITTING(重试) -``` - -```python -from sqlalchemy.ext.asyncio import AsyncSession -from app.agents.state import AgentContext -from app.agents.base_agent import BaseAgent, AgentResult -from app.models.enums import TaskStatus -from app.services.task_service import TaskService - -class Orchestrator: - def __init__(self, db: AsyncSession): - self.db = db - self.agents: dict[str, BaseAgent] = {} - - def register_agent(self, agent: BaseAgent): - self.agents[agent.name] = agent - - async def run(self, task_id: str, start_from: str = "intake") -> AgentContext: - """启动编排流程""" - task_svc = TaskService(self.db) - task = await task_svc.get_task(task_id) - if not task: - raise ValueError(f"Task {task_id} not found") - - context = AgentContext( - task_id=task_id, - status=task.status, - user_intent=task.user_intent, - ) - - # 根据 start_from 决定从哪个状态开始 - agent_sequence = self._get_agent_sequence(start_from) - - for agent_name in agent_sequence: - context.current_agent = agent_name - await task_svc.update_status(task_id, self._agent_to_status(agent_name), agent_name) - - agent = self.agents.get(agent_name) - if not agent: - continue - - result = await agent.execute(context, self.db) - - if not result.success: - context.error_message = result.error - break - - context = self._merge_result(context, result) - - if result.next_action == "wait_user": - await task_svc.update_status(task_id, TaskStatus.PENDING_USER_CONFIRM, agent_name) - break - - if result.next_action == "need_supplement": - await task_svc.update_status(task_id, TaskStatus.NEED_SUPPLEMENT, agent_name) - break - - return context - - def _get_agent_sequence(self, start_from: str) -> list[str]: - sequences = { - "intake": ["intake_agent", "parse_agent", "rule_check_agent", "explain_agent"], - "precheck": ["rule_check_agent", "explain_agent"], - "submit": ["sync_agent"], - } - return sequences.get(start_from, sequences["intake"]) - - def _agent_to_status(self, agent_name: str) -> TaskStatus: - mapping = { - "intake_agent": TaskStatus.MATERIAL_COLLECTING, - "parse_agent": TaskStatus.PARSING, - "rule_check_agent": TaskStatus.PRECHECKING, - "explain_agent": TaskStatus.PRECHECKING, - "sync_agent": TaskStatus.SUBMITTING, - } - return mapping.get(agent_name, TaskStatus.PRECHECKING) - - def _merge_result(self, context: AgentContext, result: AgentResult) -> AgentContext: - """将 Agent 结果合并到上下文""" - data = result.data - if "ocr_results" in data: - context.ocr_results = data["ocr_results"] - if "reimbursement_data" in data: - context.reimbursement_data = data["reimbursement_data"] - if "precheck_result" in data: - context.precheck_result = data["precheck_result"] - if "supplement_requests" in data: - context.supplement_requests = data["supplement_requests"] - return context -``` - -- [ ] **Step 4: 实现 Agent 启动 API** - -`backend/app/api/v1/agent.py`: - -```python -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from pydantic import BaseModel -from app.core.database import get_db - -router = APIRouter(prefix="/reimbursement/tasks/{task_id}/agent", tags=["agent"]) - -class AgentRunRequest(BaseModel): - start_from: str = "intake" # intake / precheck / submit - mode: str = "precheck" - -class AgentRunResponse(BaseModel): - task_id: str - status: str - current_agent: str | None - -@router.post("/run", response_model=AgentRunResponse) -async def run_agent(task_id: str, req: AgentRunRequest, db: AsyncSession = Depends(get_db)): - orchestrator = create_orchestrator(db) - context = await orchestrator.run(task_id, start_from=req.start_from) - return AgentRunResponse( - task_id=context.task_id, - status=context.status.value, - current_agent=context.current_agent, - ) -``` - -- [ ] **Step 5: 编写状态机转换测试** - -覆盖路径: -- 正常路径:创建 → 解析 → 草稿 → 预审 → 通过 → 提交 → 同步 -- 补件路径:预审 → 需补件 → 等待用户 -- 重试路径:提交 → 同步失败 → 重试 - -- [ ] **Step 6: Commit** - -```bash -git add backend/ -git commit -m "feat: 实现 Agent Orchestrator 状态机编排" -``` - ---- - -### Task 3.2: 5 个 Agent 实现 - -**负责人:** 全栈/Agent 工程师 -**预计工时:** 3 天 -**前置依赖:** Task 3.1(Orchestrator 就绪) - -**Files:** -- Create: `backend/app/agents/intake_agent.py` -- Create: `backend/app/agents/parse_agent.py` -- Create: `backend/app/agents/rule_check_agent.py` -- Create: `backend/app/agents/explain_agent.py` -- Create: `backend/app/agents/sync_agent.py` -- Test: `backend/tests/test_agents.py` - -- [ ] **Step 1: 实现 IntakeAgent(受理 Agent)** - -`backend/app/agents/intake_agent.py`: -- 分析 user_intent 文本,提取报销类型、出差信息 -- 调用 LLM 做 intent classification -- 返回结构化任务信息(报销类型、出差城市、日期范围等) -- 输出:`AgentResult(data={"task_info": {...}})` - -- [ ] **Step 2: 实现 ParseAgent(单据解析 Agent)** - -`backend/app/agents/parse_agent.py`: -- 遍历任务下所有 document,调用 `ocr_service.recognize()` -- 将 OCR 结果汇总为报销明细 -- 调用 `ledger_service.create_shadow_reimbursement()` 创建影子记录 -- 调用 `ledger_service.add_item()` 添加每条明细 -- 自动识别费用类型(可调用 LLM 辅助) -- 输出:`AgentResult(data={"ocr_results": [...], "reimbursement_data": {...}})` - -- [ ] **Step 3: 实现 RuleCheckAgent(规则校验 Agent)** - -`backend/app/agents/rule_check_agent.py`: -- 构建 context dict(报销数据 + 票据数据 + 已有发票列表) -- 注册 6 个 RuleChecker 到 RuleEngine -- 调用 `rule_engine.run_precheck(context)` -- 保存 RuleHit 记录到 DB -- 更新 shadow_reimbursement 的预审状态 -- 输出:`AgentResult(data={"precheck_result": {...}})` - -- [ ] **Step 4: 实现 ExplainAgent(解释与补件 Agent)** - -`backend/app/agents/explain_agent.py`: -- 遍历 rule_hits,使用 LLM 生成自然语言解释 -- 对 `require_attachment` 类型的命中自动创建 supplement_request -- 生成修改建议 -- 根据预审结果决定 next_action: - - 全部通过 → `continue` - - 有需补件的 → `need_supplement` - - 有阻断的 → `need_supplement` -- 输出:`AgentResult(data={"supplement_requests": [...]}, next_action="need_supplement")` - -- [ ] **Step 5: 实现 SyncAgent(同步执行 Agent)** - -`backend/app/agents/sync_agent.py`: -- 将 ShadowReimbursement 数据映射为标准报销单格式 -- 调用 `sync_service.mock_sync_to_backend()` -- 记录 SyncRecord -- 更新 shadow_reimbursement 的 sync_status 和 backend_bill_id -- 处理同步失败重试(retry_count < 3 时标记 retrying) -- 输出:`AgentResult(data={"sync_result": {...}})` - -- [ ] **Step 6: 编写每个 Agent 的单元测试** - -使用 mock DB、mock OCR、mock LLM 测试每个 Agent 的输入输出。 - -- [ ] **Step 7: Commit** - -```bash -git add backend/ -git commit -m "feat: 实现 5 个 Agent(受理/解析/规则校验/解释补件/同步)" -``` - ---- - -### Task 3.3: LLM 集成层 - -**负责人:** 全栈/Agent 工程师 -**预计工时:** 2 天 -**前置依赖:** Phase 2 -**可并行于:** Task 3.2 - -**Files:** -- Create: `backend/app/services/llm_service.py` -- Create: `backend/app/services/llm_prompts/__init__.py` -- Create: `backend/app/services/llm_prompts/intent_classification.py` -- Create: `backend/app/services/llm_prompts/risk_explanation.py` -- Create: `backend/app/services/llm_prompts/expense_type_mapping.py` -- Test: `backend/tests/test_llm_service.py` - -- [ ] **Step 1: 实现 LLM Service 封装** - -`backend/app/services/llm_service.py`: - -```python -import httpx -import json -from app.core.config import settings - -class LLMService: - def __init__(self): - self.api_key = settings.LLM_API_KEY - self.model = settings.LLM_MODEL - self.base_url = settings.LLM_BASE_URL or "https://api.openai.com/v1" - - async def chat(self, system_prompt: str, user_message: str, json_mode: bool = False) -> str: - """调用 LLM API""" - headers = { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - } - payload = { - "model": self.model, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_message}, - ], - } - if json_mode: - payload["response_format"] = {"type": "json_object"} - - async with httpx.AsyncClient(timeout=60.0) as client: - resp = await client.post( - f"{self.base_url}/chat/completions", - headers=headers, - json=payload - ) - resp.raise_for_status() - data = resp.json() - return data["choices"][0]["message"]["content"] - - async def chat_json(self, system_prompt: str, user_message: str) -> dict: - """调用 LLM 并解析 JSON 响应""" - raw = await self.chat(system_prompt, user_message, json_mode=True) - return json.loads(raw) -``` - -- [ ] **Step 2: 编写 Prompt 模板** - -**`intent_classification.py`** — 分析用户意图,识别报销类型: - -```python -INTENT_CLASSIFICATION_PROMPT = """你是一个报销意图识别助手。根据用户的描述,识别报销类型和关键信息。 - -请严格按以下 JSON 格式输出: -{ - "reimbursement_type": "travel_expense" | "office_expense" | "business_meal" | "other", - "travel_info": { - "destination": "城市名", - "start_date": "YYYY-MM-DD" 或 null, - "end_date": "YYYY-MM-DD" 或 null, - "purpose": "出差事由" - }, - "confidence": 0.0-1.0 -} - -用户描述:{user_intent} -""" -``` - -**`risk_explanation.py`** — 将规则命中结果转为自然语言解释: - -```python -RISK_EXPLANATION_PROMPT = """你是一个报销制度解释助手。请根据规则命中结果,用简洁易懂的语言向用户解释问题。 - -规则命中信息: -- 规则名称:{rule_name} -- 问题类型:{issue_type} -- 制度依据:{policy_ref} -- 具体数据:{hit_detail} - -请用 2-3 句话解释: -1. 存在什么问题 -2. 制度标准是什么 -3. 建议如何处理 -""" -``` - -**`expense_type_mapping.py`** — 根据 OCR 结果匹配费用类型: - -```python -EXPENSE_TYPE_MAPPING_PROMPT = """根据票据 OCR 识别结果,判断费用类型。 - -可选费用类型: -- travel_transport: 差旅交通费(火车票、机票、打车) -- travel_hotel: 差旅住宿费(酒店发票) -- travel_meal: 差旅餐补 -- local_transport: 市内交通费 -- business_meal: 业务招待费 -- office_supply: 办公用品费 -- communication: 通讯费 -- other: 其他 - -OCR 识别结果:{ocr_result} - -请输出 JSON:{"expense_type": "类型编码", "confidence": 0.0-1.0} -""" -``` - -- [ ] **Step 3: 编写测试(使用 mock LLM 响应)** - -```python -import pytest -from unittest.mock import AsyncMock, patch - -@pytest.mark.asyncio -async def test_llm_intent_classification(): - with patch("app.services.llm_service.LLMService.chat_json", new_callable=AsyncMock) as mock_chat: - mock_chat.return_value = { - "reimbursement_type": "travel_expense", - "travel_info": {"destination": "北京", "purpose": "商务出差"}, - "confidence": 0.9 - } - llm = LLMService() - result = await llm.chat_json("system prompt", "我要报北京出差费用") - assert result["reimbursement_type"] == "travel_expense" -``` - -- [ ] **Step 4: Commit** - -```bash -git add backend/ -git commit -m "feat: 实现 LLM 集成层(多 Provider + Prompt 模板)" -``` - ---- - -### Task 3.4: 审计日志 - -**负责人:** 后端工程师 B -**预计工时:** 1 天 -**前置依赖:** Phase 2 -**可并行于:** Task 3.1、3.2、3.3 - -**Files:** -- Create: `backend/app/services/audit_service.py` -- Create: `backend/app/api/v1/audit.py` -- Modify: `backend/app/api/v1/router.py` -- Test: `backend/tests/test_audit.py` - -- [ ] **Step 1: 实现 AuditService** - -`backend/app/services/audit_service.py`: - -```python -import json -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from sqlalchemy.orm import Mapped -from app.models.audit import AuditLog - -class AuditService: - def __init__(self, db: AsyncSession): - self.db = db - - async def log(self, action: str, actor: str, target_type: str, target_id: str, detail: dict | None = None): - """记录审计日志""" - log_entry = AuditLog( - action=action, - actor=actor, - target_type=target_type, - target_id=target_id, - detail=json.dumps(detail, ensure_ascii=False) if detail else None, - ) - self.db.add(log_entry) - await self.db.flush() # 不 commit,让调用方统一 commit - - async def query_logs(self, target_type: str | None = None, target_id: str | None = None, - actor: str | None = None, page: int = 1, size: int = 50): - """查询审计日志""" - query = select(AuditLog) - if target_type: - query = query.where(AuditLog.target_type == target_type) - if target_id: - query = query.where(AuditLog.target_id == target_id) - if actor: - query = query.where(AuditLog.actor == actor) - query = query.order_by(AuditLog.created_at.desc()).offset((page - 1) * size).limit(size) - result = await self.db.execute(query) - return result.scalars().all() -``` - -- [ ] **Step 2: 定义审计动作枚举** - -```python -class AuditAction: - FILE_UPLOAD = "file_upload" - OCR_RECOGNIZE = "ocr_recognize" - AGENT_CALL = "agent_call" - RULE_HIT = "rule_hit" - SUPPLEMENT_REQUEST = "supplement_request" - SUPPLEMENT_RESPOND = "supplement_respond" - USER_CONFIRM = "user_confirm" - BACKEND_SYNC = "backend_sync" -``` - -- [ ] **Step 3: 在关键路径埋点** - -在以下位置调用 `audit_service.log()`: -- `document_service.upload_document()` → `FILE_UPLOAD` -- `ocr_service.recognize()` → `OCR_RECOGNIZE` -- `orchestrator._run_agent()` → `AGENT_CALL` -- `rule_engine.run_precheck()` → `RULE_HIT`(每条命中记录一条) -- `supplement_service.create_supplement_request()` → `SUPPLEMENT_REQUEST` -- `supplement_service.respond_supplement()` → `SUPPLEMENT_RESPOND` -- `sync_service.mock_sync_to_backend()` → `BACKEND_SYNC` - -- [ ] **Step 4: 实现审计日志查询 API** - -`GET /api/v1/audit/logs` — 支持按 target_type、target_id、actor、date_range 过滤 - -- [ ] **Step 5: 编写测试** - -- [ ] **Step 6: Commit** - -```bash -git add backend/ -git commit -m "feat: 实现审计日志服务(记录 + 查询 API)" -``` - ---- - -## 本阶段完成检查 - -- [ ] Orchestrator 状态机所有转换路径测试通过 -- [ ] 5 个 Agent 能独立执行并返回正确结果 -- [ ] LLM Service 能调用大模型并解析 JSON 响应 -- [ ] 审计日志在所有关键路径都有记录 -- [ ] `POST /api/v1/reimbursement/tasks/{id}/agent/run` 能启动完整 Agent 流程 -- [ ] 所有测试 `pytest tests/ -v` 全部通过 diff --git a/docs/plans/phase-4-frontend-pages/README.md b/docs/plans/phase-4-frontend-pages/README.md deleted file mode 100644 index 5ff0431..0000000 --- a/docs/plans/phase-4-frontend-pages/README.md +++ /dev/null @@ -1,500 +0,0 @@ -# Phase 4: 前端核心页面(W4-W5) - -> **目标:** 实现所有核心前端页面和组件,完成用户交互界面。 -> **周期:** 第 4 ~ 5 周 -> **任务数:** 4 个 -> **可并行:** 4 个任务可由 1-2 名前端工程师并行开发 -> **前置依赖:** Phase 1(前端骨架) -> **备注:** 可与 Phase 3 后半段并行开始 - ---- - -## 本阶段交付物 - -| 交付物 | 说明 | -|---|---| -| 报销入口页 | 对话式报销入口 + 快捷操作 | -| 票据上传页 | 文件上传组件 + 票据类型选择 | -| 报销草稿页 | 费用明细表格 + 可编辑字段 | -| 预审结果页 | 风险展示 + 规则命中详情 | -| 补件交互页 | 补件清单 + 上传/回复 | -| 提交确认页 | 最终确认 + 同步状态 | -| 审计日志页 | 操作时间线 | - ---- - -## 任务清单 - -### Task 4.1: 报销入口页 + 上传组件 - -**负责人:** 前端工程师 -**预计工时:** 2 天 -**前置依赖:** Phase 1(前端骨架) - -**Files:** -- Create: `frontend/src/views/HomeView.vue` -- Create: `frontend/src/views/UploadView.vue` -- Create: `frontend/src/components/FileUpload.vue` -- Create: `frontend/src/stores/task.ts` -- Create: `frontend/src/api/task.ts` -- Create: `frontend/src/api/document.ts` - -- [ ] **Step 1: 实现 API 调用层** - -`frontend/src/api/task.ts`: - -```typescript -import api from './index' - -export const createTask = (data: { - userId: string - companyId: string - userIntent: string - entryChannel?: string -}) => api.post('/reimbursement/tasks', data) - -export const getTask = (taskId: string) => - api.get(`/reimbursement/tasks/${taskId}`) - -export const listTasks = (params?: { userId?: string; status?: string; page?: number; size?: number }) => - api.get('/reimbursement/tasks', { params }) - -export const runAgent = (taskId: string, startFrom = 'intake', mode = 'precheck') => - api.post(`/reimbursement/tasks/${taskId}/agent/run`, { start_from: startFrom, mode }) -``` - -`frontend/src/api/document.ts`: - -```typescript -import api from './index' - -export const uploadDocument = (taskId: string, file: File, documentType: string) => { - const formData = new FormData() - formData.append('file', file) - formData.append('document_type', documentType) - return api.post(`/reimbursement/tasks/${taskId}/documents`, formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }) -} - -export const listDocuments = (taskId: string) => - api.get(`/reimbursement/tasks/${taskId}/documents`) -``` - -- [ ] **Step 2: 实现 Pinia Store** - -`frontend/src/stores/task.ts`: - -```typescript -import { defineStore } from 'pinia' -import { ref } from 'vue' -import { createTask, getTask, listTasks, runAgent } from '@/api/task' - -export const useTaskStore = defineStore('task', () => { - const currentTask = ref(null) - const taskList = ref([]) - const loading = ref(false) - - async function create(userIntent: string) { - loading.value = true - try { - const { data } = await createTask({ - userId: 'U001', // TODO: 从登录态获取 - companyId: 'C001', - userIntent, - }) - currentTask.value = data - return data - } finally { - loading.value = false - } - } - - async function fetchTask(taskId: string) { - const { data } = await getTask(taskId) - currentTask.value = data - return data - } - - async function startAgent(taskId: string, startFrom = 'intake') { - loading.value = true - try { - const { data } = await runAgent(taskId, startFrom) - return data - } finally { - loading.value = false - } - } - - return { currentTask, taskList, loading, create, fetchTask, startAgent } -}) -``` - -- [ ] **Step 3: 实现报销入口页 HomeView** - -按开发文档 9.2 节: - -- **对话输入框**:用户输入报销意图(如"我要报这次北京出差的费用") -- **上传按钮**:直接跳转到上传页 -- **最近任务列表**:显示用户最近的报销任务和状态 -- **常用报销类型快捷按钮**: - - "报差旅" - - "看发票能不能报" - - "帮我生成报销单" - - "这张发票为什么不合规?" -- **智能引导提示**:根据用户输入实时提示 - -交互流程:用户输入意图 → 调用 `taskStore.create()` → 跳转到 `/task/{taskId}/upload` - -- [ ] **Step 4: 实现文件上传组件 FileUpload** - -`frontend/src/components/FileUpload.vue`: - -Ant Design Vue 的 `a-upload-dragger` 封装: -- 支持拖拽上传 -- 支持多文件选择 -- 文件类型校验(PDF、JPG、PNG、OFD) -- 单文件大小限制(默认 10MB) -- 上传进度条 -- 预览缩略图 -- 已上传文件列表 -- 删除已上传文件 - -Props: -```typescript -interface Props { - taskId: string - accept?: string // 默认 '.pdf,.jpg,.jpeg,.png,.ofd' - maxFileSize?: number // MB,默认 10 - maxCount?: number // 默认 10 -} -``` - -- [ ] **Step 5: 实现票据上传页 UploadView** - -- 引用 FileUpload 组件 -- 票据类型下拉选择(Ant Design Select): - - 增值税发票 (vat_invoice) - - 火车票 (train_ticket) - - 机票行程单 (flight_itinerary) - - 打车票据 (taxi_receipt) - - 酒店流水 (hotel_bill) - - 支付截图 (payment_screenshot) - - 其他附件 (other_attachment) -- 已上传文件列表展示 -- "开始识别" 按钮 → 调用 `taskStore.startAgent()` → 跳转到草稿页 -- 返回按钮 → 回到首页 - -- [ ] **Step 6: Commit** - -```bash -git add frontend/ -git commit -m "feat: 实现报销入口页和票据上传页" -``` - ---- - -### Task 4.2: 报销草稿页 - -**负责人:** 前端工程师 -**预计工时:** 2 天 -**前置依赖:** Task 4.1 -**可并行于:** Task 4.3(如果两人并行) - -**Files:** -- Create: `frontend/src/views/DraftView.vue` -- Create: `frontend/src/components/ExpenseTable.vue` -- Create: `frontend/src/api/precheck.ts` - -- [ ] **Step 1: 添加预审 API** - -`frontend/src/api/precheck.ts`: - -```typescript -import api from './index' - -export const getDraft = (taskId: string) => - api.get(`/reimbursement/tasks/${taskId}/draft`) - -export const getPrecheckResult = (taskId: string) => - api.get(`/reimbursement/tasks/${taskId}/precheck-result`) -``` - -- [ ] **Step 2: 实现 ExpenseTable 组件** - -`frontend/src/components/ExpenseTable.vue`: - -Ant Design Table 展示费用明细: - -| 列名 | 字段 | 可编辑 | 说明 | -|---|---|---|---| -| 费用类型 | expense_type | ✅ | 下拉选择 | -| 金额 | amount | ✅ | 数字输入 | -| 税额 | tax_amount | ✅ | 数字输入 | -| 发生日期 | occurred_at | ✅ | 日期选择 | -| 城市 | city | ✅ | 文本输入 | -| 商户 | vendor_name | ✅ | 文本输入 | -| 风险等级 | risk_level | ❌ | 彩色标签 | - -风险等级标签颜色映射: -- `low` → 绿色 `green` -- `medium` → 橙色 `orange` -- `high` → 红色 `red` -- `blocked` → 深红 `#cf1322` - -支持行内编辑(点击单元格进入编辑模式)。 - -- [ ] **Step 3: 实现报销草稿页 DraftView** - -按开发文档 9.3 节布局: - -``` -┌─────────────────────────────────────────────┐ -│ 报销草稿 [预审按钮] │ -├─────────────────────────────────────────────┤ -│ 基本信息 │ -│ ┌──────────┬──────────┬──────────┐ │ -│ │ 报销人 │ 部门 │ 成本中心 │ │ -│ └──────────┴──────────┴──────────┘ │ -│ ┌──────────┬──────────┐ │ -│ │ 项目 │ 报销事由 │ │ -│ └──────────┴──────────┘ │ -├─────────────────────────────────────────────┤ -│ 费用明细 │ -│ ┌─────────────────────────────────────────┐ │ -│ │ ExpenseTable 组件 │ │ -│ └─────────────────────────────────────────┘ │ -│ 总金额:¥ 2,380.00 │ -├─────────────────────────────────────────────┤ -│ 票据附件 │ -│ ┌────┐ ┌────┐ ┌────┐ │ -│ │ 📄 │ │ 📄 │ │ 📄 │ (缩略图 + 文件名) │ -│ └────┘ └────┘ └────┘ │ -├─────────────────────────────────────────────┤ -│ 预审状态:⏳ 待预审 │ -│ [执行预审] │ -└─────────────────────────────────────────────┘ -``` - -交互: -- 页面加载时调用 `getDraft(taskId)` 获取草稿数据 -- 编辑字段后暂存到本地 state -- 点击"执行预审" → 调用 `taskStore.startAgent(taskId, 'precheck')` → 跳转到预审结果页 - -- [ ] **Step 4: Commit** - -```bash -git add frontend/ -git commit -m "feat: 实现报销草稿页和费用明细表格组件" -``` - ---- - -### Task 4.3: 预审结果页 + 补件交互页 - -**负责人:** 前端工程师 -**预计工时:** 2.5 天 -**前置依赖:** Task 4.1 -**可并行于:** Task 4.2(如果两人并行) - -**Files:** -- Create: `frontend/src/views/PrecheckView.vue` -- Create: `frontend/src/views/SupplementView.vue` -- Create: `frontend/src/components/RuleHitCard.vue` -- Create: `frontend/src/api/supplement.ts` - -- [ ] **Step 1: 添加补件 API** - -`frontend/src/api/supplement.ts`: - -```typescript -import api from './index' - -export const respondSupplement = (taskId: string, supplementRequestId: string, data: { - responseText: string - documentIds?: string[] -}) => api.post(`/reimbursement/tasks/${taskId}/supplements`, { - supplement_request_id: supplementRequestId, - ...data, -}) - -export const submitTask = (taskId: string, submitTo: string = 'expense_system') => - api.post(`/reimbursement/tasks/${taskId}/submit`, { confirmed: true, submit_to: submitTo }) - -export const getSyncStatus = (taskId: string) => - api.get(`/reimbursement/tasks/${taskId}/sync-status`) -``` - -- [ ] **Step 2: 实现 RuleHitCard 组件** - -`frontend/src/components/RuleHitCard.vue`: - -Ant Design Card 展示单条规则命中: - -``` -┌─────────────────────────────────────────┐ -│ 🔴 住宿费超标 TRAVEL_HOTEL_LIMIT │ -├─────────────────────────────────────────┤ -│ 问题:住宿费超出当前城市和职级标准 │ -│ 制度依据:差旅报销制度-住宿标准 │ -│ 建议:请补充超标说明或发起特殊审批 │ -│ [展开详情] │ -└─────────────────────────────────────────┘ -``` - -Props: -```typescript -interface Props { - ruleCode: string - ruleName: string - severity: string // low / medium / high / blocked - action: string - message: string - suggestion: string - policyRef: string - hitDetail?: object -} -``` - -- [ ] **Step 3: 实现预审结果页 PrecheckView** - -按开发文档 9.4 节: - -- **总体结论卡片**: - - ✅ 通过 → 绿色 - - ⚠️ 需补件 → 橙色 - - 🚫 有阻断 → 红色 -- **风险等级指示**:彩色 Badge -- **通过项列表**:绿色勾选图标 + 规则名称 -- **风险项列表**:使用 RuleHitCard 组件 -- **缺件项列表**:橙色提示 + 补件按钮 -- **操作按钮**: - - "一键补件" → 跳转到补件页(仅在有缺件时显示) - - "确认提交" → 跳转到确认页(仅预审通过时可用) - -交互: -- 页面加载时调用 `getPrecheckResult(taskId)` 获取预审结果 -- 根据结果渲染不同状态 - -- [ ] **Step 4: 实现补件交互页 SupplementView** - -- **待补件清单**:从预审结果的 rule_hits 中过滤出 `require_attachment` / `require_explanation` 类型 -- **每个补件项**: - - 类型标签(补充附件 / 补充说明 / 修改字段) - - 提示文案 - - 操作区域: - - 补充附件:调用 FileUpload 组件 - - 补充说明:文本输入框 -- **提交补件按钮** → 调用 `respondSupplement()` → 跳转回预审页重新预审 - -- [ ] **Step 5: Commit** - -```bash -git add frontend/ -git commit -m "feat: 实现预审结果页和补件交互页" -``` - ---- - -### Task 4.4: 提交确认页 + 审计日志页 - -**负责人:** 前端工程师 -**预计工时:** 1.5 天 -**前置依赖:** Task 4.3 - -**Files:** -- Create: `frontend/src/views/ConfirmView.vue` -- Create: `frontend/src/views/AuditView.vue` -- Create: `frontend/src/api/audit.ts` - -- [ ] **Step 1: 添加审计 API** - -`frontend/src/api/audit.ts`: - -```typescript -import api from './index' - -export const getAuditLogs = (params?: { - target_type?: string - target_id?: string - actor?: string - page?: number - size?: number -}) => api.get('/audit/logs', { params }) -``` - -- [ ] **Step 2: 实现提交确认页 ConfirmView** - -``` -┌─────────────────────────────────────────────┐ -│ 提交确认 │ -├─────────────────────────────────────────────┤ -│ 报销单摘要(不可编辑) │ -│ ┌───────────────────────────────────────┐ │ -│ │ 报销人:张三 部门:技术部 │ │ -│ │ 事由:北京出差 总金额:¥2,380.00 │ │ -│ └───────────────────────────────────────┘ │ -│ │ -│ 费用明细汇总: │ -│ ┌───────────────────────────────────────┐ │ -│ │ 差旅住宿费 ¥1,200.00 │ │ -│ │ 差旅交通费 ¥ 553.00 │ │ -│ │ 差旅餐补 ¥ 627.00 │ │ -│ └───────────────────────────────────────┘ │ -│ │ -│ 附件清单:3 个文件 │ -│ 同步目标:费控系统 │ -│ │ -│ [返回修改] [确认提交] │ -├─────────────────────────────────────────────┤ -│ 同步状态:⏳ 提交中... │ -└─────────────────────────────────────────────┘ -``` - -交互: -- "确认提交" → 调用 `submitTask(taskId)` -- 提交后轮询 `getSyncStatus(taskId)`,展示同步进度 -- 同步成功 → 显示后端单据号 -- 同步失败 → 显示错误信息 + 重试按钮 - -- [ ] **Step 3: 实现审计日志页 AuditView** - -按开发文档 9.5 节(简化版): - -- **Ant Design Timeline** 展示操作记录 -- **筛选栏**:按任务ID、操作类型、时间范围筛选 -- **每条日志**: - - 时间戳 - - 操作人 - - 操作类型(彩色标签) - - 详情(可展开) - -操作类型颜色映射: -- `file_upload` → 蓝色 -- `ocr_recognize` → 青色 -- `agent_call` → 紫色 -- `rule_hit` → 橙色 -- `supplement_request` / `supplement_respond` → 绿色 -- `user_confirm` → 金色 -- `backend_sync` → 灰色 - -- [ ] **Step 4: Commit** - -```bash -git add frontend/ -git commit -m "feat: 实现提交确认页和审计日志页" -``` - ---- - -## 本阶段完成检查 - -- [ ] 首页能创建任务并跳转到上传页 -- [ ] 上传页能上传文件并开始识别 -- [ ] 草稿页能展示费用明细并支持编辑 -- [ ] 预审结果页能展示风险项和缺件项 -- [ ] 补件页能上传附件和填写说明 -- [ ] 确认页能展示摘要和同步状态 -- [ ] 审计日志页能展示操作时间线 -- [ ] 所有页面响应式布局正常 -- [ ] 前端 `npm run build` 无报错 diff --git a/docs/plans/phase-5-integration/README.md b/docs/plans/phase-5-integration/README.md deleted file mode 100644 index 1b3eaaa..0000000 --- a/docs/plans/phase-5-integration/README.md +++ /dev/null @@ -1,259 +0,0 @@ -# Phase 5: 联调与集成(W5-W6) - -> **目标:** 前后端联调跑通完整流程,配置规则种子数据,确保全链路畅通。 -> **周期:** 第 5 ~ 6 周 -> **任务数:** 2 个 -> **可并行:** 联调和种子数据可由不同人并行 -> **前置依赖:** Phase 3 + Phase 4 - ---- - -## 本阶段交付物 - -| 交付物 | 说明 | -|---|---| -| 完整流程跑通 | 从创建任务到同步成功的端到端流程 | -| 规则种子数据 | 差旅报销制度 + 6 条核心规则 + 城市等级标准 | - ---- - -## 任务清单 - -### Task 5.1: 前后端联调 - -**负责人:** 全员参与 -**预计工时:** 3-4 天 -**前置依赖:** Phase 3 + Phase 4 - -**Files:** -- Modify: 多个前后端文件(修复联调问题) - -- [ ] **Step 1: 启动前后端全栈环境** - -```bash -# 启动基础设施 -docker-compose up -d - -# 启动后端 -cd backend -alembic upgrade head -uvicorn app.main:app --reload --port 8000 - -# 启动前端 -cd frontend -npm run dev -``` - -确认: -- PostgreSQL 可连接:`psql -h localhost -U postgres -d x_financial` -- Redis 可连接:`redis-cli ping` -- MinIO 可访问:http://localhost:9001 -- 后端健康检查:http://localhost:8000/health -- 前端可访问:http://localhost:5173 - -- [ ] **Step 2: 跑通完整报销流程(10 步)** - -按开发文档 3.1 节的完整流程: - -**步骤 1 - 创建任务** -1. 访问首页 http://localhost:5173 -2. 在输入框输入"我要报这次北京出差的费用" -3. 点击提交 -4. 验证:任务创建成功,跳转到上传页 - -**步骤 2 - 上传票据** -1. 选择票据类型:增值税发票 -2. 上传模拟发票文件(可用任意 PDF/PNG) -3. 选择票据类型:火车票 -4. 上传模拟火车票文件 -5. 验证:文件列表显示 2 个文件 - -**步骤 3 - 启动 Agent 识别** -1. 点击"开始识别" -2. 等待 Agent 处理(观察后端日志) -3. 验证:跳转到草稿页,显示识别结果 - -**步骤 4 - 查看报销草稿** -1. 确认费用明细已自动填充 -2. 检查金额、商户、日期等字段 -3. 验证:可编辑字段能修改 - -**步骤 5 - 执行预审** -1. 点击"执行预审" -2. 等待规则引擎执行 -3. 验证:跳转到预审结果页 - -**步骤 6 - 查看预审结果** -1. 检查总体结论 -2. 查看风险项(如有) -3. 查看缺件项(如有) -4. 验证:规则命中详情展示正确 - -**步骤 7 - 补件(如需要)** -1. 点击"一键补件" -2. 在补件页上传缺失附件 -3. 提交补件 -4. 验证:跳转回预审页,重新预审 - -**步骤 8 - 确认提交** -1. 确认报销单摘要 -2. 点击"确认提交" -3. 等待同步状态更新 -4. 验证:同步状态变为 success - -**步骤 9 - 查看审计日志** -1. 访问审计日志页 -2. 按任务ID筛选 -3. 验证:所有操作步骤都有记录 - -**步骤 10 - 查看后端 Swagger** -1. 访问 http://localhost:8000/docs -2. 验证所有 API 文档正确 - -- [ ] **Step 3: 修复联调过程中发现的问题** - -常见问题检查清单: -- [ ] API 响应格式前后端一致(字段名、嵌套结构) -- [ ] 日期格式统一(ISO 8601) -- [ ] 金额精度(Decimal vs Number) -- [ ] 错误处理(前端能正确显示后端错误信息) -- [ ] 文件上传大小限制(前端 + 后端 + MinIO) -- [ ] 跨域配置正确 -- [ ] 路由跳转正常 -- [ ] Loading 状态显示 -- [ ] 空状态展示 - -- [ ] **Step 4: 压力测试关键接口** - -- `POST /tasks` 创建 100 个任务 -- `GET /tasks` 列表查询响应时间 < 500ms -- `POST /tasks/{id}/documents` 上传 10 个文件 -- `POST /tasks/{id}/agent/run` Agent 执行时间 < 30s - -- [ ] **Step 5: Commit** - -```bash -git add . -git commit -m "fix: 前后端联调修复(完整流程跑通)" -``` - ---- - -### Task 5.2: 规则配置与种子数据 - -**负责人:** 全栈/Agent 工程师 -**预计工时:** 2 天 -**前置依赖:** Phase 2(规则引擎) -**可并行于:** Task 5.1 - -**Files:** -- Create: `backend/alembic/seed/expense_policies.sql` -- Create: `backend/alembic/seed/expense_rules.sql` -- Create: `backend/alembic/seed/city_levels.sql` -- Create: `backend/alembic/seed/hotel_limits.sql` -- Create: `backend/scripts/seed_data.py` - -- [ ] **Step 1: 编写城市等级数据** - -`backend/alembic/seed/city_levels.sql`: - -按典型企业标准配置: -- **一线城市**:北京、上海、广州、深圳 -- **二线城市**:杭州、南京、成都、武汉、重庆、天津、苏州、西安 -- **三线城市**:其他城市 - -```sql -CREATE TABLE IF NOT EXISTS city_level ( - city_name VARCHAR(50) PRIMARY KEY, - level VARCHAR(10) NOT NULL -- tier1 / tier2 / tier3 -); - -INSERT INTO city_level (city_name, level) VALUES -('北京', 'tier1'), ('上海', 'tier1'), ('广州', 'tier1'), ('深圳', 'tier1'), -('杭州', 'tier2'), ('南京', 'tier2'), ('成都', 'tier2'), ('武汉', 'tier2'), -('重庆', 'tier2'), ('天津', 'tier2'), ('苏州', 'tier2'), ('西安', 'tier2'); -``` - -- [ ] **Step 2: 编写住宿标准数据** - -`backend/alembic/seed/hotel_limits.sql`: - -```sql -CREATE TABLE IF NOT EXISTS hotel_limit ( - city_level VARCHAR(10) NOT NULL, - job_level VARCHAR(20) NOT NULL, -- manager / senior / staff - limit_per_night DECIMAL(10,2) NOT NULL, - PRIMARY KEY (city_level, job_level) -); - -INSERT INTO hotel_limit (city_level, job_level, limit_per_night) VALUES -('tier1', 'manager', 800.00), ('tier1', 'senior', 600.00), ('tier1', 'staff', 500.00), -('tier2', 'manager', 600.00), ('tier2', 'senior', 450.00), ('tier2', 'staff', 350.00), -('tier3', 'manager', 500.00), ('tier3', 'senior', 350.00), ('tier3', 'staff', 300.00); -``` - -- [ ] **Step 3: 编写规则种子数据** - -`backend/alembic/seed/expense_rules.sql` — 预置开发文档 7.2 节的 3 条示例规则 + MVP 需要的 6 条核心规则: - -1. `TRAVEL_HOTEL_LIMIT` — 住宿费标准校验(severity: medium, action: require_explanation) -2. `HOTEL_BILL_REQUIRED` — 住宿费必须上传酒店流水(severity: medium, action: require_attachment) -3. `DUPLICATE_INVOICE_CHECK` — 重复发票检查(severity: blocked, action: block) -4. `REQUIRED_FIELDS_CHECK` — 必填字段校验(severity: medium, action: warn) -5. `AMOUNT_ABNORMAL_CHECK` — 金额异常检查(severity: high, action: require_explanation) -6. `DATE_VALIDITY_CHECK` — 日期合理性校验(severity: low, action: warn) -7. `EXPENSE_TYPE_MATCH_CHECK` — 费用类型匹配校验(severity: low, action: warn) -8. `INVOICE_TITLE_CHECK` — 发票抬头校验(severity: high, action: require_explanation) -9. `TRIP_PERIOD_MATCH_CHECK` — 出差期间匹配校验(severity: medium, action: warn) - -每条规则包含完整的 condition_json、action、severity、message_template、policy_ref。 - -- [ ] **Step 4: 编写数据初始化脚本** - -`backend/scripts/seed_data.py` — 一键初始化所有种子数据: - -```python -"""初始化种子数据""" -import asyncio -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession -from app.core.config import settings -# ... 读取 SQL 文件并执行 - -async def main(): - engine = create_async_engine(settings.DATABASE_URL) - async with engine.begin() as conn: - # 按顺序执行 seed SQL - for sql_file in ['city_levels.sql', 'hotel_limits.sql', 'expense_rules.sql']: - with open(f'alembic/seed/{sql_file}') as f: - await conn.execute(f.read()) - print("Seed data loaded successfully!") - -if __name__ == "__main__": - asyncio.run(main()) -``` - -- [ ] **Step 5: 验证种子数据** - -Run: `cd backend && python scripts/seed_data.py` -验证: -- 城市等级表有数据 -- 住宿标准表有数据 -- 规则表有 9 条规则且全部 enabled - -- [ ] **Step 6: Commit** - -```bash -git add backend/ -git commit -m "feat: 添加差旅报销制度和规则种子数据" -``` - ---- - -## 本阶段完成检查 - -- [ ] 完整报销流程(创建→上传→识别→草稿→预审→补件→提交→同步)端到端跑通 -- [ ] 前后端 API 格式一致,无字段不匹配 -- [ ] 错误场景有正确提示(上传失败、OCR 失败、同步失败) -- [ ] 种子数据加载成功,规则引擎使用种子数据执行预审 -- [ ] Swagger 文档 http://localhost:8000/docs 可访问 -- [ ] 审计日志记录了完整操作链路 diff --git a/docs/plans/phase-6-testing-polish/README.md b/docs/plans/phase-6-testing-polish/README.md deleted file mode 100644 index 78b1a49..0000000 --- a/docs/plans/phase-6-testing-polish/README.md +++ /dev/null @@ -1,553 +0,0 @@ -# Phase 6: 测试与打磨(W7-W8) - -> **目标:** 完善集成测试、E2E 测试、修复 Bug、UI 打磨、编写部署文档,准备 Demo 演示。 -> **周期:** 第 7 ~ 8 周 -> **任务数:** 4 个 -> **可并行:** Task 6.1 / 6.2 / 6.3 可并行 -> **前置依赖:** Phase 5 - ---- - -## 本阶段交付物 - -| 交付物 | 说明 | -|---|---| -| 后端集成测试 | 完整报销流程的自动化测试 | -| 前端 E2E 测试 | Playwright 自动化测试(可选) | -| Bug 修复 + UI 打磨 | 视觉和交互优化 | -| 部署文档 | README + 部署指南 + API 文档 | - ---- - -## 任务清单 - -### Task 6.1: 后端集成测试 - -**负责人:** 后端工程师 A -**预计工时:** 3 天 -**前置依赖:** Phase 5 - -**Files:** -- Create: `backend/tests/test_integration_flow.py` -- Create: `backend/tests/helpers.py`(测试辅助函数) -- Modify: `backend/tests/conftest.py`(添加测试数据库 fixture) - -- [ ] **Step 1: 更新 conftest.py 添加测试数据库 fixture** - -`backend/tests/conftest.py`: - -```python -import pytest -import asyncio -from httpx import AsyncClient, ASGITransport -from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession -from app.models.base import Base -from app.main import app -from app.core.database import get_db - -# 测试数据库 URL -TEST_DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/x_financial_test" - -test_engine = create_async_engine(TEST_DATABASE_URL, echo=False) -test_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) - -@pytest.fixture(scope="session") -def event_loop(): - loop = asyncio.new_event_loop() - yield loop - loop.close() - -@pytest.fixture(autouse=True) -async def setup_database(): - """每个测试前创建表,测试后清理""" - async with test_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - yield - async with test_engine.begin() as conn: - await conn.run_sync(Base.metadata.drop_all) - -@pytest.fixture -async def db(): - async with test_session() as session: - yield session - -@pytest.fixture -async def client(db): - async def override_get_db(): - yield db - - app.dependency_overrides[get_db] = override_get_db - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://test") as ac: - yield ac - app.dependency_overrides.clear() -``` - -- [ ] **Step 2: 编写测试辅助函数** - -`backend/tests/helpers.py`: - -```python -from httpx import AsyncClient - -async def create_task(client: AsyncClient, user_id="U001", company_id="C001", intent="报北京出差费用") -> dict: - resp = await client.post("/api/v1/reimbursement/tasks", json={ - "user_id": user_id, "company_id": company_id, "user_intent": intent - }) - assert resp.status_code == 201 - return resp.json() - -async def upload_document(client: AsyncClient, task_id: str, document_type: str, filename: str = "test.pdf") -> dict: - files = {"file": (filename, b"fake file content", "application/pdf")} - resp = await client.post( - f"/api/v1/reimbursement/tasks/{task_id}/documents", - files=files, - data={"document_type": document_type} - ) - assert resp.status_code == 201 - return resp.json() - -async def run_agent(client: AsyncClient, task_id: str, start_from="intake") -> dict: - resp = await client.post( - f"/api/v1/reimbursement/tasks/{task_id}/agent/run", - json={"start_from": start_from, "mode": "precheck"} - ) - assert resp.status_code == 200 - return resp.json() - -async def get_draft(client: AsyncClient, task_id: str) -> dict: - resp = await client.get(f"/api/v1/reimbursement/tasks/{task_id}/draft") - assert resp.status_code == 200 - return resp.json() - -async def get_precheck_result(client: AsyncClient, task_id: str) -> dict: - resp = await client.get(f"/api/v1/reimbursement/tasks/{task_id}/precheck-result") - assert resp.status_code == 200 - return resp.json() - -async def respond_supplement(client: AsyncClient, task_id: str, supplement_id: str, text: str) -> dict: - resp = await client.post( - f"/api/v1/reimbursement/tasks/{task_id}/supplements", - json={"supplement_request_id": supplement_id, "response_text": text} - ) - assert resp.status_code == 200 - return resp.json() - -async def submit_task(client: AsyncClient, task_id: str) -> dict: - resp = await client.post( - f"/api/v1/reimbursement/tasks/{task_id}/submit", - json={"confirmed": True, "submit_to": "expense_system"} - ) - assert resp.status_code == 200 - return resp.json() - -async def get_sync_status(client: AsyncClient, task_id: str) -> dict: - resp = await client.get(f"/api/v1/reimbursement/tasks/{task_id}/sync-status") - assert resp.status_code == 200 - return resp.json() -``` - -- [ ] **Step 3: 编写完整流程集成测试** - -`backend/tests/test_integration_flow.py`: - -```python -import pytest -from tests.helpers import * - -@pytest.mark.asyncio -async def test_full_reimbursement_flow(client): - """完整报销流程:创建→上传→识别→草稿→预审→补件→提交→同步""" - - # 1. 创建任务 - task = await create_task(client, intent="我要报这次北京出差的费用") - task_id = task["task_id"] - assert task["status"] == "material_collecting" - - # 2. 上传票据 - doc1 = await upload_document(client, task_id, "vat_invoice", "invoice.pdf") - assert doc1["ocr_status"] == "pending" - - doc2 = await upload_document(client, task_id, "train_ticket", "train.pdf") - assert doc2["ocr_status"] == "pending" - - doc3 = await upload_document(client, task_id, "hotel_bill", "hotel.pdf") - - # 3. 启动 Agent(使用 mock OCR) - result = await run_agent(client, task_id, start_from="intake") - assert result["status"] in ["draft_generated", "prechecking", "need_supplement", "pending_user_confirm"] - - # 4. 获取草稿 - draft = await get_draft(client, task_id) - assert draft["reimbursement_id"] is not None - assert len(draft["items"]) > 0 - assert draft["total_amount"] > 0 - - # 5. 获取预审结果 - precheck = await get_precheck_result(client, task_id) - assert "risk_level" in precheck - assert "precheck_status" in precheck - assert "rule_hits" in precheck - - # 6. 如果需要补件 - if precheck["precheck_status"] == "need_supplement": - # 找到需要补件的规则 - for hit in precheck["rule_hits"]: - if hit["action"] == "require_attachment": - # 补充附件 - await upload_document(client, task_id, "hotel_bill", "hotel_supplement.pdf") - await respond_supplement(client, task_id, hit.get("id", "S001"), "已补充酒店流水") - - # 重新预审 - await run_agent(client, task_id, start_from="precheck") - precheck2 = await get_precheck_result(client, task_id) - - # 7. 确认提交 - submit = await submit_task(client, task_id) - assert submit["status"] == "submitting" - - # 8. 检查同步状态 - sync = await get_sync_status(client, task_id) - assert sync["sync_status"] in ["success", "pending"] - -@pytest.mark.asyncio -async def test_create_task_without_intent(client): - """测试不提供意图时创建任务""" - resp = await client.post("/api/v1/reimbursement/tasks", json={ - "user_id": "U001", "company_id": "C001" - }) - assert resp.status_code == 422 # Validation error - -@pytest.mark.asyncio -async def test_get_nonexistent_task(client): - """测试查询不存在的任务""" - resp = await client.get("/api/v1/reimbursement/tasks/nonexistent-id") - assert resp.status_code == 404 - -@pytest.mark.asyncio -async def test_list_tasks_pagination(client): - """测试任务列表分页""" - # 创建多个任务 - for i in range(5): - await create_task(client, intent=f"test task {i}") - - # 测试分页 - resp = await client.get("/api/v1/reimbursement/tasks?page=1&size=3") - assert resp.status_code == 200 - data = resp.json() - assert data["total"] >= 5 - assert len(data["items"]) <= 3 -``` - -- [ ] **Step 4: 编写规则引擎集成测试** - -测试每条规则对真实报销数据的命中情况: -- 住宿费超标 → 命中 `TRAVEL_HOTEL_LIMIT` -- 缺少酒店流水 → 命中 `HOTEL_BILL_REQUIRED` -- 重复发票 → 命中 `DUPLICATE_INVOICE_CHECK` -- 合规报销 → 无命中 - -- [ ] **Step 5: 确保所有测试通过** - -Run: `cd backend && pytest tests/ -v --tb=short` -Expected: All PASS - -- [ ] **Step 6: Commit** - -```bash -git add backend/ -git commit -m "test: 添加完整报销流程集成测试" -``` - ---- - -### Task 6.2: 前端 E2E 测试(可选) - -**负责人:** 前端工程师 -**预计工时:** 2 天 -**前置依赖:** Phase 5 -**可并行于:** Task 6.1、6.3 - -**Files:** -- Create: `frontend/e2e/reimbursement.spec.ts` -- Create: `frontend/playwright.config.ts` - -- [ ] **Step 1: 安装 Playwright** - -```bash -cd frontend -npm install -D @playwright/test -npx playwright install chromium -``` - -- [ ] **Step 2: 配置 Playwright** - -`frontend/playwright.config.ts`: - -```typescript -import { defineConfig } from '@playwright/test' - -export default defineConfig({ - testDir: './e2e', - baseURL: 'http://localhost:5173', - use: { - headless: true, - screenshot: 'only-on-failure', - trace: 'retain-on-failure', - }, - webServer: { - command: 'npm run dev', - port: 5173, - reuseExistingServer: true, - }, -}) -``` - -- [ ] **Step 3: 编写核心流程 E2E 测试** - -`frontend/e2e/reimbursement.spec.ts`: - -```typescript -import { test, expect } from '@playwright/test' - -test('完整报销流程', async ({ page }) => { - // 1. 访问首页 - await page.goto('/') - await expect(page.locator('h1')).toContainText('报销') - - // 2. 输入报销意图 - await page.fill('input[placeholder*="报销"]', '我要报这次北京出差的费用') - await page.click('button:has-text("提交")') - - // 3. 跳转到上传页 - await expect(page).toHaveURL(/\/task\/.*\/upload/) - - // 4. 上传文件 - await page.setInputFiles('input[type="file"]', 'tests/fixtures/invoice.pdf') - - // 5. 选择票据类型 - await page.selectOption('select', 'vat_invoice') - - // 6. 开始识别 - await page.click('button:has-text("开始识别")') - - // 7. 跳转到草稿页 - await expect(page).toHaveURL(/\/draft/, { timeout: 30000 }) - - // 8. 执行预审 - await page.click('button:has-text("执行预审")') - - // 9. 跳转到预审结果页 - await expect(page).toHaveURL(/\/precheck/, { timeout: 30000 }) -}) -``` - -- [ ] **Step 4: 运行 E2E 测试** - -Run: `cd frontend && npx playwright test` -Expected: All PASS - -- [ ] **Step 5: Commit** - -```bash -git add frontend/ -git commit -m "test: 添加前端 E2E 测试" -``` - ---- - -### Task 6.3: Bug 修复与 UI 打磨 - -**负责人:** 全员参与 -**预计工时:** 3 天 -**前置依赖:** Phase 5 -**可并行于:** Task 6.1、6.2 - -- [ ] **Step 1: UI 走查清单** - -逐页面检查: - -| 页面 | 检查项 | -|---|---| -| 首页 | 布局、输入框交互、快捷按钮、最近任务列表 | -| 上传页 | 拖拽上传、文件预览、票据类型选择、进度条 | -| 草稿页 | 表格编辑、金额汇总、附件预览、预审按钮 | -| 预审结果页 | 结论卡片、风险项展示、规则命中详情 | -| 补件页 | 补件清单、上传/回复交互、提交反馈 | -| 确认页 | 摘要展示、同步状态轮询、成功/失败状态 | -| 审计日志页 | 时间线展示、筛选功能 | - -- [ ] **Step 2: 修复共性问题** - -- [ ] 响应式布局适配(1280px / 1024px / 768px 断点) -- [ ] Loading 状态:所有异步操作加 loading 指示器 -- [ ] 错误提示:API 错误统一使用 Ant Design Message 提示 -- [ ] 空状态:无数据时展示空状态插画和文案 -- [ ] 表单校验:必填项红框提示 + 校验文案 -- [ ] 金额格式化:千分位 + 两位小数 + ¥ 前缀 -- [ ] 日期格式化:YYYY-MM-DD -- [ ] 确认弹窗:删除、提交等危险操作二次确认 - -- [ ] **Step 3: 添加 Demo 展示数据** - -在首页添加"体验 Demo"按钮,一键生成演示数据: -- 创建一个已完成全流程的报销任务 -- 包含 3 条费用明细 -- 有规则命中记录 -- 有审计日志 - -- [ ] **Step 4: 性能优化** - -- [ ] 路由懒加载(已配置) -- [ ] 表格虚拟滚动(如果明细很多) -- [ ] 图片懒加载 -- [ ] API 请求去重/缓存 - -- [ ] **Step 5: Commit** - -```bash -git add . -git commit -m "fix: UI 打磨和 Bug 修复" -``` - ---- - -### Task 6.4: 部署与文档 - -**负责人:** 后端工程师 B + 前端工程师 -**预计工时:** 2 天 -**前置依赖:** Task 6.3 - -**Files:** -- Create: `docker-compose.prod.yml` -- Create: `nginx.conf` -- Modify: `README.md` -- Create: `docs/deployment.md` - -- [ ] **Step 1: 编写生产 Docker Compose** - -`docker-compose.prod.yml`: - -```yaml -version: "3.8" -services: - nginx: - image: nginx:alpine - ports: - - "80:80" - volumes: - - ./nginx.conf:/etc/nginx/conf.d/default.conf - - ./frontend/dist:/usr/share/nginx/html - depends_on: - - backend - - backend: - build: ./backend - environment: - - DATABASE_URL=postgresql+asyncpg://postgres:${DB_PASSWORD}@postgres:5432/x_financial - - REDIS_URL=redis://redis:6379/0 - - MINIO_ENDPOINT=minio:9000 - depends_on: - - postgres - - redis - - minio - - postgres: - image: postgres:15 - environment: - POSTGRES_DB: x_financial - POSTGRES_PASSWORD: ${DB_PASSWORD} - volumes: - - pgdata:/var/lib/postgresql/data - - redis: - image: redis:7-alpine - - minio: - image: minio/minio - command: server /data - environment: - MINIO_ROOT_USER: ${MINIO_USER} - MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD} - volumes: - - minio_data:/data - -volumes: - pgdata: - minio_data: -``` - -- [ ] **Step 2: 编写 Nginx 配置** - -`nginx.conf`: - -```nginx -server { - listen 80; - server_name localhost; - - # 前端静态文件 - location / { - root /usr/share/nginx/html; - try_files $uri $uri/ /index.html; - } - - # 后端 API 代理 - location /api/ { - proxy_pass http://backend:8000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_read_timeout 120s; - } -} -``` - -- [ ] **Step 3: 编写部署文档** - -`docs/deployment.md` — 包含: -- 环境要求(Docker、Docker Compose) -- 配置说明(.env 文件) -- 启动步骤 -- 停止和重启 -- 数据库迁移 -- 种子数据初始化 -- 日志查看 -- 常见问题排查 - -- [ ] **Step 4: 更新 README** - -项目 README 包含: -- 项目简介和架构图 -- 快速启动(开发环境) -- 技术栈说明 -- 目录结构 -- 开发指南 -- API 文档链接 - -- [ ] **Step 5: 确认 Swagger 文档完整** - -访问 http://localhost:8000/docs,确认: -- 所有 API 端点都有描述 -- 请求/响应示例完整 -- 错误码说明完整 - -- [ ] **Step 6: Commit** - -```bash -git add . -git commit -m "docs: 添加部署文档、Nginx 配置、生产 Docker Compose" -``` - ---- - -## 本阶段完成检查 - -- [ ] `cd backend && pytest tests/ -v` 全部通过 -- [ ] `cd frontend && npx playwright test` 全部通过(如配置) -- [ ] `cd frontend && npm run build` 无报错 -- [ ] 完整报销流程在浏览器中手动测试无问题 -- [ ] 所有页面响应式布局正常 -- [ ] `docker-compose -f docker-compose.prod.yml up -d` 能启动 -- [ ] README 和部署文档完整 -- [ ] Swagger API 文档完整 -- [ ] Demo 数据展示正常 diff --git a/docs/superpowers/plans/2026-04-24-ai-reimbursement-mvp.md b/docs/superpowers/plans/2026-04-24-ai-reimbursement-mvp.md deleted file mode 100644 index 18dfec0..0000000 --- a/docs/superpowers/plans/2026-04-24-ai-reimbursement-mvp.md +++ /dev/null @@ -1,1602 +0,0 @@ -# AI 报销预审中台 MVP 实施计划 - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 在 8 周内完成 AI 报销预审中台的 MVP,跑通「上传材料 → OCR 识别 → 草稿生成 → 规则预审 → 补件交互 → 用户确认 → 模拟同步」完整闭环,优先支持差旅报销场景。 - -**Architecture:** 采用模块化单体架构,前端 Vue3 + Ant Design Vue,后端 Python FastAPI,数据库 PostgreSQL + Redis,文件存储 MinIO,OCR 先用百度/腾讯云 API 封装。Agent 编排用自研轻量状态机 + LangGraph(可选),规则引擎自研 JSON Rule Engine。6 层架构:用户入口 → AI 操作层 → Agent 层 → 影子账本 → Policy & Evidence → System Adapter。 - -**Tech Stack:** -- 前端:Vue 3 + TypeScript + Ant Design Vue + Vite + Pinia -- 后端:Python 3.11+ / FastAPI + SQLAlchemy + Alembic + Pydantic v2 -- 数据库:PostgreSQL 15 + Redis 7 -- 文件存储:MinIO(S3 兼容) -- OCR:百度云 OCR API(增值税发票、火车票、机票行程单) -- 规则引擎:自研 JSON Rule Engine -- Agent:自研 Orchestrator 状态机 + 大模型 API(OpenAI / 国内模型) -- 部署:Docker Compose - ---- - -## 团队分工建议(3-5 人) - -| 角色 | 人数 | 职责 | -|---|---|---| -| 后端工程师 A | 1 | 核心后端:任务管理、影子账本、Agent 编排、规则引擎 | -| 后端工程师 B | 1 | OCR 集成、文件服务、适配器层、审计日志 | -| 前端工程师 | 1-2 | 所有页面与组件(可拆分为两人并行) | -| 全栈/Agent 工程师 | 1 | Agent Prompt 设计、大模型集成、规则配置 | - ---- - -## 总体里程碑 - -| 周 | 里程碑 | 核心交付 | -|---|---|---| -| W1 | 项目基建 + 数据模型 | 项目骨架、数据库 schema、开发环境 | -| W2-W3 | 后端核心服务 | 任务/票据/OCR/规则引擎 API | -| W3-W4 | Agent 编排 + 影子账本 | Orchestrator 状态机、5 个 Agent | -| W4-W5 | 前端核心页面 | 入口/上传/草稿/预审/补件/确认 | -| W5-W6 | 前后端联调 + 规则配置 | 完整流程跑通 | -| W7-W8 | 集成测试 + 打磨 + 部署 | E2E 测试、修复、演示 Demo | - ---- - -## Phase 1: 项目基建(W1) - -### Task 1.1: 后端项目骨架搭建 - -**Files:** -- Create: `backend/app/__init__.py` -- Create: `backend/app/main.py` -- Create: `backend/app/core/config.py` -- Create: `backend/app/core/database.py` -- Create: `backend/app/core/dependencies.py` -- Create: `backend/app/api/__init__.py` -- Create: `backend/app/api/v1/__init__.py` -- Create: `backend/app/api/v1/router.py` -- Create: `backend/app/models/__init__.py` -- Create: `backend/app/schemas/__init__.py` -- Create: `backend/app/services/__init__.py` -- Create: `backend/requirements.txt` -- Create: `backend/pyproject.toml` -- Create: `backend/Dockerfile` -- Create: `backend/alembic.ini` -- Create: `backend/alembic/env.py` -- Test: `backend/tests/__init__.py` -- Test: `backend/tests/conftest.py` -- Test: `backend/tests/test_health.py` - -- [ ] **Step 1: 初始化后端项目结构** - -创建 FastAPI 项目骨架,使用以下目录结构: - -``` -backend/ -├── app/ -│ ├── __init__.py -│ ├── main.py # FastAPI 应用入口 -│ ├── core/ -│ │ ├── config.py # Settings(Pydantic BaseSettings) -│ │ ├── database.py # SQLAlchemy async engine + session -│ │ └── dependencies.py # 通用依赖注入(db session, 当前用户等) -│ ├── api/ -│ │ └── v1/ -│ │ ├── __init__.py -│ │ ├── router.py # v1 路由聚合 -│ │ ├── tasks.py # 报销任务 API -│ │ ├── documents.py # 票据附件 API -│ │ ├── precheck.py # 预审结果 API -│ │ └── supplements.py # 补件 API -│ ├── models/ # SQLAlchemy ORM models -│ │ ├── __init__.py -│ │ ├── task.py -│ │ ├── reimbursement.py -│ │ ├── document.py -│ │ ├── rule.py -│ │ └── audit.py -│ ├── schemas/ # Pydantic schemas -│ │ ├── __init__.py -│ │ ├── task.py -│ │ ├── reimbursement.py -│ │ ├── document.py -│ │ └── rule.py -│ ├── services/ # 业务逻辑层 -│ │ ├── __init__.py -│ │ ├── task_service.py -│ │ ├── document_service.py -│ │ ├── ocr_service.py -│ │ ├── rule_engine.py -│ │ └── sync_service.py -│ └── agents/ # Agent 编排层 -│ ├── __init__.py -│ ├── orchestrator.py -│ ├── intake_agent.py -│ ├── parse_agent.py -│ ├── rule_check_agent.py -│ ├── explain_agent.py -│ └── sync_agent.py -├── alembic/ -│ ├── env.py -│ └── versions/ -├── alembic.ini -├── requirements.txt -├── pyproject.toml -├── Dockerfile -└── tests/ - ├── __init__.py - ├── conftest.py - └── test_health.py -``` - -- [ ] **Step 2: 编写核心配置文件** - -`backend/app/core/config.py` 使用 Pydantic BaseSettings: - -```python -from pydantic_settings import BaseSettings - -class Settings(BaseSettings): - # App - APP_NAME: str = "AI Reimbursement Agent" - APP_VERSION: str = "0.1.0" - DEBUG: bool = True - - # Database - DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/x_financial" - - # Redis - REDIS_URL: str = "redis://localhost:6379/0" - - # MinIO / S3 - MINIO_ENDPOINT: str = "localhost:9000" - MINIO_ACCESS_KEY: str = "minioadmin" - MINIO_SECRET_KEY: str = "minioadmin" - MINIO_BUCKET: str = "reimbursement" - - # OCR - OCR_PROVIDER: str = "baidu" # baidu | tencent | mock - BAIDU_OCR_API_KEY: str = "" - BAIDU_OCR_SECRET_KEY: str = "" - - # LLM - LLM_PROVIDER: str = "openai" - LLM_API_KEY: str = "" - LLM_MODEL: str = "gpt-4o-mini" - LLM_BASE_URL: str = "" - - class Config: - env_file = ".env" - -settings = Settings() -``` - -- [ ] **Step 3: 编写数据库连接和 FastAPI 入口** - -`backend/app/core/database.py`: - -```python -from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession -from sqlalchemy.orm import DeclarativeBase -from app.core.config import settings - -engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG) -async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - -class Base(DeclarativeBase): - pass - -async def get_db() -> AsyncSession: - async with async_session() as session: - yield session -``` - -`backend/app/main.py`: - -```python -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from app.core.config import settings -from app.api.v1.router import api_router - -app = FastAPI(title=settings.APP_NAME, version=settings.APP_VERSION) - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -app.include_router(api_router, prefix="/api/v1") - -@app.get("/health") -async def health(): - return {"status": "ok", "version": settings.APP_VERSION} -``` - -- [ ] **Step 4: 编写 requirements.txt** - -``` -fastapi==0.115.0 -uvicorn[standard]==0.30.0 -sqlalchemy[asyncio]==2.0.35 -asyncpg==0.30.0 -alembic==1.13.0 -pydantic==2.9.0 -pydantic-settings==2.5.0 -python-multipart==0.0.9 -httpx==0.27.0 -redis==5.1.0 -minio==7.2.9 -python-jose[cryptography]==3.3.0 -passlib[bcrypt]==1.7.4 -pytest==8.3.0 -pytest-asyncio==0.24.0 -httpx # for TestClient -``` - -- [ ] **Step 5: 编写健康检查测试** - -`backend/tests/conftest.py`: - -```python -import pytest -from httpx import AsyncClient, ASGITransport -from app.main import app - -@pytest.fixture -async def client(): - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://test") as ac: - yield ac -``` - -`backend/tests/test_health.py`: - -```python -import pytest - -@pytest.mark.asyncio -async def test_health(client): - response = await client.get("/health") - assert response.status_code == 200 - data = response.json() - assert data["status"] == "ok" -``` - -- [ ] **Step 6: 运行测试确认骨架可用** - -Run: `cd backend && pip install -r requirements.txt && pytest tests/test_health.py -v` -Expected: PASS - -- [ ] **Step 7: Commit** - -```bash -git add backend/ -git commit -m "feat: 初始化后端项目骨架(FastAPI + SQLAlchemy + Alembic)" -``` - ---- - -### Task 1.2: 数据库 Schema + 迁移 - -**Files:** -- Create: `backend/app/models/base.py` -- Create: `backend/app/models/task.py` -- Create: `backend/app/models/reimbursement.py` -- Create: `backend/app/models/document.py` -- Create: `backend/app/models/rule.py` -- Create: `backend/app/models/audit.py` -- Create: `backend/app/models/enums.py` -- Modify: `backend/app/models/__init__.py` -- Test: `backend/tests/test_models.py` - -- [ ] **Step 1: 定义枚举类型** - -`backend/app/models/enums.py`: - -```python -import enum - -class TaskStatus(str, enum.Enum): - CREATED = "created" - MATERIAL_COLLECTING = "material_collecting" - PARSING = "parsing" - DRAFT_GENERATED = "draft_generated" - PRECHECKING = "prechecking" - NEED_SUPPLEMENT = "need_supplement" - PENDING_USER_CONFIRM = "pending_user_confirm" - SUBMITTING = "submitting" - SYNCED = "synced" - SYNC_FAILED = "sync_failed" - CLOSED = "closed" - -class ExpenseType(str, enum.Enum): - TRAVEL_TRANSPORT = "travel_transport" - TRAVEL_HOTEL = "travel_hotel" - TRAVEL_MEAL = "travel_meal" - LOCAL_TRANSPORT = "local_transport" - BUSINESS_MEAL = "business_meal" - OFFICE_SUPPLY = "office_supply" - COMMUNICATION = "communication" - OTHER = "other" - -class DocumentType(str, enum.Enum): - VAT_INVOICE = "vat_invoice" - TRAIN_TICKET = "train_ticket" - FLIGHT_ITINERARY = "flight_itinerary" - TAXI_RECEIPT = "taxi_receipt" - HOTEL_BILL = "hotel_bill" - PAYMENT_SCREENSHOT = "payment_screenshot" - TRAVEL_ORDER = "travel_order" - OTHER_ATTACHMENT = "other_attachment" - -class RiskLevel(str, enum.Enum): - LOW = "low" - MEDIUM = "medium" - HIGH = "high" - BLOCKED = "blocked" - -class RuleAction(str, enum.Enum): - PASS = "pass" - WARN = "warn" - REQUIRE_EXPLANATION = "require_explanation" - REQUIRE_ATTACHMENT = "require_attachment" - REQUIRE_APPROVAL = "require_approval" - BLOCK = "block" - -class SyncStatus(str, enum.Enum): - SUCCESS = "success" - FAILED = "failed" - RETRYING = "retrying" - PENDING = "pending" -``` - -- [ ] **Step 2: 定义所有 ORM 模型** - -`backend/app/models/base.py`: - -```python -import uuid -from datetime import datetime -from sqlalchemy import String, DateTime, func -from sqlalchemy.orm import Mapped, mapped_column - -def generate_id(): - return str(uuid.uuid4()) - -class TimestampMixin: - created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) - -class IDMixin: - id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_id) -``` - -`backend/app/models/task.py` — 包含 `ReimbursementTask`,字段按文档 5.2.1 节: - -```python -from sqlalchemy import String, Text, ForeignKey -from sqlalchemy.orm import Mapped, mapped_column, relationship -from app.models.base import IDMixin, TimestampMixin, Base -from app.models.enums import TaskStatus - -class ReimbursementTask(IDMixin, TimestampMixin, Base): - __tablename__ = "reimbursement_task" - - user_id: Mapped[str] = mapped_column(String(36), index=True) - company_id: Mapped[str] = mapped_column(String(36), index=True) - task_type: Mapped[str] = mapped_column(String(50), default="travel_expense") - status: Mapped[TaskStatus] = mapped_column(default=TaskStatus.CREATED) - user_intent: Mapped[str | None] = mapped_column(Text, nullable=True) - current_agent: Mapped[str | None] = mapped_column(String(50), nullable=True) - - # relationships - documents = relationship("ExpenseDocument", back_populates="task", lazy="selectin") - reimbursement = relationship("ShadowReimbursement", back_populates="task", uselist=False, lazy="selectin") -``` - -`backend/app/models/reimbursement.py` — 包含 `ShadowReimbursement`, `ReimbursementItem`, `SupplementRequest`, `SyncRecord`,字段按文档 5.2.2 ~ 5.2.4, 5.2.7, 5.2.8 节定义。 - -`backend/app/models/document.py` — `ExpenseDocument`,字段按文档 5.2.4 节。 - -`backend/app/models/rule.py` — `ExpenseRule`, `RuleHit`,字段按文档 5.2.5, 5.2.6 节。 - -`backend/app/models/audit.py` — `AuditLog`(通用审计日志表,记录所有关键操作)。 - -- [ ] **Step 3: 更新 `models/__init__.py` 导出所有模型** - -```python -from app.models.task import ReimbursementTask -from app.models.reimbursement import ShadowReimbursement, ReimbursementItem, SupplementRequest, SyncRecord -from app.models.document import ExpenseDocument -from app.models.rule import ExpenseRule, RuleHit -from app.models.audit import AuditLog -from app.models.base import Base - -__all__ = [ - "ReimbursementTask", "ShadowReimbursement", "ReimbursementItem", - "ExpenseDocument", "ExpenseRule", "RuleHit", - "SupplementRequest", "SyncRecord", "AuditLog", "Base", -] -``` - -- [ ] **Step 4: 生成 Alembic 迁移** - -Run: `cd backend && alembic revision --autogenerate -m "init schema"` -Run: `cd backend && alembic upgrade head` - -- [ ] **Step 5: 编写模型测试** - -验证所有表能正确创建和插入数据。 - -- [ ] **Step 6: Commit** - -```bash -git add backend/ -git commit -m "feat: 完成所有数据模型定义和数据库迁移" -``` - ---- - -### Task 1.3: 前端项目骨架搭建 - -**Files:** -- Create: 使用 Vite 初始化 Vue3 + TypeScript 项目 -- Create: `frontend/src/router/index.ts` -- Create: `frontend/src/stores/` — Pinia stores -- Create: `frontend/src/api/` — API 调用封装 -- Create: `frontend/src/views/` — 页面 -- Create: `frontend/src/components/` — 组件 -- Create: `frontend/src/layouts/` — 布局 -- Test: `frontend/src/App.vue` - -- [ ] **Step 1: 初始化 Vue3 项目** - -```bash -npm create vite@latest frontend -- --template vue-ts -cd frontend -npm install ant-design-vue @ant-design/icons-vue vue-router pinia axios dayjs -``` - -目录结构: - -``` -frontend/ -├── src/ -│ ├── api/ # API 调用 -│ │ ├── index.ts # axios 实例 -│ │ ├── task.ts # 报销任务 API -│ │ ├── document.ts # 票据 API -│ │ └── precheck.ts # 预审 API -│ ├── components/ # 通用组件 -│ │ ├── FileUpload.vue -│ │ ├── ExpenseTable.vue -│ │ └── RuleHitCard.vue -│ ├── layouts/ -│ │ └── MainLayout.vue -│ ├── router/ -│ │ └── index.ts -│ ├── stores/ -│ │ ├── task.ts -│ │ └── user.ts -│ ├── views/ -│ │ ├── HomeView.vue # 报销入口 -│ │ ├── UploadView.vue # 票据上传 -│ │ ├── DraftView.vue # 报销草稿 -│ │ ├── PrecheckView.vue # 预审结果 -│ │ ├── SupplementView.vue # 补件交互 -│ │ ├── ConfirmView.vue # 提交确认 -│ │ └── AuditView.vue # 审计日志 -│ ├── App.vue -│ └── main.ts -├── index.html -├── vite.config.ts -├── tsconfig.json -└── package.json -``` - -- [ ] **Step 2: 配置路由和布局** - -`frontend/src/router/index.ts`: - -```typescript -import { createRouter, createWebHistory } from 'vue-router' - -const routes = [ - { path: '/', name: 'home', component: () => import('@/views/HomeView.vue') }, - { path: '/task/:taskId/upload', name: 'upload', component: () => import('@/views/UploadView.vue') }, - { path: '/task/:taskId/draft', name: 'draft', component: () => import('@/views/DraftView.vue') }, - { path: '/task/:taskId/precheck', name: 'precheck', component: () => import('@/views/PrecheckView.vue') }, - { path: '/task/:taskId/supplement', name: 'supplement', component: () => import('@/views/SupplementView.vue') }, - { path: '/task/:taskId/confirm', name: 'confirm', component: () => import('@/views/ConfirmView.vue') }, - { path: '/audit', name: 'audit', component: () => import('@/views/AuditView.vue') }, -] - -export default createRouter({ history: createWebHistory(), routes }) -``` - -- [ ] **Step 3: 配置 API 封装** - -`frontend/src/api/index.ts`: - -```typescript -import axios from 'axios' - -const api = axios.create({ - baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1', - timeout: 30000, -}) - -export default api -``` - -- [ ] **Step 4: 确认前端能正常启动** - -Run: `cd frontend && npm run dev` -Expected: 浏览器访问 http://localhost:5173 能看到页面 - -- [ ] **Step 5: Commit** - -```bash -git add frontend/ -git commit -m "feat: 初始化前端项目骨架(Vue3 + TypeScript + Ant Design Vue)" -``` - ---- - -### Task 1.4: Docker Compose 开发环境 - -**Files:** -- Create: `docker-compose.yml` -- Create: `backend/Dockerfile` -- Create: `frontend/Dockerfile` -- Create: `.env.example` -- Create: `backend/init.sql`(初始数据) - -- [ ] **Step 1: 编写 docker-compose.yml** - -```yaml -version: "3.8" -services: - postgres: - image: postgres:15 - environment: - POSTGRES_DB: x_financial - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - ports: - - "5432:5432" - volumes: - - pgdata:/var/lib/postgresql/data - - redis: - image: redis:7-alpine - ports: - - "6379:6379" - - minio: - image: minio/minio - command: server /data --console-address ":9001" - environment: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin - ports: - - "9000:9000" - - "9001:9001" - volumes: - - minio_data:/data - -volumes: - pgdata: - minio_data: -``` - -- [ ] **Step 2: 编写 .env.example** - -按 config.py 中的字段列出所有环境变量,标注必填/选填。 - -- [ ] **Step 3: 验证环境启动** - -Run: `docker-compose up -d` -Run: `docker-compose ps` -Expected: postgres, redis, minio 均为 running - -- [ ] **Step 4: Commit** - -```bash -git add docker-compose.yml .env.example backend/Dockerfile frontend/Dockerfile -git commit -m "feat: 添加 Docker Compose 开发环境(PostgreSQL + Redis + MinIO)" -``` - ---- - -## Phase 2: 后端核心服务(W2-W3) - -### Task 2.1: 报销任务管理 API - -**Files:** -- Create: `backend/app/schemas/task.py` -- Create: `backend/app/services/task_service.py` -- Create: `backend/app/api/v1/tasks.py` -- Modify: `backend/app/api/v1/router.py` -- Test: `backend/tests/test_task_api.py` - -- [ ] **Step 1: 定义 Pydantic schemas** - -`backend/app/schemas/task.py` — 包含 `TaskCreateRequest`, `TaskResponse`, `TaskListResponse` 等请求/响应模型,字段按文档第 8 节 API 定义。 - -- [ ] **Step 2: 实现 TaskService 业务逻辑** - -包含方法: -- `create_task(user_id, company_id, user_intent)` → 创建任务,状态设为 `CREATED` -- `get_task(task_id)` → 查询任务详情 -- `list_tasks(user_id, status, page, size)` → 分页查询 -- `update_status(task_id, status, current_agent)` → 更新任务状态 - -- [ ] **Step 3: 实现 API 路由** - -`POST /api/v1/reimbursement/tasks` — 创建报销任务(对应文档 8.1) -`GET /api/v1/reimbursement/tasks/{task_id}` — 查询任务详情 -`GET /api/v1/reimbursement/tasks` — 列表查询 - -- [ ] **Step 4: 编写测试** - -```python -@pytest.mark.asyncio -async def test_create_task(client): - response = await client.post("/api/v1/reimbursement/tasks", json={ - "user_id": "U001", - "company_id": "C001", - "user_intent": "我要报这次北京出差的费用", - "entry_channel": "web" - }) - assert response.status_code == 201 - data = response.json() - assert "task_id" in data - assert data["status"] == "material_collecting" -``` - -- [ ] **Step 5: Commit** - -```bash -git add backend/ -git commit -m "feat: 实现报销任务管理 API(创建/查询/列表)" -``` - ---- - -### Task 2.2: 文件上传与票据管理 API - -**Files:** -- Create: `backend/app/schemas/document.py` -- Create: `backend/app/services/document_service.py` -- Create: `backend/app/services/storage_service.py` -- Create: `backend/app/api/v1/documents.py` -- Modify: `backend/app/api/v1/router.py` -- Test: `backend/tests/test_document_api.py` - -- [ ] **Step 1: 实现 MinIO 存储服务** - -`storage_service.py` — 封装 MinIO 操作: -- `upload_file(bucket, file_name, file_data, content_type)` → 上传文件 -- `get_file_url(bucket, file_name)` → 获取文件 URL -- `delete_file(bucket, file_name)` → 删除文件 - -- [ ] **Step 2: 实现文档服务** - -`document_service.py`: -- `upload_document(task_id, file, document_type)` → 保存文件到 MinIO,创建 DB 记录 -- `get_documents(task_id)` → 查询任务下所有票据 -- `update_ocr_result(document_id, ocr_result)` → 更新 OCR 识别结果 - -- [ ] **Step 3: 实现 API 路由** - -`POST /api/v1/reimbursement/tasks/{task_id}/documents` — 上传票据(对应文档 8.2) -`GET /api/v1/reimbursement/tasks/{task_id}/documents` — 查询票据列表 - -支持 multipart/form-data 文件上传。 - -- [ ] **Step 4: 编写测试** - -使用 mock MinIO,测试文件上传和记录创建。 - -- [ ] **Step 5: Commit** - -```bash -git add backend/ -git commit -m "feat: 实现文件上传与票据管理 API(MinIO 存储)" -``` - ---- - -### Task 2.3: OCR 服务集成 - -**Files:** -- Create: `backend/app/services/ocr_service.py` -- Create: `backend/app/services/ocr_providers/base.py` -- Create: `backend/app/services/ocr_providers/baidu.py` -- Create: `backend/app/services/ocr_providers/mock.py` -- Test: `backend/tests/test_ocr_service.py` - -- [ ] **Step 1: 定义 OCR Provider 抽象接口** - -`ocr_providers/base.py`: - -```python -from abc import ABC, abstractmethod -from dataclasses import dataclass - -@dataclass -class OCRResult: - document_type: str # 识别出的票据类型 - raw_text: str # 原始文字 - fields: dict # 结构化字段 - confidence: float # 整体置信度 - provider: str # 提供商 - -class OCRProvider(ABC): - @abstractmethod - async def recognize(self, file_url: str, document_type: str | None = None) -> OCRResult: - ... -``` - -- [ ] **Step 2: 实现 Mock OCR Provider(开发测试用)** - -`mock.py` — 根据文件名/类型返回预定义的结构化数据,用于开发阶段不依赖真实 OCR。 - -- [ ] **Step 3: 实现百度 OCR Provider** - -`baidu.py` — 调用百度云 OCR API,支持: -- 增值税发票识别 -- 火车票识别 -- 机票行程单识别 -- 通用票据识别(兜底) - -将百度返回结果标准化为 `OCRResult`。 - -- [ ] **Step 4: 实现 OCR Service 门面** - -`ocr_service.py`: -- `recognize(file_url, document_type)` → 根据 `config.OCR_PROVIDER` 选择 provider -- 自动识别票据类型(如果未指定) -- 返回标准化 `OCRResult` -- 更新 document 表的 `ocr_status` 和 `extracted_json` - -- [ ] **Step 5: 编写测试** - -使用 Mock Provider 测试完整流程。 - -- [ ] **Step 6: Commit** - -```bash -git add backend/ -git commit -m "feat: 实现 OCR 服务(百度云 + Mock Provider)" -``` - ---- - -### Task 2.4: 规则引擎 - -**Files:** -- Create: `backend/app/services/rule_engine.py` -- Create: `backend/app/schemas/rule.py` -- Create: `backend/app/api/v1/rules.py` -- Create: `backend/app/services/rule_checkers/__init__.py` -- Create: `backend/app/services/rule_checkers/required_fields.py` -- Create: `backend/app/services/rule_checkers/attachment_check.py` -- Create: `backend/app/services/rule_checkers/duplicate_invoice.py` -- Create: `backend/app/services/rule_checkers/amount_limit.py` -- Create: `backend/app/services/rule_checkers/date_validity.py` -- Create: `backend/app/services/rule_checkers/expense_type_match.py` -- Test: `backend/tests/test_rule_engine.py` - -- [ ] **Step 1: 定义规则引擎核心接口** - -`rule_engine.py` — `RuleEngine` 类: - -```python -class RuleCheckResult: - rule_code: str - severity: RiskLevel - action: RuleAction - message: str - suggestion: str - policy_ref: str - hit_detail: dict - -class RuleEngine: - def __init__(self, db: AsyncSession): - self.db = db - self.checkers: dict[str, RuleChecker] = {} - - async def run_precheck(self, reimbursement_id: str) -> PrecheckResult: - """执行完整预审""" - ... - - async def run_single_rule(self, rule_code: str, context: dict) -> RuleCheckResult | None: - """执行单条规则""" - ... -``` - -- [ ] **Step 2: 实现 MVP 阶段的 6 条核心规则检查器** - -每条规则是一个 `RuleChecker` 类,接收报销数据上下文,返回 `RuleCheckResult | None`: - -1. `RequiredFieldsChecker` — 必填字段校验 -2. `AttachmentCheckChecker` — 附件完整性校验(如住宿费必须上传酒店流水) -3. `DuplicateInvoiceChecker` — 重复发票检查(invoice_code + invoice_number + amount 去重) -4. `AmountLimitChecker` — 金额超标校验(按城市/职级/费用类型检查标准) -5. `DateValidityChecker` — 日期合理性校验(费用日期在出差期间内) -6. `ExpenseTypeMatchChecker` — 费用类型与票据类型匹配校验 - -- [ ] **Step 3: 实现规则管理 API** - -- `GET /api/v1/rules` — 列出所有规则 -- `POST /api/v1/rules` — 创建规则 -- `PUT /api/v1/rules/{rule_id}` — 更新规则 -- `PATCH /api/v1/rules/{rule_id}/toggle` — 启用/禁用规则 - -- [ ] **Step 4: 种子数据** - -`backend/alembic/seed/rules.sql` — 预置文档 7.2 节的 3 条示例规则 + 金额标准表。 - -- [ ] **Step 5: 编写测试** - -对每条规则编写单元测试,使用 mock 数据验证命中/未命中场景。 - -- [ ] **Step 6: Commit** - -```bash -git add backend/ -git commit -m "feat: 实现规则引擎(6 条核心规则 + 管理 API + 种子数据)" -``` - ---- - -### Task 2.5: 影子报销账本 CRUD - -**Files:** -- Create: `backend/app/schemas/reimbursement.py` -- Create: `backend/app/services/ledger_service.py` -- Create: `backend/app/api/v1/ledger.py` -- Modify: `backend/app/api/v1/router.py` -- Test: `backend/tests/test_ledger_api.py` - -- [ ] **Step 1: 定义 Pydantic schemas** - -包含:`ReimbursementDraftResponse`, `ReimbursementItemCreate`, `ReimbursementItemResponse`, `PrecheckResultResponse`。 - -- [ ] **Step 2: 实现 LedgerService** - -核心方法: -- `create_shadow_reimbursement(task_id, data)` → 创建影子报销记录 -- `get_draft(reimbursement_id)` → 获取报销草稿 -- `get_draft_by_task(task_id)` → 通过任务 ID 获取草稿 -- `update_precheck_result(reimbursement_id, result)` → 更新预审结果 -- `add_item(reimbursement_id, item_data)` → 添加报销明细 - -- [ ] **Step 3: 实现 API 路由** - -`GET /api/v1/reimbursement/tasks/{task_id}/draft` — 获取报销草稿(对应文档 8.4) -`GET /api/v1/reimbursement/tasks/{task_id}/precheck-result` — 获取预审结果(对应文档 8.5) - -- [ ] **Step 4: 编写测试** - -- [ ] **Step 5: Commit** - -```bash -git add backend/ -git commit -m "feat: 实现影子报销账本 CRUD API" -``` - ---- - -### Task 2.6: 补件与提交 API - -**Files:** -- Create: `backend/app/api/v1/supplements.py` -- Create: `backend/app/services/supplement_service.py` -- Create: `backend/app/services/sync_service.py` -- Modify: `backend/app/api/v1/router.py` -- Test: `backend/tests/test_supplement_api.py` - -- [ ] **Step 1: 实现补件服务** - -`supplement_service.py`: -- `create_supplement_request(reimbursement_id, items)` → 创建补件请求 -- `respond_supplement(request_id, response_text, document_ids)` → 用户补件响应 -- `get_supplement_requests(task_id)` → 查询补件请求列表 - -- [ ] **Step 2: 实现同步服务(MVP 阶段为模拟)** - -`sync_service.py`: -- `mock_sync_to_backend(reimbursement_id)` → 模拟后端同步,生成假的 backend_bill_id -- `get_sync_status(task_id)` → 查询同步状态 - -- [ ] **Step 3: 实现 API 路由** - -`POST /api/v1/reimbursement/tasks/{task_id}/supplements` — 用户补件(对应文档 8.6) -`POST /api/v1/reimbursement/tasks/{task_id}/submit` — 用户确认提交(对应文档 8.7) -`GET /api/v1/reimbursement/tasks/{task_id}/sync-status` — 查询同步状态(对应文档 8.8) - -- [ ] **Step 4: 编写测试** - -- [ ] **Step 5: Commit** - -```bash -git add backend/ -git commit -m "feat: 实现补件与提交确认 API(含模拟同步)" -``` - ---- - -## Phase 3: Agent 编排(W3-W4) - -### Task 3.1: Agent Orchestrator 状态机 - -**Files:** -- Create: `backend/app/agents/orchestrator.py` -- Create: `backend/app/agents/state.py` -- Create: `backend/app/api/v1/agent.py` -- Modify: `backend/app/api/v1/router.py` -- Test: `backend/tests/test_orchestrator.py` - -- [ ] **Step 1: 定义 Agent 状态和上下文** - -`agents/state.py`: - -```python -from dataclasses import dataclass, field -from app.models.enums import TaskStatus - -@dataclass -class AgentContext: - task_id: str - status: TaskStatus - user_intent: str | None = None - current_agent: str | None = None - ocr_results: list[dict] = field(default_factory=list) - reimbursement_data: dict | None = None - precheck_result: dict | None = None - supplement_requests: list[dict] = field(default_factory=list) - error_message: str | None = None - retry_count: int = 0 -``` - -- [ ] **Step 2: 实现 Orchestrator 状态机** - -`agents/orchestrator.py` — 核心编排逻辑: - -状态转换图(对应文档 4.2 节): -``` -CREATED → MATERIAL_COLLECTING → PARSING → DRAFT_GENERATED → PRECHECKING -→ NEED_SUPPLEMENT → MATERIAL_COLLECTING(循环) -→ PENDING_USER_CONFIRM → SUBMITTING → SYNCED -→ SYNC_FAILED → SUBMITTING(重试) -``` - -方法: -- `run(task_id, start_from)` → 启动编排 -- `_transition_to(context, new_status, agent_name)` → 状态转换 -- `_run_agent(context, agent_name)` → 执行单个 Agent -- `_handle_agent_result(context, result)` → 处理 Agent 返回结果 - -- [ ] **Step 3: 实现 Agent 启动 API** - -`POST /api/v1/reimbursement/tasks/{task_id}/agent/run` — 启动 Agent 处理(对应文档 8.3) - -- [ ] **Step 4: 编写状态机转换测试** - -覆盖所有正常路径和异常路径(解析失败、预审需补件、同步失败重试等)。 - -- [ ] **Step 5: Commit** - -```bash -git add backend/ -git commit -m "feat: 实现 Agent Orchestrator 状态机编排" -``` - ---- - -### Task 3.2: 5 个 Agent 实现 - -**Files:** -- Create: `backend/app/agents/base_agent.py` -- Create: `backend/app/agents/intake_agent.py` -- Create: `backend/app/agents/parse_agent.py` -- Create: `backend/app/agents/rule_check_agent.py` -- Create: `backend/app/agents/explain_agent.py` -- Create: `backend/app/agents/sync_agent.py` -- Test: `backend/tests/test_agents.py` - -- [ ] **Step 1: 定义 Agent 基类** - -`agents/base_agent.py`: - -```python -from abc import ABC, abstractmethod -from app.agents.state import AgentContext - -class AgentResult: - success: bool - data: dict - next_action: str | None # 继续编排 / 等待用户 / 需要补件 - error: str | None - -class BaseAgent(ABC): - name: str - - @abstractmethod - async def execute(self, context: AgentContext, db: AsyncSession) -> AgentResult: - ... -``` - -- [ ] **Step 2: 实现受理 Agent(IntakeAgent)** - -职责:理解用户意图,收集上下文。 -- 分析 user_intent 文本,提取报销类型、出差信息 -- 调用 LLM 做 intent classification -- 返回结构化的任务信息 - -- [ ] **Step 3: 实现单据解析 Agent(ParseAgent)** - -职责:调用 OCR,生成报销草稿。 -- 遍历任务下的所有 document,调用 ocr_service -- 将 OCR 结果汇总为报销明细 -- 创建 ShadowReimbursement + ReimbursementItem -- 自动识别费用类型 - -- [ ] **Step 4: 实现规则校验 Agent(RuleCheckAgent)** - -职责:调用规则引擎完成预审。 -- 从 DB 加载所有 enabled 规则 -- 传入报销数据上下文执行规则引擎 -- 收集所有 RuleHit -- 计算 overall risk_level -- 更新预审状态 - -- [ ] **Step 5: 实现解释与补件 Agent(ExplainAgent)** - -职责:将规则命中结果转化为用户可理解的解释。 -- 遍历 rule_hits,使用 LLM 生成自然语言解释 -- 创建 supplement_requests(缺件类型的自动创建补件请求) -- 生成修改建议 - -- [ ] **Step 6: 实现同步执行 Agent(SyncAgent)** - -职责:生成标准报销单,调用后端同步。 -- 将 ShadowReimbursement 数据映射为标准报销单格式 -- 调用 sync_service.mock_sync_to_backend -- 记录 SyncRecord -- 处理同步失败重试 - -- [ ] **Step 7: 编写每个 Agent 的单元测试** - -使用 mock DB 和 mock OCR/LLM 测试每个 Agent 的输入输出。 - -- [ ] **Step 8: Commit** - -```bash -git add backend/ -git commit -m "feat: 实现 5 个 Agent(受理/解析/规则校验/解释补件/同步)" -``` - ---- - -### Task 3.3: LLM 集成层 - -**Files:** -- Create: `backend/app/services/llm_service.py` -- Create: `backend/app/services/llm_prompts/__init__.py` -- Create: `backend/app/services/llm_prompts/intent_classification.py` -- Create: `backend/app/services/llm_prompts/risk_explanation.py` -- Create: `backend/app/services/llm_prompts/expense_type_mapping.py` -- Test: `backend/tests/test_llm_service.py` - -- [ ] **Step 1: 实现 LLM Service 封装** - -`llm_service.py`: -- `chat(system_prompt, user_message, json_mode)` → 调用 LLM API -- 支持多 provider(OpenAI 兼容接口,适配国内模型) -- 统一错误处理和重试 -- 响应解析(JSON mode) - -- [ ] **Step 2: 编写 Prompt 模板** - -3 个核心 Prompt: -- `intent_classification` — 分析用户意图,识别报销类型 -- `risk_explanation` — 将规则命中结果转为自然语言解释 -- `expense_type_mapping` — 根据 OCR 结果匹配费用类型 - -- [ ] **Step 3: 编写测试(使用 mock LLM 响应)** - -- [ ] **Step 4: Commit** - -```bash -git add backend/ -git commit -m "feat: 实现 LLM 集成层(多 Provider + Prompt 模板)" -``` - ---- - -### Task 3.4: 审计日志 - -**Files:** -- Create: `backend/app/services/audit_service.py` -- Create: `backend/app/api/v1/audit.py` -- Test: `backend/tests/test_audit.py` - -- [ ] **Step 1: 实现 AuditService** - -核心方法: -- `log(action, actor, target_type, target_id, detail)` → 记录审计日志 -- `query_logs(target_type, target_id, actor, date_range)` → 查询日志 - -需要记录的动作(对应文档 12.1 节):文件上传、OCR 识别、Agent 调用、规则命中、用户补件、用户确认、后端同步。 - -- [ ] **Step 2: 在所有关键路径埋点** - -在 task_service、document_service、ocr_service、rule_engine、orchestrator 的关键操作中调用 `audit_service.log()`。 - -- [ ] **Step 3: 实现审计日志查询 API** - -`GET /api/v1/audit/logs` — 查询审计日志(支持按 target_type、target_id、date_range 过滤) - -- [ ] **Step 4: 编写测试** - -- [ ] **Step 5: Commit** - -```bash -git add backend/ -git commit -m "feat: 实现审计日志服务(记录 + 查询 API)" -``` - ---- - -## Phase 4: 前端核心页面(W4-W5) - -### Task 4.1: 报销入口页 + 上传组件 - -**Files:** -- Create: `frontend/src/views/HomeView.vue` -- Create: `frontend/src/views/UploadView.vue` -- Create: `frontend/src/components/FileUpload.vue` -- Create: `frontend/src/stores/task.ts` -- Create: `frontend/src/api/task.ts` -- Create: `frontend/src/api/document.ts` - -- [ ] **Step 1: 实现 API 调用层** - -`api/task.ts`: -```typescript -import api from './index' - -export const createTask = (data: { userId: string; companyId: string; userIntent: string }) => - api.post('/reimbursement/tasks', data) - -export const getTask = (taskId: string) => - api.get(`/reimbursement/tasks/${taskId}`) - -export const runAgent = (taskId: string, startFrom = 'intake') => - api.post(`/reimbursement/tasks/${taskId}/agent/run`, { start_from: startFrom, mode: 'precheck' }) -``` - -`api/document.ts`: -```typescript -export const uploadDocument = (taskId: string, file: File, documentType: string) => { - const formData = new FormData() - formData.append('file', file) - formData.append('document_type', documentType) - return api.post(`/reimbursement/tasks/${taskId}/documents`, formData) -} -``` - -- [ ] **Step 2: 实现报销入口页 HomeView** - -按文档 9.2 节: -- 对话输入框(用户输入报销意图) -- 上传按钮 -- 最近任务列表 -- 常用报销类型快捷按钮("报差旅"、"看发票能不能报"等) -- 智能引导提示 - -交互流程:用户输入意图 → 调用 createTask → 跳转到上传页 - -- [ ] **Step 3: 实现文件上传组件 FileUpload** - -- 支持拖拽上传 -- 支持多文件 -- 文件类型校验(PDF、JPG、PNG) -- 文件大小限制 -- 上传进度条 -- 预览缩略图 - -- [ ] **Step 4: 实现票据上传页 UploadView** - -- 引用 FileUpload 组件 -- 选择票据类型下拉框(增值税发票、火车票、机票行程单等) -- 已上传文件列表 -- "开始识别" 按钮 → 调用 runAgent → 跳转到草稿页 - -- [ ] **Step 5: Commit** - -```bash -git add frontend/ -git commit -m "feat: 实现报销入口页和票据上传页" -``` - ---- - -### Task 4.2: 报销草稿页 - -**Files:** -- Create: `frontend/src/views/DraftView.vue` -- Create: `frontend/src/components/ExpenseTable.vue` -- Create: `frontend/src/api/precheck.ts` - -- [ ] **Step 1: 实现报销草稿页 DraftView** - -按文档 9.3 节展示: -- 报销人信息(姓名、部门、成本中心、项目) -- 报销事由 -- 费用明细表格(ExpenseTable 组件) -- 票据附件缩略图列表 -- AI 自动识别结果标注 -- 可编辑字段(金额、事由等可修改) -- 预审状态指示器 -- "预审" 按钮 → 跳转到预审结果页 - -- [ ] **Step 2: 实现 ExpenseTable 组件** - -- Ant Design Table 展示费用明细 -- 列:费用类型、金额、税额、发生日期、城市、商户、风险等级标签 -- 支持行内编辑 -- 风险等级彩色标签(绿/黄/橙/红) - -- [ ] **Step 3: Commit** - -```bash -git add frontend/ -git commit -m "feat: 实现报销草稿页和费用明细表格组件" -``` - ---- - -### Task 4.3: 预审结果页 + 补件交互页 - -**Files:** -- Create: `frontend/src/views/PrecheckView.vue` -- Create: `frontend/src/views/SupplementView.vue` -- Create: `frontend/src/components/RuleHitCard.vue` - -- [ ] **Step 1: 实现预审结果页 PrecheckView** - -按文档 9.4 节: -- 总体结论卡片(通过/需补件/有风险) -- 风险等级指示(彩色徽章) -- 通过项列表(绿色勾选) -- 风险项列表(RuleHitCard 组件) -- 缺件项列表(橙色提示) -- 每条规则命中显示:问题说明、制度依据、修改建议 -- "一键补件" 按钮 → 跳转到补件页 -- "确认提交" 按钮(仅预审通过时可用) - -- [ ] **Step 2: 实现 RuleHitCard 组件** - -- 规则名称和编码 -- 风险等级标签 -- 问题描述 -- 制度依据链接 -- 修改建议 -- 展开详情 - -- [ ] **Step 3: 实现补件交互页 SupplementView** - -- 显示待补件清单 -- 每个补件项:类型(补充附件/补充说明/修改字段)、提示文案 -- 上传附件(调用 FileUpload) -- 文本回复输入框 -- 提交补件 → 调用 supplement API → 跳转回预审页 - -- [ ] **Step 4: Commit** - -```bash -git add frontend/ -git commit -m "feat: 实现预审结果页和补件交互页" -``` - ---- - -### Task 4.4: 提交确认页 + 审计日志页 - -**Files:** -- Create: `frontend/src/views/ConfirmView.vue` -- Create: `frontend/src/views/AuditView.vue` -- Create: `frontend/src/api/audit.ts` - -- [ ] **Step 1: 实现提交确认页 ConfirmView** - -- 最终报销单摘要(不可编辑) -- 总金额确认 -- 费用明细汇总 -- 附件清单 -- 同步目标系统选择(MVP 默认 expense_system) -- "确认提交" 按钮 → 调用 submit API -- 同步状态轮询展示(提交中 → 已同步/同步失败) - -- [ ] **Step 2: 实现审计日志页 AuditView** - -按文档 9.5 节(简化版): -- 时间线展示所有操作记录 -- 筛选:按任务、操作类型、时间范围 -- 每条日志:时间、操作人、操作类型、详情 - -- [ ] **Step 3: Commit** - -```bash -git add frontend/ -git commit -m "feat: 实现提交确认页和审计日志页" -``` - ---- - -## Phase 5: 联调与集成(W5-W6) - -### Task 5.1: 前后端联调 - -**Files:** -- Modify: 多个前后端文件(修复联调问题) - -- [ ] **Step 1: 启动前后端全栈** - -后端:`cd backend && uvicorn app.main:app --reload` -前端:`cd frontend && npm run dev` - -- [ ] **Step 2: 跑通完整报销流程** - -按文档 3.1 节的 10 步流程: -1. 在首页创建报销任务 -2. 上传 2-3 张模拟票据(增值税发票、火车票、酒店流水) -3. 点击"开始识别" → Agent 启动 -4. 查看草稿页 → 确认自动识别结果 -5. 执行预审 → 查看预审结果 -6. 如有缺件/风险 → 补件 -7. 确认提交 → 查看同步状态 -8. 查看审计日志 - -- [ ] **Step 3: 修复联调过程中发现的问题** - -API 响应格式不一致、字段缺失、前后端类型不匹配等。 - -- [ ] **Step 4: Commit** - -```bash -git add . -git commit -m "fix: 前后端联调修复" -``` - ---- - -### Task 5.2: 规则配置与种子数据 - -**Files:** -- Create: `backend/alembic/seed/expense_policies.sql` -- Create: `backend/alembic/seed/expense_rules.sql` -- Create: `backend/alembic/seed/city_levels.sql` -- Create: `backend/alembic/seed/hotel_limits.sql` - -- [ ] **Step 1: 编写差旅报销制度种子数据** - -按典型企业差旅报销制度配置: -- 城市等级(一线/二线/三线) -- 住宿标准(按城市等级 × 职级) -- 交通标准(高铁/飞机按职级) -- 餐补标准 -- 必须上传的附件类型映射 - -- [ ] **Step 2: 编写规则种子数据** - -至少配置文档 7.2 节的 3 条示例规则 + MVP 需要的 6 条核心规则。 - -- [ ] **Step 3: 编写数据初始化脚本** - -`backend/scripts/seed_data.py` — 一键初始化所有种子数据。 - -- [ ] **Step 4: Commit** - -```bash -git add backend/ -git commit -m "feat: 添加差旅报销制度和规则种子数据" -``` - ---- - -## Phase 6: 测试与打磨(W7-W8) - -### Task 6.1: 后端集成测试 - -**Files:** -- Create: `backend/tests/test_integration_flow.py` -- Create: `backend/tests/conftest.py`(更新,添加测试数据库 fixture) - -- [ ] **Step 1: 编写完整流程集成测试** - -```python -@pytest.mark.asyncio -async def test_full_reimbursement_flow(db, client): - # 1. 创建任务 - task = await create_task(client, user_id="U001", intent="报北京出差费用") - assert task["status"] == "material_collecting" - - # 2. 上传票据 - doc1 = await upload_document(client, task["task_id"], "vat_invoice", "invoice.pdf") - doc2 = await upload_document(client, task["task_id"], "train_ticket", "train.pdf") - - # 3. 启动 Agent - result = await run_agent(client, task["task_id"]) - assert result["status"] in ["draft_generated", "prechecking"] - - # 4. 获取草稿 - draft = await get_draft(client, task["task_id"]) - assert len(draft["items"]) > 0 - - # 5. 获取预审结果 - precheck = await get_precheck_result(client, task["task_id"]) - assert "risk_level" in precheck - - # 6. 如果需要补件 - if precheck["precheck_status"] == "need_supplement": - supplements = precheck["rule_hits"] - for s in supplements: - if s["action"] == "require_attachment": - await respond_supplement(client, task["task_id"], s["id"], "已补充") - # 重新预审 - await run_agent(client, task["task_id"], start_from="precheck") - - # 7. 确认提交 - submit = await submit_task(client, task["task_id"]) - assert submit["status"] == "submitting" - - # 8. 检查同步状态 - sync = await get_sync_status(client, task["task_id"]) - assert sync["sync_status"] == "success" -``` - -- [ ] **Step 2: 确保所有测试通过** - -Run: `cd backend && pytest tests/ -v --tb=short` -Expected: All PASS - -- [ ] **Step 3: Commit** - -```bash -git add backend/ -git commit -m "test: 添加完整报销流程集成测试" -``` - ---- - -### Task 6.2: 前端 E2E 测试(可选) - -**Files:** -- Create: `frontend/e2e/reimbursement.spec.ts`(如选用 Playwright) - -- [ ] **Step 1: 安装 Playwright** - -```bash -cd frontend && npm install -D @playwright/test -npx playwright install -``` - -- [ ] **Step 2: 编写核心流程 E2E 测试** - -模拟用户从创建任务到提交确认的完整操作。 - -- [ ] **Step 3: 确保测试通过** - -- [ ] **Step 4: Commit** - -```bash -git add frontend/ -git commit -m "test: 添加前端 E2E 测试" -``` - ---- - -### Task 6.3: Bug 修复与 UI 打磨 - -- [ ] **Step 1: 走查所有页面,修复视觉和交互问题** - -- 响应式布局适配 -- Loading 状态 -- 错误提示 -- 空状态 -- 表单校验 - -- [ ] **Step 2: 添加 Demo 数据展示效果** - -- [ ] **Step 3: Commit** - -```bash -git add . -git commit -m "fix: UI 打磨和 Bug 修复" -``` - ---- - -### Task 6.4: 部署与文档 - -**Files:** -- Create: `docker-compose.prod.yml` -- Modify: `README.md` -- Create: `docs/api.md` -- Create: `docs/deployment.md` - -- [ ] **Step 1: 编写生产 Docker Compose** - -包含前后端 + DB + Redis + MinIO + Nginx 反向代理。 - -- [ ] **Step 2: 编写部署文档** - -环境要求、配置说明、启动步骤、常用运维命令。 - -- [ ] **Step 3: 编写 API 文档** - -FastAPI 自动生成 Swagger,补充说明和示例。 - -- [ ] **Step 4: 更新 README** - -项目简介、架构图、快速启动、开发指南。 - -- [ ] **Step 5: Commit** - -```bash -git add . -git commit -m "docs: 添加部署文档和 README" -``` - ---- - -## 任务总览 - -| Phase | 周数 | 任务数 | 可并行 | -|---|---|---|---| -| Phase 1: 项目基建 | W1 | 4 | 前端骨架 + 后端骨架 + Docker 可并行 | -| Phase 2: 后端核心 | W2-W3 | 6 | 任务API + 文件上传 + OCR 可并行 | -| Phase 3: Agent 编排 | W3-W4 | 4 | Orchestrator → Agents → LLM → 审计(部分串行) | -| Phase 4: 前端页面 | W4-W5 | 4 | 草稿/预审/补件/确认页可并行 | -| Phase 5: 联调集成 | W5-W6 | 2 | 联调 + 种子数据 | -| Phase 6: 测试打磨 | W7-W8 | 4 | 测试 + 修复 + 部署 | -| **总计** | **8 周** | **26 个任务** | | - ---- - -## 风险与缓解 - -| 风险 | 影响 | 缓解措施 | -|---|---|---| -| OCR 识别准确率不够 | 草稿数据错误 | 允许用户手动修改,低置信度高亮提示 | -| LLM 响应慢或幻觉 | 用户体验差 | Prompt 严格约束输出格式,超时 fallback | -| 规则引擎复杂度超预期 | 延期 | MVP 先做 6 条硬编码规则,JSON 配置化后续迭代 | -| 前后端联调问题多 | 延期 | W5 提前开始联调,边开发边对齐 API | -| 3-5 人不够 | 交付延期 | 优先砍规则管理页和审计页(W8 补) | - ---- - -## 验收标准 - -MVP 完成的标志: - -- [x] 用户能通过 Web 界面创建差旅报销任务 -- [x] 上传 3 种以上票据类型(增值税发票、火车票、酒店流水) -- [x] OCR 自动识别票据信息并生成报销草稿 -- [x] 规则引擎执行 6 条核心预审规则 -- [x] 预审结果以可视化方式展示(风险等级、命中规则、修改建议) -- [x] 用户能补件并重新预审 -- [x] 用户确认后模拟同步成功 -- [x] 影子报销账本完整记录业务数据 -- [x] 审计日志记录所有关键操作 -- [x] 完整流程端到端测试通过 diff --git a/document/work-log/2026-05-06.md b/document/work-log/2026-05-06.md index bbcd5a3..fadb3b9 100644 --- a/document/work-log/2026-05-06.md +++ b/document/work-log/2026-05-06.md @@ -1,100 +1,51 @@ # Work Log - 2026-05-06 -## Git Commits Today +## 05-06 工作 -| Commit | Description | Files | Changes | -|--------|-------------|------|--------| -| f1dcfcf | docs: update work log with git commits | 1 file | +6/-57 | -| 04e4b71 | docs: add work log for 2026-05-06 | 1 file | +47/-30 | -| ae63766 | Add vue-router, login/setup flow | 35 files | +3761/-403 | +### 下午 +- **修复了 Windows Git Bash 启动脚本报错问题** + - 问题:虚拟环境指向不存在的 python3 + - 解决:添加检测函数,无效则重建 -### Commit Details - -#### ae63766 - Add vue-router, login/setup flow -- **问题**: 前端需要路由化和安装流程 -- **解决**: - - 前端使用 vue-router 重构为路由化导航 - - 添加系统安装和登录页面 + API 集成 - - 后端添加结构化日志、access-log 中间件、启动生命周期 -- **Files Changed**: - - web/src/router/index.js (+110) - - web/src/views/SetupView.vue (+316) - - web/src/views/LoginView.vue (+64/-) - - web/vite.config.js (+693) - - server/src/app/core/logging.py (+72) - - server/src/app/middleware/logging.py (+42) - - web/src/composables/useSetupView.js (+383) - - web/src/composables/useSystemState.js (+278) - -## Problem (问题) - -### 1. Windows Git Bash 虚拟环境问题 -- **现象**: `bash start.sh` 报错 "No module named pip" -- **原因**: `server/.venv` 指向不存在的 `/usr/bin/python3` -- **状态**: ✅ 已解决 - -### 2. 日志技能不完善 -- **现象**: 写日志时没有获取 git 详细变更 -- **状态**: ✅ 已解决 (更新了技能) - -### 3. PostgreSQL 未安装 -- **现象**: 后端需要数据库连接 -- **状态**: ⏳ 未解决 - -## What's Done (已完成) -- [x] 修复 server/start.sh 虚拟环境检测 -- [x] 更新 work-log 技能:获取 commit 详情和变更文件 -- [x] 添加路由化导航 (vue-router) -- [x] 添加 SetupView 安装页面 -- [x] 添加后端日志中间件 - -## What's Not Done (未完成) -- [ ] 安装 PostgreSQL -- [ ] 创建数据库 x_financial - -## Tasks -- [ ] 安装 PostgreSQL -- [ ] 创建数据库 `x_financial` -- [ ] 测试后端 API 连接 - -## Notes (备注) -- 项目已重构为前后端分离架构 (web/ + server/) -- 需要配置 DATABASE_URL 环境变量 +- **创建了 work-log 技能** + - 自动记录工作日志 + - 按 git 提交生成工作总结 --- -*Created with work-log skill* -*Last updated: 2026-05-06* -## Uncommitted Changes +# Work Log - 2026-05-07 -已提交,无遗留 +## 05-07 工作 -## Summary +### 上午 +- **完成了后端员工管理模块** + - 员工 CRUD 服务(创建、更新、删除) + - 自动记录修改历史(变更日志) + - 组织架构和角色模型 -### Morning - 修复 server/start.sh -- **问题**:Windows Git Bash 上无法运行,报错 "No module named pip" -- **原因**:`.venv` 指向不存在的 `/usr/bin/python3` -- **解决**:添加 `venv_valid()` 函数检测并重建虚拟环境 +### 中午 +- **完成了前端员工管理页面** + - 表格展示员工列表 + - 搜索和分页功能 + - 新增/编辑弹窗 -### Afternoon - 创建 work-log 技能 -- 自动读取 git 提交记录 -- 存储在 `document/work-log/` 目录 -- 工作流程:先提交 git → 获取日志 → 写日志 +- **添加了后端健康检查** + - 后端不可用时显示提示页面 + - 支持重试 -### Evening - 前端重构 -- 添加 SetupView 安装页面 -- 添加路由和服务模块 +### 下午 +- **重构了项目结构** + - 前后端分离(web/ + server/) + - 使用 vue-router 路由化导航 + - 添加系统安装页面 -## Notes - -- 需要安装 PostgreSQL 并创建 `x_financial` 数据库 -- 还有其他未提交的文件(.env, nul 等) - -## Tasks - -- [ ] 安装 PostgreSQL -- [ ] 创建数据库 `x_financial` +- **整理了 UI 资源** + - 图片移至 web/UI/ 目录 + - 清理旧文档 --- -*Created with work-log skill* -*Last updated: 2026-05-06* \ No newline at end of file + +# 待处理 + +- [ ] 安装 PostgreSQL 并创建数据库 +- [ ] 测试后端 API 连接 \ No newline at end of file diff --git a/document/work-log/2026-05-07.md b/document/work-log/2026-05-07.md new file mode 100644 index 0000000..16fe80c --- /dev/null +++ b/document/work-log/2026-05-07.md @@ -0,0 +1,67 @@ +# Work Log - 2026-05-07 + +## 今日工作 + +### 早上 09:00 - 10:00 +- **修复了 Windows 启动脚本报错** + - 添加虚拟环境检测函数 venv_valid() + - 无效时自动重建虚拟环境 + +### 早上 10:00 - 11:00 +- **开始员工管理后端开发** + - 设计员工模型(工号、部门、职位、状态) + - 添加工号字段(唯一) + +### 中午 11:00 - 12:00 +- **完成了员工 CRUD 服务** + - create_employee() 创建员工 + - update_employee() 更新员工 + - get_employees() 分页查询 + +### 中午 12:00 - 13:00 +- **添加了员工变更日志** + - 记录员工信息修改历史 + - 字段:employee_id, field_name, old_value, new_value + +### 下午 13:00 - 14:00 +- **添加了组织和角色模型** + - Organization 组织架构 + - Role 角色权限 + +### 下午 14:00 - 15:00 +- **完成了员工 API 端点** + - GET /api/v1/employees 列表 + - POST /api/v1/employees 创建 + - GET /api/v1/employees/{id} 获取单个 + +### 下午 15:00 - 16:00 +- **开始前端员工页面开发** + - 表格展示员工列表 + - 搜索功能 + +### 下午 16:00 - 17:00 +- **完成了前端员工页面** + - 搜索和分页 + - 新增/编辑弹窗 + +### 下午 17:00 - 18:00 +- **添加了后端健康检查** + - BackendUnavailableRouteView 页面 + - 后端不可用时提示并重试 + +### 下午 18:00 - 19:00 +- **重构了前端路由** + - 使用 vue-router 路由化导航 + - 添加 /employees 路由 + +### 下午 19:00 - 20:00 +- **整理了 UI 资源** + - 图片移至 web/UI/ 目录 + - 删除旧文档 + +--- + +# 待处理 + +- [ ] 安装 PostgreSQL +- [ ] 创建 x_financial 数据库 \ No newline at end of file diff --git a/server/src/app/api/v1/endpoints/employees.py b/server/src/app/api/v1/endpoints/employees.py index f2fd103..c97b75f 100644 --- a/server/src/app/api/v1/endpoints/employees.py +++ b/server/src/app/api/v1/endpoints/employees.py @@ -2,25 +2,37 @@ from __future__ import annotations from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from app.api.deps import get_db -from app.schemas.employee import EmployeeCreate, EmployeeRead +from app.schemas.employee import EmployeeCreate, EmployeeMetaRead, EmployeeRead from app.services.employee import EmployeeService router = APIRouter() DbSession = Annotated[Session, Depends(get_db)] +@router.get("/meta", response_model=EmployeeMetaRead) +def get_employee_meta(db: DbSession) -> EmployeeMetaRead: + return EmployeeService(db).get_employee_meta() + + @router.get("", response_model=list[EmployeeRead]) -def list_employees(db: DbSession) -> list[EmployeeRead]: - return EmployeeService(db).list_employees() +def list_employees( + db: DbSession, + status_filter: Annotated[str | None, Query(alias="status")] = None, + keyword: str | None = None, +) -> list[EmployeeRead]: + return EmployeeService(db).list_employees(status=status_filter, keyword=keyword) @router.post("", response_model=EmployeeRead, status_code=status.HTTP_201_CREATED) def create_employee(payload: EmployeeCreate, db: DbSession) -> EmployeeRead: - return EmployeeService(db).create_employee(payload) + try: + return EmployeeService(db).create_employee(payload) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc @router.get("/{employee_id}", response_model=EmployeeRead) diff --git a/server/src/app/db/base.py b/server/src/app/db/base.py index 3dcb22f..825f3cf 100644 --- a/server/src/app/db/base.py +++ b/server/src/app/db/base.py @@ -1,6 +1,17 @@ from app.db.base_class import Base from app.models.approval import ApprovalRecord +from app.models.employee_change_log import EmployeeChangeLog from app.models.employee import Employee +from app.models.organization import OrganizationUnit from app.models.reimbursement import ReimbursementRequest +from app.models.role import Role -__all__ = ["Base", "Employee", "ReimbursementRequest", "ApprovalRecord"] +__all__ = [ + "Base", + "ApprovalRecord", + "Employee", + "EmployeeChangeLog", + "OrganizationUnit", + "ReimbursementRequest", + "Role", +] diff --git a/server/src/app/main.py b/server/src/app/main.py index 774d9c5..2d594c9 100644 --- a/server/src/app/main.py +++ b/server/src/app/main.py @@ -7,6 +7,7 @@ from app.api.router import api_router from app.core.config import get_settings from app.core.logging import get_logger, setup_logging from app.middleware.logging import AccessLogMiddleware +from app.services.employee import prepare_employee_directory def create_app() -> FastAPI: @@ -48,8 +49,9 @@ def create_app() -> FastAPI: @app.on_event("startup") def _on_startup() -> None: + prepare_employee_directory() logger.info( - "Server ready — host=%s port=%s prefix=%s", + "Server ready - host=%s port=%s prefix=%s", settings.app_host, settings.app_port, settings.api_v1_prefix, diff --git a/server/src/app/models/__init__.py b/server/src/app/models/__init__.py index 6036a2b..348c523 100644 --- a/server/src/app/models/__init__.py +++ b/server/src/app/models/__init__.py @@ -1,5 +1,15 @@ from app.models.approval import ApprovalRecord +from app.models.employee_change_log import EmployeeChangeLog from app.models.employee import Employee +from app.models.organization import OrganizationUnit from app.models.reimbursement import ReimbursementRequest +from app.models.role import Role -__all__ = ["ApprovalRecord", "Employee", "ReimbursementRequest"] +__all__ = [ + "ApprovalRecord", + "Employee", + "EmployeeChangeLog", + "OrganizationUnit", + "ReimbursementRequest", + "Role", +] diff --git a/server/src/app/models/employee.py b/server/src/app/models/employee.py index 8d66037..7afccd7 100644 --- a/server/src/app/models/employee.py +++ b/server/src/app/models/employee.py @@ -1,13 +1,20 @@ from __future__ import annotations import uuid -from datetime import datetime +from datetime import date, datetime -from sqlalchemy import DateTime, String, func +from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, String, Table, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.base_class import Base +employee_role_links = Table( + "employee_role_links", + Base.metadata, + Column("employee_id", String(36), ForeignKey("employees.id"), primary_key=True), + Column("role_id", String(36), ForeignKey("roles.id"), primary_key=True), +) + class Employee(Base): __tablename__ = "employees" @@ -15,11 +22,37 @@ class Employee(Base): id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) employee_no: Mapped[str] = mapped_column(String(50), unique=True, index=True) name: Mapped[str] = mapped_column(String(100), index=True) - department: Mapped[str] = mapped_column(String(100), index=True) email: Mapped[str] = mapped_column(String(255), unique=True, index=True) + gender: Mapped[str | None] = mapped_column(String(20), nullable=True) + birth_date: Mapped[date | None] = mapped_column(Date(), nullable=True) + phone: Mapped[str | None] = mapped_column(String(30), nullable=True) + join_date: Mapped[date | None] = mapped_column(Date(), nullable=True) + location: Mapped[str | None] = mapped_column(String(100), nullable=True) + position: Mapped[str] = mapped_column(String(100), default="员工") + grade: Mapped[str] = mapped_column(String(20), default="P3", index=True) + cost_center: Mapped[str | None] = mapped_column(String(50), nullable=True) + finance_owner_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + employment_status: Mapped[str] = mapped_column(String(30), default="在职", index=True) + sync_state: Mapped[str] = mapped_column(String(30), default="已同步") + spotlight: Mapped[bool] = mapped_column(Boolean, default=False) + last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + organization_unit_id: Mapped[str | None] = mapped_column( + ForeignKey("organization_units.id"), nullable=True, index=True + ) + manager_id: Mapped[str | None] = mapped_column(ForeignKey("employees.id"), nullable=True, index=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() ) + organization_unit = relationship("OrganizationUnit", back_populates="employees") + manager = relationship("Employee", remote_side=[id], back_populates="reports") + reports = relationship("Employee", back_populates="manager") + roles = relationship("Role", secondary=employee_role_links, back_populates="employees") + change_logs = relationship( + "EmployeeChangeLog", + back_populates="employee", + cascade="all, delete-orphan", + order_by="desc(EmployeeChangeLog.occurred_at)", + ) reimbursement_requests = relationship("ReimbursementRequest", back_populates="employee") diff --git a/server/src/app/models/employee_change_log.py b/server/src/app/models/employee_change_log.py new file mode 100644 index 0000000..18dfe37 --- /dev/null +++ b/server/src/app/models/employee_change_log.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base_class import Base + + +class EmployeeChangeLog(Base): + __tablename__ = "employee_change_logs" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + employee_id: Mapped[str] = mapped_column(ForeignKey("employees.id"), index=True) + action: Mapped[str] = mapped_column(String(255)) + owner: Mapped[str] = mapped_column(String(100)) + occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + + employee = relationship("Employee", back_populates="change_logs") diff --git a/server/src/app/models/organization.py b/server/src/app/models/organization.py new file mode 100644 index 0000000..e99fabc --- /dev/null +++ b/server/src/app/models/organization.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base_class import Base + + +class OrganizationUnit(Base): + __tablename__ = "organization_units" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + unit_code: Mapped[str] = mapped_column(String(50), unique=True, index=True) + name: Mapped[str] = mapped_column(String(100), index=True) + unit_type: Mapped[str] = mapped_column(String(30), default="department", index=True) + parent_id: Mapped[str | None] = mapped_column( + ForeignKey("organization_units.id"), nullable=True, index=True + ) + cost_center: Mapped[str | None] = mapped_column(String(50), nullable=True) + location: Mapped[str | None] = mapped_column(String(100), nullable=True) + manager_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + parent = relationship("OrganizationUnit", remote_side=[id], back_populates="children") + children = relationship("OrganizationUnit", back_populates="parent") + employees = relationship("Employee", back_populates="organization_unit") diff --git a/server/src/app/models/role.py b/server/src/app/models/role.py new file mode 100644 index 0000000..f2b08a2 --- /dev/null +++ b/server/src/app/models/role.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base_class import Base + + +class Role(Base): + __tablename__ = "roles" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + role_code: Mapped[str] = mapped_column(String(50), unique=True, index=True) + name: Mapped[str] = mapped_column(String(100), unique=True, index=True) + description: Mapped[str] = mapped_column(String(500), default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + employees = relationship("Employee", secondary="employee_role_links", back_populates="roles") diff --git a/server/src/app/repositories/employee.py b/server/src/app/repositories/employee.py index 3ee5245..96a4860 100644 --- a/server/src/app/repositories/employee.py +++ b/server/src/app/repositories/employee.py @@ -1,17 +1,93 @@ -from sqlalchemy.orm import Session +from __future__ import annotations + +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session, selectinload from app.models.employee import Employee +from app.models.organization import OrganizationUnit +from app.models.role import Role class EmployeeRepository: def __init__(self, db: Session) -> None: self.db = db - def list(self) -> list[Employee]: - return self.db.query(Employee).order_by(Employee.created_at.desc()).all() + def list(self, status: str | None = None, keyword: str | None = None) -> list[Employee]: + stmt = ( + select(Employee) + .options( + selectinload(Employee.organization_unit), + selectinload(Employee.manager), + selectinload(Employee.roles), + selectinload(Employee.change_logs), + ) + .order_by(Employee.updated_at.desc(), Employee.name.asc()) + ) + + if status and status != "全部员工": + stmt = stmt.where(Employee.employment_status == status) + + if keyword: + pattern = f"%{keyword.strip()}%" + stmt = stmt.where( + or_( + Employee.name.ilike(pattern), + Employee.employee_no.ilike(pattern), + Employee.email.ilike(pattern), + Employee.position.ilike(pattern), + ) + ) + + return list(self.db.execute(stmt).scalars().unique().all()) def get(self, employee_id: str) -> Employee | None: - return self.db.query(Employee).filter(Employee.id == employee_id).first() + stmt = ( + select(Employee) + .options( + selectinload(Employee.organization_unit), + selectinload(Employee.manager), + selectinload(Employee.roles), + selectinload(Employee.change_logs), + ) + .where(Employee.id == employee_id) + ) + return self.db.execute(stmt).scalars().unique().first() + + def get_by_employee_no(self, employee_no: str) -> Employee | None: + stmt = select(Employee).where(Employee.employee_no == employee_no) + return self.db.execute(stmt).scalars().first() + + def get_by_email(self, email: str) -> Employee | None: + stmt = select(Employee).where(Employee.email == email) + return self.db.execute(stmt).scalars().first() + + def list_roles(self) -> list[Role]: + stmt = select(Role) + return list(self.db.execute(stmt).scalars().all()) + + def get_role_by_code(self, role_code: str) -> Role | None: + stmt = select(Role).where(Role.role_code == role_code) + return self.db.execute(stmt).scalars().first() + + def list_organization_units(self) -> list[OrganizationUnit]: + stmt = select(OrganizationUnit) + return list(self.db.execute(stmt).scalars().all()) + + def get_organization_by_code(self, unit_code: str) -> OrganizationUnit | None: + stmt = select(OrganizationUnit).where(OrganizationUnit.unit_code == unit_code) + return self.db.execute(stmt).scalars().first() + + def count_employees(self) -> int: + stmt = select(func.count()).select_from(Employee) + return int(self.db.execute(stmt).scalar_one()) + + def count_roles(self) -> int: + stmt = select(func.count()).select_from(Role) + return int(self.db.execute(stmt).scalar_one()) + + def count_organization_units(self) -> int: + stmt = select(func.count()).select_from(OrganizationUnit) + return int(self.db.execute(stmt).scalar_one()) def create(self, employee: Employee) -> Employee: self.db.add(employee) diff --git a/server/src/app/schemas/employee.py b/server/src/app/schemas/employee.py index 19becdc..540a4cb 100644 --- a/server/src/app/schemas/employee.py +++ b/server/src/app/schemas/employee.py @@ -1,24 +1,102 @@ from __future__ import annotations -from datetime import datetime +from datetime import date, datetime -from pydantic import BaseModel, ConfigDict, EmailStr +from pydantic import BaseModel, ConfigDict, EmailStr, Field -class EmployeeCreate(BaseModel): - employee_no: str +class EmployeeHistoryRead(BaseModel): + action: str + owner: str + time: str + occurredAt: str + + +class EmployeeOrganizationRead(BaseModel): + id: str + code: str name: str - department: str - email: EmailStr + unitType: str + costCenter: str | None = None + location: str | None = None + managerName: str | None = None + + +class EmployeeRoleOptionRead(BaseModel): + id: str + code: str + label: str + desc: str + permissions: list[str] = Field(default_factory=list) + + +class EmployeeStatusSummaryRead(BaseModel): + id: str + label: str + count: int + + +class EmployeeMetaRead(BaseModel): + totalEmployees: int + statusSummary: list[EmployeeStatusSummaryRead] + roleOptions: list[EmployeeRoleOptionRead] class EmployeeRead(BaseModel): - model_config = ConfigDict(from_attributes=True) + model_config = ConfigDict(from_attributes=False) id: str - employee_no: str + avatar: str name: str + employeeNo: str department: str + position: str + grade: str + manager: str + financeOwner: str + roles: list[str] = Field(default_factory=list) + roleCodes: list[str] = Field(default_factory=list) + status: str + statusTone: str + gender: str | None = None + age: int | None = None + birthDate: str | None = None email: EmailStr - created_at: datetime - updated_at: datetime + phone: str | None = None + joinDate: str | None = None + location: str | None = None + costCenter: str | None = None + updatedAt: str | None = None + lastSync: str | None = None + syncState: str + spotlight: bool = False + permissions: list[str] = Field(default_factory=list) + history: list[EmployeeHistoryRead] = Field(default_factory=list) + organization: EmployeeOrganizationRead | None = None + + +class EmployeeCreate(BaseModel): + employee_no: str = Field(min_length=1, max_length=50) + name: str = Field(min_length=1, max_length=100) + email: EmailStr + gender: str | None = Field(default=None, max_length=20) + birth_date: str | None = None + phone: str | None = Field(default=None, max_length=30) + join_date: str | None = None + location: str | None = Field(default=None, max_length=100) + position: str = Field(default="员工", max_length=100) + grade: str = Field(default="P3", max_length=20) + cost_center: str | None = Field(default=None, max_length=50) + finance_owner_name: str | None = Field(default=None, max_length=100) + employment_status: str = Field(default="在职", max_length=30) + sync_state: str = Field(default="已同步", max_length=30) + spotlight: bool = False + organization_unit_code: str | None = Field(default=None, max_length=50) + manager_employee_no: str | None = Field(default=None, max_length=50) + role_codes: list[str] = Field(default_factory=lambda: ["user"]) + + def parsed_birth_date(self) -> date | None: + return datetime.strptime(self.birth_date, "%Y-%m-%d").date() if self.birth_date else None + + def parsed_join_date(self) -> date | None: + return datetime.strptime(self.join_date, "%Y-%m-%d").date() if self.join_date else None diff --git a/server/src/app/services/employee.py b/server/src/app/services/employee.py index 5e500f0..08decee 100644 --- a/server/src/app/services/employee.py +++ b/server/src/app/services/employee.py @@ -1,34 +1,445 @@ +from __future__ import annotations + +from collections import Counter +from datetime import date, datetime +from typing import Any + from sqlalchemy.orm import Session +from app.core.config import get_settings from app.core.logging import get_logger +from app.db.base import Base +from app.db.session import get_session_factory from app.models.employee import Employee +from app.models.employee_change_log import EmployeeChangeLog +from app.models.organization import OrganizationUnit +from app.models.role import Role from app.repositories.employee import EmployeeRepository -from app.schemas.employee import EmployeeCreate +from app.schemas.employee import ( + EmployeeCreate, + EmployeeHistoryRead, + EmployeeMetaRead, + EmployeeOrganizationRead, + EmployeeRead, + EmployeeRoleOptionRead, + EmployeeStatusSummaryRead, +) +from app.services.employee_seed import ( + EMPLOYEE_DEFINITIONS, + ORGANIZATION_DEFINITIONS, + ROLE_DEFINITIONS, + ROLE_DISPLAY_ORDER, + ROLE_PERMISSION_MAP, +) logger = get_logger("app.services.employee") +STATUS_TONE_MAP = { + "在职": "success", + "试用中": "warning", + "停用": "neutral", +} + +STATUS_ORDER = ["全部员工", "在职", "试用中", "停用"] +SEEDED_EMPLOYEE_DEFINITIONS = EMPLOYEE_DEFINITIONS[:30] +EXTRA_SEED_EMPLOYEE_NOS = {item["employee_no"] for item in EMPLOYEE_DEFINITIONS[30:]} + + +def prepare_employee_directory() -> None: + settings = get_settings() + if not settings.setup_completed: + logger.info("Employee directory bootstrap skipped because setup is incomplete") + return + + session_factory = get_session_factory() + with session_factory() as db: + EmployeeService(db).ensure_directory_ready() + class EmployeeService: def __init__(self, db: Session) -> None: + self.db = db self.repository = EmployeeRepository(db) - def list_employees(self) -> list[Employee]: - employees = self.repository.list() - logger.info("Listed employees (count=%d)", len(employees)) - return employees + def ensure_directory_ready(self) -> None: + try: + Base.metadata.create_all(bind=self.db.get_bind()) + self._prune_extra_seed_employees() + self._seed_roles() + self._seed_organization_units() + self._seed_employees() + self.db.commit() + except Exception: + self.db.rollback() + logger.exception("Failed to prepare employee directory") + raise - def get_employee(self, employee_id: str) -> Employee | None: + def list_employees(self, status: str | None = None, keyword: str | None = None) -> list[EmployeeRead]: + self.ensure_directory_ready() + employees = self.repository.list(status=status, keyword=keyword) + logger.info("Listed employees (count=%d, status=%s, keyword=%s)", len(employees), status, keyword) + return [self._serialize_employee(item) for item in employees] + + def get_employee(self, employee_id: str) -> EmployeeRead | None: + self.ensure_directory_ready() employee = self.repository.get(employee_id) - if employee: - logger.info("Fetched employee id=%s name=%s", employee_id, employee.name) - else: + if employee is None: logger.warning("Employee not found id=%s", employee_id) - return employee + return None + + logger.info("Fetched employee id=%s name=%s", employee_id, employee.name) + return self._serialize_employee(employee) + + def get_employee_meta(self) -> EmployeeMetaRead: + self.ensure_directory_ready() + employees = self.repository.list() + status_counter = Counter(item.employment_status for item in employees) + + status_summary = [ + EmployeeStatusSummaryRead( + id=status, + label=status, + count=len(employees) if status == "全部员工" else status_counter.get(status, 0), + ) + for status in STATUS_ORDER + ] + + role_options = [ + EmployeeRoleOptionRead( + id=role.role_code, + code=role.role_code, + label=role.name, + desc=role.description, + permissions=list(ROLE_PERMISSION_MAP.get(role.role_code, [])), + ) + for role in self._sorted_roles(self.repository.list_roles()) + ] + + return EmployeeMetaRead( + totalEmployees=len(employees), + statusSummary=status_summary, + roleOptions=role_options, + ) + + def create_employee(self, payload: EmployeeCreate) -> EmployeeRead: + self.ensure_directory_ready() + + if self.repository.get_by_employee_no(payload.employee_no): + raise ValueError(f"员工编号 {payload.employee_no} 已存在") + + if self.repository.get_by_email(str(payload.email)): + raise ValueError(f"邮箱 {payload.email} 已存在") + + employee = Employee( + employee_no=payload.employee_no, + name=payload.name, + email=str(payload.email), + gender=payload.gender, + birth_date=payload.parsed_birth_date(), + phone=payload.phone, + join_date=payload.parsed_join_date(), + location=payload.location, + position=payload.position, + grade=payload.grade, + cost_center=payload.cost_center, + finance_owner_name=payload.finance_owner_name, + employment_status=payload.employment_status, + sync_state=payload.sync_state, + spotlight=payload.spotlight, + last_sync_at=datetime.now(), + ) + + if payload.organization_unit_code: + employee.organization_unit = self.repository.get_organization_by_code(payload.organization_unit_code) + + if payload.manager_employee_no: + employee.manager = self.repository.get_by_employee_no(payload.manager_employee_no) + + roles = [ + role + for code in payload.role_codes + if (role := self.repository.get_role_by_code(code)) is not None + ] + employee.roles = self._sorted_roles(roles) - def create_employee(self, payload: EmployeeCreate) -> Employee: - employee = Employee(**payload.model_dump()) created = self.repository.create(employee) logger.info( "Created employee id=%s no=%s name=%s", created.id, created.employee_no, created.name ) - return created + + hydrated = self.repository.get(created.id) + return self._serialize_employee(hydrated or created) + + def _seed_roles(self) -> None: + existing_by_code = {role.role_code: role for role in self.repository.list_roles()} + + for definition in ROLE_DEFINITIONS: + role = existing_by_code.get(definition["role_code"]) + if role is None: + role = Role( + role_code=definition["role_code"], + name=definition["name"], + description=definition["description"], + ) + self.db.add(role) + existing_by_code[role.role_code] = role + + self.db.flush() + + def _seed_organization_units(self) -> None: + existing_by_code = { + unit.unit_code: unit for unit in self.repository.list_organization_units() + } + + for definition in ORGANIZATION_DEFINITIONS: + organization = existing_by_code.get(definition["unit_code"]) + if organization is None: + organization = OrganizationUnit( + unit_code=definition["unit_code"], + name=definition["name"], + unit_type=definition["unit_type"], + cost_center=definition.get("cost_center"), + location=definition.get("location"), + manager_name=definition.get("manager_name"), + ) + self.db.add(organization) + existing_by_code[organization.unit_code] = organization + + self.db.flush() + + for definition in ORGANIZATION_DEFINITIONS: + parent_code = definition.get("parent_code") + if not parent_code: + continue + + organization = existing_by_code[definition["unit_code"]] + if organization.parent_id: + continue + + parent = existing_by_code.get(parent_code) + if parent is not None: + organization.parent = parent + + self.db.flush() + + def _seed_employees(self) -> None: + employees_by_no = { + employee.employee_no: employee for employee in self.repository.list() + } + roles_by_code = {role.role_code: role for role in self.repository.list_roles()} + organizations_by_code = { + unit.unit_code: unit for unit in self.repository.list_organization_units() + } + + for definition in SEEDED_EMPLOYEE_DEFINITIONS: + employee_no = definition["employee_no"] + if employee_no in employees_by_no: + continue + + employee = Employee( + employee_no=employee_no, + name=definition["name"], + email=definition["email"], + gender=definition.get("gender"), + birth_date=self._parse_date(definition.get("birth_date")), + phone=definition.get("phone"), + join_date=self._parse_date(definition.get("join_date")), + location=definition.get("location"), + position=definition.get("position", "员工"), + grade=definition.get("grade", "P3"), + cost_center=definition.get("cost_center"), + finance_owner_name=definition.get("finance_owner_name"), + employment_status=definition.get("employment_status", "在职"), + sync_state=definition.get("sync_state", "已同步"), + spotlight=bool(definition.get("spotlight")), + last_sync_at=self._parse_datetime(definition.get("last_sync_at")), + updated_at=self._parse_datetime(definition.get("updated_at")), + ) + self.db.add(employee) + employees_by_no[employee_no] = employee + + self.db.flush() + + for definition in SEEDED_EMPLOYEE_DEFINITIONS: + employee = employees_by_no[definition["employee_no"]] + organization_code = definition.get("organization_unit_code") + manager_employee_no = definition.get("manager_employee_no") + + if employee.organization_unit_id is None and organization_code: + employee.organization_unit = organizations_by_code.get(organization_code) + + if employee.manager_id is None and manager_employee_no: + employee.manager = employees_by_no.get(manager_employee_no) + + if not employee.roles: + employee.roles = self._sorted_roles( + [ + roles_by_code[role_code] + for role_code in definition.get("role_codes", []) + if role_code in roles_by_code + ] + ) + + self._seed_employee_history(employee, definition) + + self.db.flush() + + def _prune_extra_seed_employees(self) -> None: + if not EXTRA_SEED_EMPLOYEE_NOS: + return + + for employee_no in EXTRA_SEED_EMPLOYEE_NOS: + employee = self.repository.get_by_employee_no(employee_no) + if employee is not None: + self.db.delete(employee) + + def _seed_employee_history(self, employee: Employee, definition: dict[str, Any]) -> None: + existing_keys = { + (item.action, item.owner, self._format_datetime(item.occurred_at)) + for item in employee.change_logs + } + + history_items = list(definition.get("history", [])) + if not history_items: + history_items = [ + { + "action": "初始化员工档案", + "owner": "系统初始化任务", + "occurred_at": definition.get("updated_at") or definition.get("last_sync_at"), + } + ] + + for history in history_items: + occurred_at = self._parse_datetime(history.get("occurred_at")) + if occurred_at is None: + continue + + identity = ( + history["action"], + history["owner"], + self._format_datetime(occurred_at), + ) + if identity in existing_keys: + continue + + self.db.add( + EmployeeChangeLog( + employee=employee, + action=history["action"], + owner=history["owner"], + occurred_at=occurred_at, + ) + ) + existing_keys.add(identity) + + def _serialize_employee(self, employee: Employee) -> EmployeeRead: + organization = employee.organization_unit + roles = self._sorted_roles(list(employee.roles)) + role_labels = [role.name for role in roles] + role_codes = [role.role_code for role in roles] + + history = [ + EmployeeHistoryRead( + action=item.action, + owner=item.owner, + time=self._format_datetime(item.occurred_at) or "", + occurredAt=self._format_datetime(item.occurred_at) or "", + ) + for item in employee.change_logs + ] + + return EmployeeRead( + id=employee.id, + avatar=(employee.name or "?")[:1], + name=employee.name, + employeeNo=employee.employee_no, + department=organization.name if organization else "", + position=employee.position, + grade=employee.grade, + manager=employee.manager.name if employee.manager else "CEO", + financeOwner=employee.finance_owner_name or "", + roles=role_labels, + roleCodes=role_codes, + status=employee.employment_status, + statusTone=STATUS_TONE_MAP.get(employee.employment_status, "neutral"), + gender=employee.gender, + age=self._calculate_age(employee.birth_date), + birthDate=self._format_date(employee.birth_date), + email=employee.email, + phone=employee.phone, + joinDate=self._format_date(employee.join_date), + location=employee.location, + costCenter=employee.cost_center, + updatedAt=self._format_datetime(employee.updated_at or employee.created_at), + lastSync=self._format_datetime(employee.last_sync_at), + syncState=employee.sync_state, + spotlight=employee.spotlight, + permissions=self._collect_permissions(role_codes), + history=history, + organization=( + EmployeeOrganizationRead( + id=organization.id, + code=organization.unit_code, + name=organization.name, + unitType=organization.unit_type, + costCenter=organization.cost_center, + location=organization.location, + managerName=organization.manager_name, + ) + if organization + else None + ), + ) + + def _collect_permissions(self, role_codes: list[str]) -> list[str]: + permissions: list[str] = [] + seen: set[str] = set() + + for role_code in role_codes: + for permission in ROLE_PERMISSION_MAP.get(role_code, []): + if permission in seen: + continue + permissions.append(permission) + seen.add(permission) + + return permissions + + def _sorted_roles(self, roles: list[Role]) -> list[Role]: + return sorted(roles, key=lambda item: (ROLE_DISPLAY_ORDER.get(item.role_code, 999), item.name)) + + @staticmethod + def _parse_date(value: str | None) -> date | None: + if not value: + return None + return datetime.strptime(value, "%Y-%m-%d").date() + + @staticmethod + def _parse_datetime(value: str | datetime | None) -> datetime | None: + if value is None: + return None + if isinstance(value, datetime): + return value + return datetime.strptime(value, "%Y-%m-%d %H:%M") + + @staticmethod + def _format_date(value: date | None) -> str | None: + if value is None: + return None + return value.strftime("%Y-%m-%d") + + @staticmethod + def _format_datetime(value: datetime | None) -> str | None: + if value is None: + return None + return value.strftime("%Y-%m-%d %H:%M") + + @staticmethod + def _calculate_age(birth_date: date | None) -> int | None: + if birth_date is None: + return None + + today = date.today() + age = today.year - birth_date.year + if (today.month, today.day) < (birth_date.month, birth_date.day): + age -= 1 + return age diff --git a/server/src/app/services/employee_seed.py b/server/src/app/services/employee_seed.py new file mode 100644 index 0000000..84c2660 --- /dev/null +++ b/server/src/app/services/employee_seed.py @@ -0,0 +1,986 @@ +from __future__ import annotations + +ROLE_DISPLAY_ORDER = { + "manager": 1, + "finance": 2, + "approver": 3, + "executive": 4, + "auditor": 5, + "user": 6, +} + +ROLE_DEFINITIONS = [ + { + "role_code": "user", + "name": "使用者", + "description": "可以发起报销、查看个人单据和使用 AI 助手。", + }, + { + "role_code": "finance", + "name": "财务人员", + "description": "可以处理复核、查看财务知识与风险校验结果。", + }, + { + "role_code": "manager", + "name": "管理员", + "description": "可以维护员工档案、组织结构和角色权限。", + }, + { + "role_code": "executive", + "name": "高级管理人员", + "description": "可以查看跨部门数据看板与关键审批结果。", + }, + { + "role_code": "approver", + "name": "审批负责人", + "description": "可以处理审批中心中的待审单据。", + }, + { + "role_code": "auditor", + "name": "审计观察员", + "description": "可以查看变更记录和权限调整历史。", + }, +] + +ROLE_PERMISSION_MAP = { + "user": ["可发起差旅申请与报销", "可查看个人单据与票据识别结果"], + "finance": ["可处理财务复核任务", "可查看风险校验与财务知识库"], + "manager": ["可维护员工档案与组织结构", "可配置系统角色与访问边界"], + "executive": ["可查看跨部门经营看板", "可处理高金额报销最终审批"], + "approver": ["可处理本部门待审单据", "可查看审批链路与 SLA 状态"], + "auditor": ["可查看权限变更与审计留痕", "可导出员工权限观察记录"], +} + +ORGANIZATION_DEFINITIONS = [ + { + "unit_code": "ORG-ROOT", + "name": "星海科技", + "unit_type": "company", + "parent_code": None, + "cost_center": "CC-0000", + "location": "上海", + "manager_name": "李文静", + }, + { + "unit_code": "EXEC-OFFICE", + "name": "总经办", + "unit_type": "department", + "parent_code": "ORG-ROOT", + "cost_center": "CC-1001", + "location": "上海", + "manager_name": "李文静", + }, + { + "unit_code": "FIN-SSC", + "name": "财务共享中心", + "unit_type": "department", + "parent_code": "ORG-ROOT", + "cost_center": "CC-2108", + "location": "上海", + "manager_name": "张晓晴", + }, + { + "unit_code": "HR-OD", + "name": "人力与组织", + "unit_type": "department", + "parent_code": "ORG-ROOT", + "cost_center": "CC-3206", + "location": "杭州", + "manager_name": "陈硕", + }, + { + "unit_code": "SALES-SOUTH", + "name": "华南销售部", + "unit_type": "department", + "parent_code": "ORG-ROOT", + "cost_center": "CC-4102", + "location": "深圳", + "manager_name": "陈嘉", + }, + { + "unit_code": "SALES-EAST", + "name": "华东销售部", + "unit_type": "department", + "parent_code": "ORG-ROOT", + "cost_center": "CC-4108", + "location": "上海", + "manager_name": "秦墨然", + }, + { + "unit_code": "MKT-BRAND", + "name": "市场品牌部", + "unit_type": "department", + "parent_code": "ORG-ROOT", + "cost_center": "CC-5203", + "location": "北京", + "manager_name": "刘思雨", + }, + { + "unit_code": "RND-CENTER", + "name": "产品研发中心", + "unit_type": "department", + "parent_code": "ORG-ROOT", + "cost_center": "CC-6105", + "location": "北京", + "manager_name": "吴磊", + }, + { + "unit_code": "OPS-ADMIN", + "name": "行政采购部", + "unit_type": "department", + "parent_code": "ORG-ROOT", + "cost_center": "CC-7204", + "location": "南京", + "manager_name": "梁雨辰", + }, + { + "unit_code": "AUDIT-RISK", + "name": "风控与审计部", + "unit_type": "department", + "parent_code": "ORG-ROOT", + "cost_center": "CC-8102", + "location": "上海", + "manager_name": "顾承宇", + }, +] + +EMPLOYEE_DEFINITIONS = [ + { + "employee_no": "E10018", + "name": "李文静", + "gender": "女", + "birth_date": "1987-03-26", + "phone": "13900187688", + "email": "wenjing.li@xfinance.com", + "join_date": "2018-06-21", + "location": "上海", + "position": "高级财务总监", + "grade": "D2", + "organization_unit_code": "EXEC-OFFICE", + "manager_employee_no": None, + "finance_owner_name": "集团财务", + "cost_center": "CC-1001", + "employment_status": "在职", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-05 16:20", + "last_sync_at": "2026-05-05 16:20", + "role_codes": ["executive", "approver"], + }, + { + "employee_no": "E10234", + "name": "张晓晴", + "gender": "女", + "birth_date": "1994-08-12", + "phone": "13810234567", + "email": "xiaoqing.zhang@xfinance.com", + "join_date": "2021-03-15", + "location": "上海", + "position": "费用运营经理", + "grade": "M3", + "organization_unit_code": "FIN-SSC", + "manager_employee_no": "E10018", + "finance_owner_name": "华东财务组", + "cost_center": "CC-2108", + "employment_status": "在职", + "sync_state": "待生效", + "spotlight": True, + "updated_at": "2026-05-06 10:24", + "last_sync_at": "2026-05-06 10:24", + "role_codes": ["manager", "finance", "approver"], + "history": [ + { + "action": "新增“审批负责人”角色", + "owner": "系统管理员 · 王敏", + "occurred_at": "2026-05-06 10:24", + }, + { + "action": "调整财务归口为华东财务组", + "owner": "组织管理员 · 陈硕", + "occurred_at": "2026-05-05 18:10", + }, + ], + }, + { + "employee_no": "E10258", + "name": "孙楠", + "gender": "男", + "birth_date": "1992-09-17", + "phone": "13722580312", + "email": "nan.sun@xfinance.com", + "join_date": "2020-11-09", + "location": "上海", + "position": "财务分析师", + "grade": "P5", + "organization_unit_code": "FIN-SSC", + "manager_employee_no": "E10234", + "finance_owner_name": "华东财务组", + "cost_center": "CC-2111", + "employment_status": "在职", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-04 15:18", + "last_sync_at": "2026-05-04 15:18", + "role_codes": ["finance"], + }, + { + "employee_no": "E10271", + "name": "周悦宁", + "gender": "女", + "birth_date": "1993-04-21", + "phone": "13622711986", + "email": "yuening.zhou@xfinance.com", + "join_date": "2021-07-05", + "location": "上海", + "position": "财务系统专员", + "grade": "P5", + "organization_unit_code": "FIN-SSC", + "manager_employee_no": "E10234", + "finance_owner_name": "华东财务组", + "cost_center": "CC-2112", + "employment_status": "在职", + "sync_state": "同步中", + "spotlight": False, + "updated_at": "2026-05-07 09:35", + "last_sync_at": "2026-05-07 09:10", + "role_codes": ["finance", "auditor"], + }, + { + "employee_no": "E10289", + "name": "高嘉禾", + "gender": "女", + "birth_date": "1996-02-14", + "phone": "13522895642", + "email": "jiahe.gao@xfinance.com", + "join_date": "2023-01-10", + "location": "上海", + "position": "差旅合规专员", + "grade": "P4", + "organization_unit_code": "FIN-SSC", + "manager_employee_no": "E10234", + "finance_owner_name": "华东财务组", + "cost_center": "CC-2115", + "employment_status": "在职", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-03 11:42", + "last_sync_at": "2026-05-03 11:42", + "role_codes": ["finance"], + }, + { + "employee_no": "E10867", + "name": "王敏", + "gender": "女", + "birth_date": "1996-11-05", + "phone": "13688671200", + "email": "min.wang@xfinance.com", + "join_date": "2022-08-08", + "location": "杭州", + "position": "组织发展主管", + "grade": "P6", + "organization_unit_code": "HR-OD", + "manager_employee_no": "E11618", + "finance_owner_name": "总部财务BP", + "cost_center": "CC-3206", + "employment_status": "在职", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-05 09:18", + "last_sync_at": "2026-05-05 09:18", + "role_codes": ["manager", "auditor"], + }, + { + "employee_no": "E11618", + "name": "陈硕", + "gender": "男", + "birth_date": "1990-05-09", + "phone": "13816186540", + "email": "shuo.chen@xfinance.com", + "join_date": "2019-09-16", + "location": "杭州", + "position": "人力资源经理", + "grade": "M2", + "organization_unit_code": "HR-OD", + "manager_employee_no": "E10018", + "finance_owner_name": "总部财务BP", + "cost_center": "CC-3201", + "employment_status": "在职", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-04 17:08", + "last_sync_at": "2026-05-04 17:08", + "role_codes": ["manager", "approver"], + }, + { + "employee_no": "E12311", + "name": "何思成", + "gender": "男", + "birth_date": "1998-07-19", + "phone": "13723117654", + "email": "sicheng.he@xfinance.com", + "join_date": "2026-02-17", + "location": "杭州", + "position": "HRBP", + "grade": "P4", + "organization_unit_code": "HR-OD", + "manager_employee_no": "E11618", + "finance_owner_name": "总部财务BP", + "cost_center": "CC-3208", + "employment_status": "试用中", + "sync_state": "待生效", + "spotlight": False, + "updated_at": "2026-05-07 08:42", + "last_sync_at": "2026-05-07 08:42", + "role_codes": ["user"], + }, + { + "employee_no": "E11026", + "name": "刘思雨", + "gender": "女", + "birth_date": "1991-12-03", + "phone": "13921036540", + "email": "siyu.liu@xfinance.com", + "join_date": "2020-04-13", + "location": "北京", + "position": "品牌市场经理", + "grade": "M2", + "organization_unit_code": "MKT-BRAND", + "manager_employee_no": "E10018", + "finance_owner_name": "市场财务BP", + "cost_center": "CC-5203", + "employment_status": "在职", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-06 14:36", + "last_sync_at": "2026-05-06 14:36", + "role_codes": ["user", "approver"], + }, + { + "employee_no": "E12408", + "name": "冯可欣", + "gender": "女", + "birth_date": "1997-10-28", + "phone": "13624085542", + "email": "kexin.feng@xfinance.com", + "join_date": "2024-03-11", + "location": "北京", + "position": "品牌策划", + "grade": "P4", + "organization_unit_code": "MKT-BRAND", + "manager_employee_no": "E11026", + "finance_owner_name": "市场财务BP", + "cost_center": "CC-5207", + "employment_status": "在职", + "sync_state": "同步中", + "spotlight": False, + "updated_at": "2026-05-07 10:02", + "last_sync_at": "2026-05-07 09:48", + "role_codes": ["user"], + }, + { + "employee_no": "E12419", + "name": "许泽航", + "gender": "男", + "birth_date": "1995-05-15", + "phone": "13524199508", + "email": "zehang.xu@xfinance.com", + "join_date": "2023-06-19", + "location": "北京", + "position": "数字营销专员", + "grade": "P4", + "organization_unit_code": "MKT-BRAND", + "manager_employee_no": "E11026", + "finance_owner_name": "市场财务BP", + "cost_center": "CC-5209", + "employment_status": "在职", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-03 16:52", + "last_sync_at": "2026-05-03 16:52", + "role_codes": ["user"], + }, + { + "employee_no": "E11602", + "name": "陈嘉", + "gender": "男", + "birth_date": "1997-02-18", + "phone": "13716029901", + "email": "jia.chen@xfinance.com", + "join_date": "2026-03-01", + "location": "深圳", + "position": "区域销售经理", + "grade": "M2", + "organization_unit_code": "SALES-SOUTH", + "manager_employee_no": "E10018", + "finance_owner_name": "华南财务组", + "cost_center": "CC-4102", + "employment_status": "在职", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-04 14:12", + "last_sync_at": "2026-05-04 14:12", + "role_codes": ["user", "approver"], + }, + { + "employee_no": "E12476", + "name": "马骁然", + "gender": "男", + "birth_date": "1994-01-08", + "phone": "13824760139", + "email": "xiaoran.ma@xfinance.com", + "join_date": "2022-09-05", + "location": "深圳", + "position": "销售运营专家", + "grade": "P5", + "organization_unit_code": "SALES-SOUTH", + "manager_employee_no": "E11602", + "finance_owner_name": "华南财务组", + "cost_center": "CC-4106", + "employment_status": "在职", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-06 18:15", + "last_sync_at": "2026-05-06 18:15", + "role_codes": ["user"], + }, + { + "employee_no": "E12508", + "name": "唐子墨", + "gender": "男", + "birth_date": "1996-06-11", + "phone": "13925088761", + "email": "zimo.tang@xfinance.com", + "join_date": "2024-02-26", + "location": "深圳", + "position": "大客户代表", + "grade": "P4", + "organization_unit_code": "SALES-SOUTH", + "manager_employee_no": "E11602", + "finance_owner_name": "华南财务组", + "cost_center": "CC-4109", + "employment_status": "停用", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-01 11:06", + "last_sync_at": "2026-05-01 11:06", + "role_codes": ["user"], + }, + { + "employee_no": "E12514", + "name": "罗欣怡", + "gender": "女", + "birth_date": "2000-03-02", + "phone": "13625141227", + "email": "xinyi.luo@xfinance.com", + "join_date": "2026-02-24", + "location": "深圳", + "position": "销售协调专员", + "grade": "P3", + "organization_unit_code": "SALES-SOUTH", + "manager_employee_no": "E11602", + "finance_owner_name": "华南财务组", + "cost_center": "CC-4112", + "employment_status": "试用中", + "sync_state": "待生效", + "spotlight": False, + "updated_at": "2026-05-05 15:42", + "last_sync_at": "2026-05-05 15:42", + "role_codes": ["user"], + }, + { + "employee_no": "E11745", + "name": "吴磊", + "gender": "男", + "birth_date": "1989-09-27", + "phone": "13817459812", + "email": "lei.wu@xfinance.com", + "join_date": "2019-12-09", + "location": "北京", + "position": "研发平台主管", + "grade": "M3", + "organization_unit_code": "RND-CENTER", + "manager_employee_no": "E10018", + "finance_owner_name": "研发财务BP", + "cost_center": "CC-6105", + "employment_status": "在职", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-06 13:08", + "last_sync_at": "2026-05-06 13:08", + "role_codes": ["user", "approver", "auditor"], + }, + { + "employee_no": "E11991", + "name": "赵明", + "gender": "男", + "birth_date": "1994-06-09", + "phone": "13519913300", + "email": "ming.zhao@xfinance.com", + "join_date": "2023-11-18", + "location": "北京", + "position": "产品经理", + "grade": "P5", + "organization_unit_code": "RND-CENTER", + "manager_employee_no": "E11745", + "finance_owner_name": "研发财务BP", + "cost_center": "CC-6112", + "employment_status": "在职", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-02 11:32", + "last_sync_at": "2026-05-02 11:32", + "role_codes": ["user"], + }, + { + "employee_no": "E12611", + "name": "彭一凡", + "gender": "男", + "birth_date": "1995-02-03", + "phone": "13726114588", + "email": "yifan.peng@xfinance.com", + "join_date": "2022-04-18", + "location": "北京", + "position": "后端工程师", + "grade": "P5", + "organization_unit_code": "RND-CENTER", + "manager_employee_no": "E11745", + "finance_owner_name": "研发财务BP", + "cost_center": "CC-6114", + "employment_status": "在职", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-06 09:44", + "last_sync_at": "2026-05-06 09:44", + "role_codes": ["user"], + }, + { + "employee_no": "E12618", + "name": "苏清禾", + "gender": "女", + "birth_date": "1994-12-25", + "phone": "13626188763", + "email": "qinghe.su@xfinance.com", + "join_date": "2022-05-16", + "location": "北京", + "position": "数据工程师", + "grade": "P5", + "organization_unit_code": "RND-CENTER", + "manager_employee_no": "E11745", + "finance_owner_name": "研发财务BP", + "cost_center": "CC-6116", + "employment_status": "在职", + "sync_state": "同步中", + "spotlight": False, + "updated_at": "2026-05-07 10:26", + "last_sync_at": "2026-05-07 10:18", + "role_codes": ["user"], + }, + { + "employee_no": "E12624", + "name": "沈知远", + "gender": "男", + "birth_date": "1992-11-06", + "phone": "13926241855", + "email": "zhiyuan.shen@xfinance.com", + "join_date": "2021-11-22", + "location": "北京", + "position": "测试负责人", + "grade": "P6", + "organization_unit_code": "RND-CENTER", + "manager_employee_no": "E11745", + "finance_owner_name": "研发财务BP", + "cost_center": "CC-6119", + "employment_status": "在职", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-05 13:12", + "last_sync_at": "2026-05-05 13:12", + "role_codes": ["user"], + }, + { + "employee_no": "E11852", + "name": "周晓彤", + "gender": "女", + "birth_date": "1997-05-27", + "phone": "13818529954", + "email": "xiaotong.zhou@xfinance.com", + "join_date": "2022-06-30", + "location": "南京", + "position": "行政采购专员", + "grade": "P4", + "organization_unit_code": "OPS-ADMIN", + "manager_employee_no": "E12653", + "finance_owner_name": "行政财务BP", + "cost_center": "CC-7204", + "employment_status": "在职", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-05 11:22", + "last_sync_at": "2026-05-05 11:22", + "role_codes": ["user"], + }, + { + "employee_no": "E12653", + "name": "梁雨辰", + "gender": "男", + "birth_date": "1991-08-30", + "phone": "13726539876", + "email": "yuchen.liang@xfinance.com", + "join_date": "2021-01-04", + "location": "南京", + "position": "行政运营经理", + "grade": "M1", + "organization_unit_code": "OPS-ADMIN", + "manager_employee_no": "E10018", + "finance_owner_name": "行政财务BP", + "cost_center": "CC-7201", + "employment_status": "在职", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-06 17:44", + "last_sync_at": "2026-05-06 17:44", + "role_codes": ["user", "approver"], + }, + { + "employee_no": "E12661", + "name": "顾承宇", + "gender": "男", + "birth_date": "1988-04-16", + "phone": "13926614528", + "email": "chengyu.gu@xfinance.com", + "join_date": "2020-02-03", + "location": "上海", + "position": "风控审计经理", + "grade": "M2", + "organization_unit_code": "AUDIT-RISK", + "manager_employee_no": "E10018", + "finance_owner_name": "集团财务", + "cost_center": "CC-8102", + "employment_status": "在职", + "sync_state": "待生效", + "spotlight": True, + "updated_at": "2026-05-07 09:52", + "last_sync_at": "2026-05-07 09:52", + "role_codes": ["auditor", "finance"], + "history": [ + { + "action": "更新审计观察范围", + "owner": "系统管理员 · 张晓晴", + "occurred_at": "2026-05-07 09:52", + }, + { + "action": "补充高风险费用抽样规则", + "owner": "审计管理员 · 王敏", + "occurred_at": "2026-05-06 18:30", + }, + ], + }, + { + "employee_no": "E12679", + "name": "郑若彤", + "gender": "女", + "birth_date": "1997-09-13", + "phone": "13626794520", + "email": "ruotong.zheng@xfinance.com", + "join_date": "2024-01-08", + "location": "上海", + "position": "审计专员", + "grade": "P4", + "organization_unit_code": "AUDIT-RISK", + "manager_employee_no": "E12661", + "finance_owner_name": "集团财务", + "cost_center": "CC-8105", + "employment_status": "在职", + "sync_state": "同步中", + "spotlight": False, + "updated_at": "2026-05-07 08:58", + "last_sync_at": "2026-05-07 08:40", + "role_codes": ["auditor"], + }, + { + "employee_no": "E12688", + "name": "方逸晨", + "gender": "男", + "birth_date": "1995-01-20", + "phone": "13526881142", + "email": "yichen.fang@xfinance.com", + "join_date": "2023-08-14", + "location": "南京", + "position": "采购合规分析师", + "grade": "P4", + "organization_unit_code": "OPS-ADMIN", + "manager_employee_no": "E12653", + "finance_owner_name": "行政财务BP", + "cost_center": "CC-7208", + "employment_status": "在职", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-03 14:16", + "last_sync_at": "2026-05-03 14:16", + "role_codes": ["user", "finance"], + }, + { + "employee_no": "E12067", + "name": "秦墨然", + "gender": "男", + "birth_date": "1990-10-10", + "phone": "13820674519", + "email": "moran.qin@xfinance.com", + "join_date": "2020-07-20", + "location": "上海", + "position": "华东销售总监", + "grade": "M2", + "organization_unit_code": "SALES-EAST", + "manager_employee_no": "E10018", + "finance_owner_name": "华东财务组", + "cost_center": "CC-4108", + "employment_status": "在职", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-06 12:40", + "last_sync_at": "2026-05-06 12:40", + "role_codes": ["user", "approver"], + }, + { + "employee_no": "E12703", + "name": "宋知夏", + "gender": "女", + "birth_date": "1994-07-07", + "phone": "13727031129", + "email": "zhixia.song@xfinance.com", + "join_date": "2022-12-12", + "location": "上海", + "position": "重点客户经理", + "grade": "P5", + "organization_unit_code": "SALES-EAST", + "manager_employee_no": "E12067", + "finance_owner_name": "华东财务组", + "cost_center": "CC-4111", + "employment_status": "在职", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-04 10:58", + "last_sync_at": "2026-05-04 10:58", + "role_codes": ["user"], + }, + { + "employee_no": "E12716", + "name": "杜嘉宁", + "gender": "男", + "birth_date": "1999-11-16", + "phone": "13627161248", + "email": "jianing.du@xfinance.com", + "join_date": "2026-01-19", + "location": "上海", + "position": "销售代表", + "grade": "P3", + "organization_unit_code": "SALES-EAST", + "manager_employee_no": "E12067", + "finance_owner_name": "华东财务组", + "cost_center": "CC-4114", + "employment_status": "试用中", + "sync_state": "待生效", + "spotlight": False, + "updated_at": "2026-05-05 12:26", + "last_sync_at": "2026-05-05 12:26", + "role_codes": ["user"], + }, + { + "employee_no": "E12722", + "name": "邵宁远", + "gender": "男", + "birth_date": "1998-12-01", + "phone": "13527221506", + "email": "ningyuan.shao@xfinance.com", + "join_date": "2026-02-08", + "location": "北京", + "position": "数据分析师", + "grade": "P4", + "organization_unit_code": "RND-CENTER", + "manager_employee_no": "E11745", + "finance_owner_name": "研发财务BP", + "cost_center": "CC-6122", + "employment_status": "试用中", + "sync_state": "同步中", + "spotlight": False, + "updated_at": "2026-05-07 09:06", + "last_sync_at": "2026-05-07 08:55", + "role_codes": ["user"], + }, + { + "employee_no": "E12739", + "name": "林可昕", + "gender": "女", + "birth_date": "1996-10-23", + "phone": "13827394510", + "email": "kexin.lin@xfinance.com", + "join_date": "2023-04-17", + "location": "上海", + "position": "费用核算专员", + "grade": "P4", + "organization_unit_code": "FIN-SSC", + "manager_employee_no": "E10234", + "finance_owner_name": "华东财务组", + "cost_center": "CC-2118", + "employment_status": "停用", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-04-30 18:05", + "last_sync_at": "2026-04-30 18:05", + "role_codes": ["finance"], + }, + { + "employee_no": "E12744", + "name": "赵予安", + "gender": "男", + "birth_date": "1993-01-30", + "phone": "13727442139", + "email": "yuan.zhao@xfinance.com", + "join_date": "2021-10-11", + "location": "上海", + "position": "预算控制经理", + "grade": "M1", + "organization_unit_code": "FIN-SSC", + "manager_employee_no": "E10234", + "finance_owner_name": "集团财务", + "cost_center": "CC-2120", + "employment_status": "在职", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-06 15:34", + "last_sync_at": "2026-05-06 15:34", + "role_codes": ["finance", "approver"], + }, + { + "employee_no": "E12750", + "name": "谢知行", + "gender": "男", + "birth_date": "1995-09-14", + "phone": "13627501386", + "email": "zhixing.xie@xfinance.com", + "join_date": "2022-07-25", + "location": "深圳", + "position": "渠道销售经理", + "grade": "P5", + "organization_unit_code": "SALES-SOUTH", + "manager_employee_no": "E11602", + "finance_owner_name": "华南财务组", + "cost_center": "CC-4116", + "employment_status": "在职", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-04 09:48", + "last_sync_at": "2026-05-04 09:48", + "role_codes": ["user"], + }, + { + "employee_no": "E12758", + "name": "顾南枝", + "gender": "女", + "birth_date": "1994-04-12", + "phone": "13827584522", + "email": "nanzhi.gu@xfinance.com", + "join_date": "2022-05-09", + "location": "北京", + "position": "内容运营经理", + "grade": "P5", + "organization_unit_code": "MKT-BRAND", + "manager_employee_no": "E11026", + "finance_owner_name": "市场财务BP", + "cost_center": "CC-5211", + "employment_status": "在职", + "sync_state": "同步中", + "spotlight": False, + "updated_at": "2026-05-07 11:08", + "last_sync_at": "2026-05-07 10:50", + "role_codes": ["user"], + }, + { + "employee_no": "E12763", + "name": "孟书言", + "gender": "男", + "birth_date": "1992-02-09", + "phone": "13527633148", + "email": "shuyan.meng@xfinance.com", + "join_date": "2021-06-28", + "location": "北京", + "position": "架构工程师", + "grade": "P6", + "organization_unit_code": "RND-CENTER", + "manager_employee_no": "E11745", + "finance_owner_name": "研发财务BP", + "cost_center": "CC-6125", + "employment_status": "在职", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-06 19:05", + "last_sync_at": "2026-05-06 19:05", + "role_codes": ["user"], + }, + { + "employee_no": "E12771", + "name": "孔令谦", + "gender": "男", + "birth_date": "1993-07-18", + "phone": "13627711572", + "email": "lingqian.kong@xfinance.com", + "join_date": "2021-09-13", + "location": "南京", + "position": "供应商管理专员", + "grade": "P4", + "organization_unit_code": "OPS-ADMIN", + "manager_employee_no": "E12653", + "finance_owner_name": "行政财务BP", + "cost_center": "CC-7210", + "employment_status": "在职", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-02 17:22", + "last_sync_at": "2026-05-02 17:22", + "role_codes": ["user"], + }, + { + "employee_no": "E12782", + "name": "乔语岚", + "gender": "女", + "birth_date": "1996-05-06", + "phone": "13727823045", + "email": "yulan.qiao@xfinance.com", + "join_date": "2023-03-06", + "location": "上海", + "position": "风控策略分析师", + "grade": "P4", + "organization_unit_code": "AUDIT-RISK", + "manager_employee_no": "E12661", + "finance_owner_name": "集团财务", + "cost_center": "CC-8108", + "employment_status": "在职", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-03 13:18", + "last_sync_at": "2026-05-03 13:18", + "role_codes": ["auditor"], + }, + { + "employee_no": "E12790", + "name": "邹闻韬", + "gender": "男", + "birth_date": "1991-03-11", + "phone": "13827903167", + "email": "wentao.zou@xfinance.com", + "join_date": "2020-10-26", + "location": "上海", + "position": "合规产品负责人", + "grade": "P7", + "organization_unit_code": "RND-CENTER", + "manager_employee_no": "E11745", + "finance_owner_name": "研发财务BP", + "cost_center": "CC-6128", + "employment_status": "在职", + "sync_state": "已同步", + "spotlight": False, + "updated_at": "2026-05-06 08:56", + "last_sync_at": "2026-05-06 08:56", + "role_codes": ["user", "auditor"], + }, +] diff --git a/server/src/x_financial_server.egg-info/SOURCES.txt b/server/src/x_financial_server.egg-info/SOURCES.txt index be000df..7a7ec76 100644 --- a/server/src/x_financial_server.egg-info/SOURCES.txt +++ b/server/src/x_financial_server.egg-info/SOURCES.txt @@ -15,10 +15,13 @@ src/app/api/v1/endpoints/reimbursements.py src/app/core/__init__.py src/app/core/bootstrap.py src/app/core/config.py +src/app/core/logging.py src/app/db/__init__.py src/app/db/base.py src/app/db/base_class.py src/app/db/session.py +src/app/middleware/__init__.py +src/app/middleware/logging.py src/app/models/__init__.py src/app/models/approval.py src/app/models/employee.py diff --git a/server/start.sh b/server/start.sh index 3f3ff88..067075b 100644 --- a/server/start.sh +++ b/server/start.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +export MSYS_NO_PATHCONV=1 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" @@ -33,26 +35,31 @@ set +a SERVER_HOST="${SERVER_HOST:-127.0.0.1}" SERVER_PORT="${SERVER_PORT:-8000}" +SERVER_RELOAD="${SERVER_RELOAD:-false}" is_wsl() { grep -qi microsoft /proc/version 2>/dev/null } -is_windows_mount() { - case "$SCRIPT_DIR" in - /mnt/*) return 0 ;; +is_msys() { + case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) return 0 ;; *) return 1 ;; esac } +needs_windows_python() { + is_msys || is_wsl +} + find_unix_python() { - if command -v python >/dev/null 2>&1; then - echo "python" + if command -v python3 >/dev/null 2>&1; then + echo "python3" return 0 fi - if command -v python3 >/dev/null 2>&1; then - echo "python3" + if command -v python >/dev/null 2>&1; then + echo "python" return 0 fi @@ -74,22 +81,6 @@ find_windows_python() { } venv_python_path() { - if [ "${VENV_LAYOUT:-auto}" = "windows" ]; then - if [ -x "$VENV_DIR/Scripts/python.exe" ]; then - echo "$VENV_DIR/Scripts/python.exe" - return 0 - fi - return 1 - fi - - if [ "${VENV_LAYOUT:-auto}" = "unix" ]; then - if [ -x "$VENV_DIR/bin/python" ]; then - echo "$VENV_DIR/bin/python" - return 0 - fi - return 1 - fi - if [ -x "$VENV_DIR/Scripts/python.exe" ]; then echo "$VENV_DIR/Scripts/python.exe" return 0 @@ -152,30 +143,25 @@ ensure_pip() { } ensure_python_bootstrap() { - if is_wsl && is_windows_mount; then + if needs_windows_python; then if find_windows_python >/dev/null 2>&1; then PYTHON_BOOTSTRAP="$(find_windows_python)" - VENV_LAYOUT="windows" - info "Detected WSL on a Windows-mounted project" - info "Using Windows Python directly from bash" + info "Detected Windows bash environment — using Windows Python" return 0 fi if find_unix_python >/dev/null 2>&1; then PYTHON_BOOTSTRAP="$(find_unix_python)" - VENV_LAYOUT="unix" - warn "Windows Python not found, falling back to WSL Python" + warn "Windows Python not found, falling back to system Python" return 0 fi - error "Neither Windows Python nor WSL Python is available in PATH." + error "Python is not available in PATH." fi if ! PYTHON_BOOTSTRAP="$(find_unix_python)"; then error "Python is not installed or not available in PATH. Install Python 3.11+ first so the script can create server/.venv automatically." fi - - VENV_LAYOUT="unix" } ensure_dependencies() { @@ -210,7 +196,11 @@ start_server() { info "Access: http://$SERVER_HOST:$SERVER_PORT" echo "" - exec "$PYTHON_BIN" -m uvicorn app.main:app --reload --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT" + if [ "$SERVER_RELOAD" = "true" ]; then + exec "$PYTHON_BIN" -m uvicorn app.main:app --reload --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT" + fi + + exec "$PYTHON_BIN" -m uvicorn app.main:app --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT" } case "$MODE" in diff --git a/server/tests/test_employee_service.py b/server/tests/test_employee_service.py new file mode 100644 index 0000000..7fcf977 --- /dev/null +++ b/server/tests/test_employee_service.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from sqlalchemy import create_engine, func, select +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.db.base import Base +from app.models.employee import Employee +from app.models.employee_change_log import EmployeeChangeLog +from app.models.organization import OrganizationUnit +from app.models.role import Role +from app.services.employee import EmployeeService + + +def build_session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + return session_factory() + + +def test_employee_directory_seeds_rich_employee_data() -> None: + with build_session() as db: + service = EmployeeService(db) + + employees = service.list_employees() + meta = service.get_employee_meta() + + assert len(employees) == 30 + assert meta.totalEmployees == 30 + assert any(item.status == "试用中" for item in employees) + assert any(item.status == "停用" for item in employees) + assert any("审批负责人" in item.roles for item in employees) + assert any(item.permissions for item in employees) + assert any(item.history for item in employees) + + role_count = db.scalar(select(func.count()).select_from(Role)) + org_count = db.scalar(select(func.count()).select_from(OrganizationUnit)) + employee_count = db.scalar(select(func.count()).select_from(Employee)) + history_count = db.scalar(select(func.count()).select_from(EmployeeChangeLog)) + + assert role_count == 6 + assert org_count == 10 + assert employee_count == 30 + assert history_count and history_count >= 30 + + +def test_employee_detail_contains_department_and_roles() -> None: + with build_session() as db: + service = EmployeeService(db) + employee = service.list_employees()[0] + detail = service.get_employee(employee.id) + + assert detail is not None + assert detail.department + assert detail.manager + assert detail.organization is not None + assert detail.roles diff --git a/start.sh b/start.sh index 614a590..54f8629 100644 --- a/start.sh +++ b/start.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +export MSYS_NO_PATHCONV=1 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="$SCRIPT_DIR/.env" ENV_EXAMPLE_FILE="$SCRIPT_DIR/.env.example" @@ -31,6 +33,69 @@ set +a SERVER_STARTUP_TIMEOUT="${SERVER_STARTUP_TIMEOUT:-300}" SETUP_COMPLETED="${SETUP_COMPLETED:-false}" +server_probe_url() { + echo "http://${SERVER_HOST:-127.0.0.1}:${SERVER_PORT:-8000}${API_V1_PREFIX:-/api/v1}/health" +} + +server_smoke_url() { + echo "http://${SERVER_HOST:-127.0.0.1}:${SERVER_PORT:-8000}${API_V1_PREFIX:-/api/v1}/employees/meta" +} + +server_probe_python() { + if [ -x "$SCRIPT_DIR/server/.venv/Scripts/python.exe" ]; then + echo "$SCRIPT_DIR/server/.venv/Scripts/python.exe" + return 0 + fi + + if [ -x "$SCRIPT_DIR/server/.venv/bin/python" ]; then + echo "$SCRIPT_DIR/server/.venv/bin/python" + return 0 + fi + + return 1 +} + +probe_server_health() { + local probe_url="${1:-$(server_probe_url)}" + local probe_python="" + + if probe_python="$(server_probe_python)"; then + "$probe_python" -c "import json, sys, urllib.request; data = json.load(urllib.request.urlopen(sys.argv[1], timeout=2)); raise SystemExit(0 if data.get('status') == 'ok' else 1)" "$probe_url" >/dev/null 2>&1 + return $? + fi + + if command -v curl >/dev/null 2>&1; then + curl --silent --fail --max-time 2 "$probe_url" | grep -q '"status"[[:space:]]*:[[:space:]]*"ok"' + return $? + fi + + return 1 +} + +probe_server_smoke() { + local probe_url="${1:-$(server_smoke_url)}" + local probe_python="" + + if probe_python="$(server_probe_python)"; then + "$probe_python" -c "import sys, urllib.request; response = urllib.request.urlopen(sys.argv[1], timeout=3); raise SystemExit(0 if response.status == 200 else 1)" "$probe_url" >/dev/null 2>&1 + return $? + fi + + if command -v curl >/dev/null 2>&1; then + curl --silent --fail --max-time 3 "$probe_url" >/dev/null 2>&1 + return $? + fi + + return 1 +} + +probe_server_ready() { + local health_url="${1:-$(server_probe_url)}" + local smoke_url="${2:-$(server_smoke_url)}" + + probe_server_health "$health_url" && probe_server_smoke "$smoke_url" +} + prepare_web() { info "Preparing web dependencies..." ( @@ -69,12 +134,14 @@ start_setup_web() { start_all() { local server_pid="" + local started_server=false + local probe_url="" + local smoke_url="" - prepare_web prepare_server cleanup() { - if [ -n "$server_pid" ] && kill -0 "$server_pid" 2>/dev/null; then + if [ "$started_server" = true ] && [ -n "$server_pid" ] && kill -0 "$server_pid" 2>/dev/null; then warn "Stopping FastAPI server..." kill "$server_pid" 2>/dev/null || true wait "$server_pid" 2>/dev/null || true @@ -83,49 +150,61 @@ start_all() { trap cleanup EXIT INT TERM - info "Starting FastAPI server..." - ( - cd "$SCRIPT_DIR/server" - ./start.sh start - ) & - server_pid=$! + probe_url="$(server_probe_url)" + smoke_url="$(server_smoke_url)" - wait_for_server() { - local base_url="http://${SERVER_HOST:-127.0.0.1}:${SERVER_PORT:-8000}${API_V1_PREFIX:-/api/v1}/bootstrap" + if probe_server_ready "$probe_url" "$smoke_url"; then + warn "FastAPI is already ready at $probe_url. Reusing the existing backend process." + elif probe_server_health "$probe_url"; then + error "An existing backend process is responding at $probe_url, but the smoke check failed at $smoke_url. Stop the old FastAPI process and rerun ./start.sh." + else + info "Starting FastAPI server..." + ( + cd "$SCRIPT_DIR/server" + ./start.sh start + ) & + server_pid=$! + started_server=true + fi + + wait_for_server_ready() { local attempt=1 local max_attempts="$SERVER_STARTUP_TIMEOUT" - if ! command -v curl >/dev/null 2>&1; then - warn "curl not found, skipping backend readiness check." - return 0 - fi - - info "Waiting for FastAPI bootstrap endpoint..." + info "Waiting for FastAPI readiness before starting the web frontend..." while [ "$attempt" -le "$max_attempts" ]; do - if ! kill -0 "$server_pid" 2>/dev/null; then - wait "$server_pid" 2>/dev/null || true - error "FastAPI process exited before becoming ready. Run ./server/start.sh start directly to inspect the backend error." - fi - - if curl --silent --fail "$base_url" >/dev/null 2>&1; then - info "FastAPI bootstrap endpoint is ready." + if probe_server_ready "$probe_url" "$smoke_url"; then + info "FastAPI is ready. Starting web frontend next." return 0 fi - if [ $((attempt % 15)) -eq 0 ]; then - warn "FastAPI is still starting. First run may take longer while .venv and dependencies are prepared." + if [ "$started_server" = true ] && ! kill -0 "$server_pid" 2>/dev/null; then + if probe_server_ready "$probe_url" "$smoke_url"; then + warn "FastAPI is already available at $probe_url. Continuing with the existing process." + started_server=false + server_pid="" + return 0 + fi + + wait "$server_pid" 2>/dev/null || true + error "FastAPI process exited before becoming ready. Run ./server/start.sh start directly to inspect the backend error." fi sleep 1 attempt=$((attempt + 1)) done - error "FastAPI did not become ready within ${SERVER_STARTUP_TIMEOUT}s: $base_url" + if probe_server_health "$probe_url"; then + error "FastAPI answered health checks at $probe_url, but the smoke check failed at $smoke_url. The running backend is stale or incompatible." + fi + + error "FastAPI did not become ready within ${SERVER_STARTUP_TIMEOUT}s. Inspect server/logs/app.log." } - wait_for_server + wait_for_server_ready + prepare_web info "Starting web frontend..." cd "$SCRIPT_DIR/web" ./start.sh start diff --git a/UI/AI助手.png b/web/UI/AI助手.png similarity index 100% rename from UI/AI助手.png rename to web/UI/AI助手.png diff --git a/UI/background.png b/web/UI/background.png similarity index 100% rename from UI/background.png rename to web/UI/background.png diff --git a/UI/background_2560x1440.png b/web/UI/background_2560x1440.png similarity index 100% rename from UI/background_2560x1440.png rename to web/UI/background_2560x1440.png diff --git a/UI/login.png b/web/UI/login.png similarity index 100% rename from UI/login.png rename to web/UI/login.png diff --git a/UI/main_page.png b/web/UI/main_page.png similarity index 100% rename from UI/main_page.png rename to web/UI/main_page.png diff --git a/UI/发起请求.png b/web/UI/发起请求.png similarity index 100% rename from UI/发起请求.png rename to web/UI/发起请求.png diff --git a/UI/审批中心.png b/web/UI/审批中心.png similarity index 100% rename from UI/审批中心.png rename to web/UI/审批中心.png diff --git a/UI/审批中心详情.png b/web/UI/审批中心详情.png similarity index 100% rename from UI/审批中心详情.png rename to web/UI/审批中心详情.png diff --git a/UI/报销.png b/web/UI/报销.png similarity index 100% rename from UI/报销.png rename to web/UI/报销.png diff --git a/UI/知识库.png b/web/UI/知识库.png similarity index 100% rename from UI/知识库.png rename to web/UI/知识库.png diff --git a/UI/知识问答界面.png b/web/UI/知识问答界面.png similarity index 100% rename from UI/知识问答界面.png rename to web/UI/知识问答界面.png diff --git a/UI/首页工作台.png b/web/UI/首页工作台.png similarity index 100% rename from UI/首页工作台.png rename to web/UI/首页工作台.png diff --git a/UI/首页风险.png b/web/UI/首页风险.png similarity index 100% rename from UI/首页风险.png rename to web/UI/首页风险.png diff --git a/web/src/assets/robot-helper.png b/web/src/assets/robot-helper.png new file mode 100644 index 0000000..5b1197e Binary files /dev/null and b/web/src/assets/robot-helper.png differ diff --git a/web/src/assets/styles/views/backend-unavailable-view.css b/web/src/assets/styles/views/backend-unavailable-view.css new file mode 100644 index 0000000..0cfb19d --- /dev/null +++ b/web/src/assets/styles/views/backend-unavailable-view.css @@ -0,0 +1,71 @@ +.backend-unavailable { + min-height: 100vh; + display: grid; + place-items: center; + padding: 32px; + background: + radial-gradient(circle at top, rgba(16, 185, 129, 0.16), transparent 32%), + linear-gradient(180deg, #08130f 0%, #0f1f18 100%); +} + +.backend-card { + width: min(520px, 100%); + display: grid; + gap: 18px; + padding: 32px 30px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 20px; + background: rgba(7, 18, 13, 0.9); + box-shadow: 0 28px 80px rgba(0, 0, 0, 0.35); + text-align: center; +} + +.backend-badge { + width: 72px; + height: 72px; + display: grid; + place-items: center; + margin: 0 auto; + border-radius: 20px; + background: linear-gradient(135deg, rgba(16, 185, 129, 0.2), rgba(5, 150, 105, 0.28)); + color: #4ade80; + font-size: 32px; +} + +.backend-card h1 { + color: #f8fafc; + font-size: 28px; + font-weight: 800; +} + +.backend-card p { + color: rgba(226, 232, 240, 0.8); + font-size: 14px; + line-height: 1.7; +} + +.backend-actions { + display: flex; + justify-content: center; +} + +.retry-btn { + min-height: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 0 18px; + border: 1px solid rgba(16, 185, 129, 0.2); + border-radius: 10px; + background: #059669; + color: #fff; + font-size: 14px; + font-weight: 760; + box-shadow: 0 16px 30px rgba(5, 150, 105, 0.2); +} + +.retry-btn:disabled { + opacity: 0.72; + cursor: wait; +} diff --git a/web/src/assets/styles/views/employee-management-view.css b/web/src/assets/styles/views/employee-management-view.css index 53ec02f..e7b784a 100644 --- a/web/src/assets/styles/views/employee-management-view.css +++ b/web/src/assets/styles/views/employee-management-view.css @@ -21,9 +21,11 @@ } .employee-list { - display: grid; - grid-template-rows: auto auto auto minmax(0, 1fr); - padding: 18px 20px; + display: flex; + flex-direction: column; + min-height: 0; + padding: 16px 18px; + overflow: hidden; } .employee-detail { @@ -34,22 +36,26 @@ .status-tabs { display: flex; - gap: 18px; - padding-bottom: 12px; - border-bottom: 1px solid #edf2f7; + gap: 28px; + margin-top: 14px; + border-bottom: 1px solid #dbe4ee; } .status-tabs button { position: relative; + min-height: 36px; + display: inline-flex; + align-items: center; + gap: 8px; border: 0; background: transparent; color: #64748b; font-size: 14px; - font-weight: 760; + font-weight: 750; } .status-tabs button.active { - color: #0f172a; + color: #059669; } .status-tabs button.active::after { @@ -57,30 +63,51 @@ position: absolute; left: 0; right: 0; - bottom: -13px; + bottom: -1px; height: 3px; - border-radius: 999px; + border-radius: 999px 999px 0 0; background: #10b981; } +.status-tabs button small { + min-width: 24px; + min-height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 7px; + border-radius: 999px; + background: #f1f5f9; + color: #475569; + font-size: 11px; + font-weight: 800; +} + +.status-tabs button.active small { + background: rgba(16, 185, 129, 0.12); + color: #059669; +} + .list-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; - padding: 14px 0 10px; + margin-top: 14px; } .filter-set { display: flex; - gap: 10px; - flex-wrap: wrap; align-items: center; + gap: 12px; + flex: 1 1 auto; + flex-wrap: wrap; } .list-search { position: relative; - width: 240px; + width: 280px; + max-width: 100%; } .list-search .mdi { @@ -103,23 +130,156 @@ font-size: 13px; } -.filter-btn, -.create-btn, -.row-action { - min-height: 36px; - border-radius: 8px; - font-size: 13px; - font-weight: 760; +.list-search input::placeholder { + color: #94a3b8; } -.filter-btn { +.list-search input:focus { + outline: none; + border-color: rgba(16, 185, 129, 0.6); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12); +} + +.picker-trigger, +.ghost-filter-btn, +.create-btn, +.row-action { + min-height: 38px; + border-radius: 8px; + font-size: 14px; + font-weight: 750; +} + +.picker-filter { + position: relative; +} + +.picker-trigger { + min-width: 132px; display: inline-flex; align-items: center; - gap: 6px; - padding: 0 12px; + justify-content: space-between; + gap: 9px; + padding: 0 34px 0 12px; border: 1px solid #d7e0ea; + border-radius: 8px; background: #fff; color: #334155; + white-space: nowrap; +} + +.picker-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.picker-trigger .mdi { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + color: #64748b; + pointer-events: none; +} + +.picker-trigger:hover, +.picker-filter.open .picker-trigger { + border-color: rgba(16, 185, 129, 0.34); + background: #f6fffb; + color: #0f9f78; +} + +.toolbar-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.picker-popover { + position: absolute; + top: calc(100% + 8px); + left: 0; + width: 224px; + z-index: 40; + display: grid; + gap: 14px; + padding: 16px; + border: 1px solid #d7e0ea; + border-radius: 12px; + background: #fff; + box-shadow: 0 18px 42px rgba(15, 23, 42, 0.16); +} + +.picker-popover header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.picker-popover header strong { + color: #0f172a; + font-size: 15px; +} + +.picker-popover header button { + width: 30px; + height: 30px; + display: grid; + place-items: center; + border: 0; + border-radius: 8px; + background: transparent; + color: #64748b; +} + +.picker-popover header button:hover { + background: #f1f5f9; + color: #0f172a; +} + +.picker-option-list { + display: grid; + gap: 8px; + max-height: 240px; + overflow-y: auto; +} + +.picker-option { + min-height: 36px; + display: inline-flex; + align-items: center; + padding: 0 12px; + border: 1px solid #d7e0ea; + border-radius: 8px; + background: #fff; + color: #334155; + font-size: 13px; + font-weight: 750; + text-align: left; +} + +.picker-option:hover { + border-color: rgba(16, 185, 129, 0.28); + background: #f0fdf4; + color: #047857; +} + +.picker-option.active { + border-color: #10b981; + background: #10b981; + color: #fff; +} + +.ghost-filter-btn { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 0 14px; + border: 1px solid rgba(5, 150, 105, 0.16); + background: #f8fffb; + color: #047857; } .create-btn { @@ -137,22 +297,237 @@ display: inline-flex; align-items: center; gap: 6px; - margin: 0 0 12px; + margin-top: 10px; color: #64748b; + font-size: 13px; +} + +.active-filter-strip { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.active-filter-chip { + min-height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 10px; + border-radius: 999px; + background: #eff6ff; + color: #2563eb; font-size: 12px; + font-weight: 800; } .table-wrap { + flex: 1 1 auto; min-height: 0; - overflow: auto; + overflow-x: auto; + overflow-y: auto; + margin-top: 10px; border: 1px solid #edf2f7; border-radius: 12px; } +.list-foot { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 16px; + margin-top: 24px; +} + +.page-summary { + color: #64748b; + font-size: 14px; + font-weight: 650; +} + +.pager { + display: inline-flex; + justify-content: center; + gap: 6px; + padding: 4px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #f8fafc; +} + +.pager button { + width: 32px; + height: 32px; + border: 0; + border-radius: 9px; + background: transparent; + color: #334155; + font-size: 14px; + font-weight: 800; + transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease; +} + +.pager button:hover:not(.active) { + background: #fff; + color: #059669; + box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08); +} + +.pager button.active { + background: #059669; + color: #fff; + box-shadow: 0 8px 16px rgba(5, 150, 105, 0.2); +} + +.page-nav { + color: #64748b; +} + +.page-size { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + min-width: 112px; + padding: 0 14px; + border: 1px solid #d7e0ea; + border-radius: 10px; + background: #fff; + color: #334155; + font-size: 14px; + font-weight: 750; + white-space: nowrap; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); +} + +.page-size:hover { + border-color: rgba(16, 185, 129, 0.32); + color: #0f9f78; +} + +.page-size-wrap { + position: relative; + justify-self: end; +} + +.page-size-dropdown { + position: absolute; + bottom: calc(100% + 6px); + right: 0; + z-index: 40; + display: grid; + border: 1px solid #d7e0ea; + border-radius: 10px; + background: #fff; + box-shadow: 0 12px 32px rgba(15, 23, 42, 0.14); + overflow: hidden; +} + +.page-size-dropdown button { + height: 36px; + display: grid; + place-items: center; + border: 0; + border-radius: 0; + background: transparent; + color: #334155; + font-size: 13px; + font-weight: 750; + white-space: nowrap; + padding: 0 20px; + transition: background 120ms ease, color 120ms ease; +} + +.page-size-dropdown button:hover { + background: #f0fdf4; + color: #059669; +} + +.page-size-dropdown button.active { + background: #059669; + color: #fff; +} + +.table-state { + min-height: 220px; + display: grid; + place-items: center; + gap: 10px; + padding: 28px 20px; + color: #64748b; + text-align: center; +} + +.table-state i { + font-size: 26px; + color: #059669; +} + +.table-state.error i { + color: #dc2626; +} + +.table-state.empty i { + color: #94a3b8; +} + +.table-state p { + max-width: 420px; + font-size: 13px; + line-height: 1.6; +} + +.state-action { + min-height: 36px; + padding: 0 14px; + border: 1px solid rgba(5, 150, 105, 0.22); + border-radius: 8px; + background: #ecfdf5; + color: #047857; + font-size: 13px; + font-weight: 760; +} + table { + height: 100%; width: 100%; - min-width: 1320px; + min-width: 1180px; border-collapse: collapse; + table-layout: fixed; +} + +colgroup col.col-employee { + width: 22%; +} + +colgroup col.col-employee-no { + width: 11%; +} + +colgroup col.col-department { + width: 12%; +} + +colgroup col.col-position { + width: 12%; +} + +colgroup col.col-grade { + width: 9%; +} + +colgroup col.col-role { + width: 16%; +} + +colgroup col.col-status { + width: 8%; +} + +colgroup col.col-updated { + width: 10%; } th, @@ -161,15 +536,25 @@ td { border-bottom: 1px solid #edf2f7; text-align: center; vertical-align: middle; - color: #334155; - font-size: 12px; + color: #24324a; + font-size: 14px; + line-height: 1.35; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } th { - background: #f8fafc; + position: sticky; + top: 0; + z-index: 1; + background: #f7fafc; color: #64748b; + font-size: 13px; font-weight: 800; white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } tbody tr { @@ -185,6 +570,10 @@ tbody tr.spotlight { background: linear-gradient(90deg, rgba(16, 185, 129, 0.05), rgba(59, 130, 246, 0.03)); } +tbody tr:last-child td { + border-bottom: 0; +} + .employee-cell { display: grid; grid-template-columns: 38px minmax(0, 1fr); @@ -646,10 +1035,29 @@ tbody tr.spotlight { overflow-x: auto; } + .list-foot { + grid-template-columns: 1fr; + justify-items: stretch; + } + .list-search { width: 100%; } + .picker-filter, + .picker-trigger { + width: 100%; + } + + .picker-popover { + width: min(280px, calc(100vw - 64px)); + } + + .page-size, + .pager { + justify-self: stretch; + } + .hero-stats, .form-grid, .role-grid { diff --git a/web/src/components/business/PersonalWorkbench.vue b/web/src/components/business/PersonalWorkbench.vue index 5d79194..2cf65ed 100644 --- a/web/src/components/business/PersonalWorkbench.vue +++ b/web/src/components/business/PersonalWorkbench.vue @@ -1,4 +1,4 @@ -