From c00db75c13da1bcf3f2d50257b5d2b16a992ae54 Mon Sep 17 00:00:00 2001 From: "DESKTOP-72TV0V4\\caoxiaozhu" Date: Thu, 7 May 2026 11:50:10 +0800 Subject: [PATCH] feat: add employee management, backend health check, and UI improvements --- .env.example | 2 + docs/plans/00-overview.md | 117 -- docs/plans/phase-1-project-infra/README.md | 802 --------- docs/plans/phase-2-backend-core/README.md | 834 --------- .../phase-3-agent-orchestration/README.md | 568 ------ docs/plans/phase-4-frontend-pages/README.md | 500 ----- docs/plans/phase-5-integration/README.md | 259 --- docs/plans/phase-6-testing-polish/README.md | 553 ------ .../plans/2026-04-24-ai-reimbursement-mvp.md | 1602 ----------------- document/work-log/2026-05-06.md | 121 +- document/work-log/2026-05-07.md | 67 + server/src/app/api/v1/endpoints/employees.py | 22 +- server/src/app/db/base.py | 13 +- server/src/app/main.py | 4 +- server/src/app/models/__init__.py | 12 +- server/src/app/models/employee.py | 39 +- server/src/app/models/employee_change_log.py | 21 + server/src/app/models/organization.py | 32 + server/src/app/models/role.py | 24 + server/src/app/repositories/employee.py | 84 +- server/src/app/schemas/employee.py | 98 +- server/src/app/services/employee.py | 437 ++++- server/src/app/services/employee_seed.py | 986 ++++++++++ .../x_financial_server.egg-info/SOURCES.txt | 3 + server/start.sh | 56 +- server/tests/test_employee_service.py | 62 + start.sh | 133 +- {UI => web/UI}/AI助手.png | Bin {UI => web/UI}/background.png | Bin {UI => web/UI}/background_2560x1440.png | Bin {UI => web/UI}/login.png | Bin {UI => web/UI}/main_page.png | Bin {UI => web/UI}/发起请求.png | Bin {UI => web/UI}/审批中心.png | Bin {UI => web/UI}/审批中心详情.png | Bin {UI => web/UI}/报销.png | Bin {UI => web/UI}/知识库.png | Bin {UI => web/UI}/知识问答界面.png | Bin {UI => web/UI}/首页工作台.png | Bin {UI => web/UI}/首页风险.png | Bin web/src/assets/robot-helper.png | Bin 0 -> 67980 bytes .../styles/views/backend-unavailable-view.css | 71 + .../styles/views/employee-management-view.css | 468 ++++- .../components/business/PersonalWorkbench.vue | 221 ++- web/src/components/layout/SidebarRail.vue | 173 +- web/src/components/layout/TopBar.vue | 60 + web/src/composables/useBackendHealth.js | 47 + web/src/composables/useSystemState.js | 216 ++- web/src/main.js | 3 + web/src/router/index.js | 28 +- web/src/services/api.js | 39 + web/src/services/employees.js | 24 + web/src/services/system.js | 5 + web/src/views/AppShellRouteView.vue | 16 +- web/src/views/BackendUnavailableRouteView.vue | 27 + web/src/views/EmployeeManagementView.vue | 288 ++- .../scripts/BackendUnavailableRouteView.js | 39 + .../views/scripts/EmployeeManagementView.js | 539 ++++-- web/start.sh | 7 +- 59 files changed, 3926 insertions(+), 5796 deletions(-) delete mode 100644 docs/plans/00-overview.md delete mode 100644 docs/plans/phase-1-project-infra/README.md delete mode 100644 docs/plans/phase-2-backend-core/README.md delete mode 100644 docs/plans/phase-3-agent-orchestration/README.md delete mode 100644 docs/plans/phase-4-frontend-pages/README.md delete mode 100644 docs/plans/phase-5-integration/README.md delete mode 100644 docs/plans/phase-6-testing-polish/README.md delete mode 100644 docs/superpowers/plans/2026-04-24-ai-reimbursement-mvp.md create mode 100644 document/work-log/2026-05-07.md create mode 100644 server/src/app/models/employee_change_log.py create mode 100644 server/src/app/models/organization.py create mode 100644 server/src/app/models/role.py create mode 100644 server/src/app/services/employee_seed.py create mode 100644 server/tests/test_employee_service.py rename {UI => web/UI}/AI助手.png (100%) rename {UI => web/UI}/background.png (100%) rename {UI => web/UI}/background_2560x1440.png (100%) rename {UI => web/UI}/login.png (100%) rename {UI => web/UI}/main_page.png (100%) rename {UI => web/UI}/发起请求.png (100%) rename {UI => web/UI}/审批中心.png (100%) rename {UI => web/UI}/审批中心详情.png (100%) rename {UI => web/UI}/报销.png (100%) rename {UI => web/UI}/知识库.png (100%) rename {UI => web/UI}/知识问答界面.png (100%) rename {UI => web/UI}/首页工作台.png (100%) rename {UI => web/UI}/首页风险.png (100%) create mode 100644 web/src/assets/robot-helper.png create mode 100644 web/src/assets/styles/views/backend-unavailable-view.css create mode 100644 web/src/composables/useBackendHealth.js create mode 100644 web/src/services/api.js create mode 100644 web/src/services/employees.js create mode 100644 web/src/services/system.js create mode 100644 web/src/views/BackendUnavailableRouteView.vue create mode 100644 web/src/views/scripts/BackendUnavailableRouteView.js 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 0000000000000000000000000000000000000000..5b1197ee71deba1a0fc107be157c6a9c7fca844d GIT binary patch literal 67980 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�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࢟~mI2 z*U~ywaYX`F4rYC^HkjYgcj7*SOKRi6e@FD?A6V%D)7}(ehy1hlyqS) z8h53GpX-kUkh3rq?KE;Srp7>Jt~4;dQ+D4jPTl%{?)t+C|D8|*JgkPn?jCCd5l3vE zH=i2_ReIi%VxinbZFsD=NH}M{hkU6q?!~1#ji+?9P_DbP(Bi;{=x%(AO z;A#2bcB|YUnn=*!Ff^OrVK>ryzhd%W+G0Z^ob9X$r{eHVetQ`?UAwnValffk=|B;b z;#Ye|YOC2hNp8|$GE<_<6hNC<#W_f4#SZZ(AV>(1)wZny?yOuftr%z{&fJ2%-TG>x_430 za$Ys^?%T!9;P56?1K(Ov2%?BicIfZIQfY1<#8ofK1HG0PjMWTE zxHPSe`PG*($S1>=jH2Oj&X(*z_znZd?U10j#6Q~rZzuDT?=gE2`G^=me}2RhLvsDu ztEQd(TMh?}lkGJOfl9|l-2G3Ri;LTXn{Nz5&nv{&^YlK1{O?C+mQ{1uqbo&P;PFu( zxfEW@Ni8c!J+`?rd|bU9 z0Ky%%La8%JZHZXqv+b^Dl32otpZ3jlPY6)A>ASC39~1c>k)kjs$0r5lemA&eMb(b1 zq7vrww=fmB?GDR8!SOOLz+_Ci{heIIG#0i)X98A^)_@rXL;fs{bEOkMBN<@>G)gNY z55e^V5~{N)s>|(?CWXP+5_0YhW85)de}}idwF?}3t!^Oydn11+w)Nt+b@v@kzB&@L zxP@EX`C8#v!^KV>A>o|;y#0YP>b(^^nF;%0r`~2uW6(pE!gq1@*k##U>R?iLDY0P0 zZ2~lo$un49|6AOY7#btX)Fiip$ecj|!U|T}i525>tSXDK`t~q`#zFMR0Q{gmV2mYwj%x8P2|{j6jU^k=sCyv40x~+>52`9$G1pk@ zaV~nYz&6%|+~7DEN5i7``n&ERL=P6vCboi1){mpVk{Mk<2(9}10q*ZU;NZ?RkvKd?M835iZWWFFVuK5H65_c4Y z)nR}dw}v4$?QD~_MGhML??xe1mMGP#&D4IY<_3fG1Yk7HoGdJ@IJ zD8iDkYtc*5`}*4ZfGb6+Y@Wx%xQunYE)GVKP#BahKZw}x1bXzg_kN|%Qpi~;G*vo1 ze*H9<;>67L&@gfnhT8GnbuVFMobgdWp39Rx(>I|w5~=pc zH0}ej_<0?2uU0Yg_fVIqafhBgJG)OfFsY}^%a?Du{%6iimH3E*Va=#e14X!r8yanS-0ULY2LV)YI4%%V$f8*k2DvQop7Fw zWG-wScu{6pNG4Ro?+n{*`g7q;s{wnJr0|bic$y%YvIUJRg)z-kQcg~v8~)wk&{9Z` z>kg$1vL|DfkdCi;53|H&sTupms@Ve`tUnn$`kY1`PEPDpbQammQ{ZUTnOZlTySi%QP#4$ zu&DkCi%x-~!Cb~+Y^-VA>k0Qfg?h738T;C?Obd+8PtH1<_LEbUbrsJIBmakb zz|(0}=dn0XO9RA-Qa&pobW+5Rlxkmj0-JwH1xzEbnCwK3hVoQRYYC}d9x6OqAFz1L z88V%oz}S@Oj%lwklPAg-U7b5}39~jWwXc_VG-&KBor*-qcs+AyX0OVV<|-I~;=_?w z>CQfJz4>QkY)`F{g2h2|PZNf#fsS@>VqhVY0TNX(tD3ak>_Xxrp-T+xOqsUwXeqCI zI+H<=@aK{+XNY!B=7sXuoHMvIMp|Ly61HjS16hqc$~jD$WevXcG(0Y*T%zdxHC z%f5!ErJ1e)cbqi;uHEmX1B9f3_h*wSoH=zbOtyC5`M-G{uCL#Oi)&Y5dvDj4Bv=TJ zWCGuTiUL;DdmmAMImD$)1h!twUipCMM&Yc*i$q7+I%|S(`v6wSWg$SY~K< zs?vO5q1ogE1ev}?hjo&L=*ylkVak3pDYy1E7dQ8|?^)Yf|J_TsZhrZ#D_5TFjho`j z4?YR=gL#-vCSid~-=-;f@0Z2@1wu>4D`?HD_ZlegHNUuEs7v4b{tw}uH{XPnmBUcG zWE{HshbUEeN{}Ky*ic4q%)zAKxm;mfIM71yAp}|7g*>e9YKy}4#d*CF2%u^!i#qK+G5-z;@20Zq$ z$6#e?1=f_IEzhg#&*nQ@k1j}%oKtVIqJCNw{-TIj?9GW6As5Thzb?9aYxN^1aCCGn zdz&NZ!dUWP`R_97;zJo&lMitan2eC+yNa0-Uly=ikKyHO@80+NtxNZ>O}0;O?Cn0F zjP3I~)A7e{>}-quYqwxJ8^b?(>N946)duWvyJxK0MtkrzBv(-no0W_5^v*e_8RVlm zxVf-1@WDo7iXEF>uas4R!d9EaBY1ZUU}(txbMERu(q)d_Z~YAE6T`Jnig`u zQs`;!a!Gy06YA#`b%!+pZ7f(nq2PQa;lsUrj&i1%n91_Rx-&&ZDZ*5o2s%i=)Tvn4 zX?5bRFgZvjI(EcJB{{)Bhh(=VyYQXYpa0Bv-+SSoUZJ^hTQ~JD)pk0uh9y|% zai@rQ>U}6}Hv$$q=UO*y^q;yR`ws8*;mgYZ9%SF74iN^GLjphRg)*+E@aEbFkG#3| z{@=ef-u~Er**q$HDhAivC_yQ;XsCXnfq*SBhM!-06Ye{F!jgO)0^w;-A@?KU;CxGH zHDpuiN&#dSx6Z?Kuagc3zxTr*!p_dF*EEgxDkq2a}M--n0Jormq+U9&0ZhR&vL z%=@Xc$3d?@nof5gd;R+R^3dFTb!uT%mQem$8QR32x-dlP42bS*{%sG%YLkQs1grHi z8eIl325f<3tMRin`OTF%?gC6N5||X3E~3M^OnpQzl=p93>tDWc?enV(i_aYy&!1h} zzA;~G{Gn&t-hD9#`vuga0^YiH1#awY!b)!eW-6}0e8RowyGj4vu0u-0>R_o*8Yb68 zQ|jC+_|?ACDGj$bRJp6!n@goY@N;KvOXtEJI`&oL`09pt^sBb z8v@&}BWfeN`0hp6+TMcgecLyu?}cz-tf6NXh`q8m`o+app8BBPl#d@jN1r?P2p%6E z{#7lTNt@Gn5QWz(fpsKJ*j-CLfMo@3dGW5)4!h{>ccC9Fu-5}wJW-0)H)3bJhwrXk z{@U)|-aoAC$rC+^hgD7>rd0!d-LhV~EUmG%BbX~;eY_7>w%6hC>LC}Z2uG4-F|{<4 zzwFN3#I_;}il{(Cg(JGStQYXB7cLlv{BUlhm8yX2Zg`y#l5wzIK&V3PPNd3AmNIc* zyx{Xf$P3E>Wi&~U4FkQVpO8NJPeT$@Y>qrZqlMyF7GBELM@?zI-+<>jUUD#9i^w9ib zDA1e!Kb0HK^+#9?`h!=uE0zIpTIHG5GQ{9L*g_?fzZmo{!Zeq(dv zkM<_x-`UyTKPGBYHYId%8R}Ubrc+QU4AZ?B_F+0exV*C-dP|4$BOb+q=EX1lKX3o!%kS>pc(N45kQcqkt*w?2Y-k(FX;iV(+n0!tOX?w?$eOvSu9<5g zE#gF9*1>5U`F&cqi0>A+qJ^@h8aN-)F5KB&j@o0ddS`KP1G2((}pCEl=?L&nmF(tV*65| z&Qs@HH?H4+?VTOiRz{}T_H!yHmM-)qU58Yw>(F3Mr_Wf)W^sA6@Ud4nFU`Jv`M2Z$ z=-g+kMKOG>lGPMF${EsfobLDwB;tIXx*aC5p*;vAQ+T?@kw*6R{fz zyED2j-u0cW)7Ng@_{!FF|Mac#p3sBS$Fqrv1T_e#8QQXz!AWSEl5UMCM*~pi1!Flg zE&1ke!t(Eo*o$y&`0n2>1xXMgeY_Tf+1%PP;h{zpo2GJw4A=UZ8@rSA<8SSzkLt&7q%FGkykzPH-IZf$J9rpv>0 zOG_wFdrc3r2G&=b>_k>-1FjF1!{U>_Tz~7~mu|g(UZnv?!9=t&GAJk~vJ>Q!st*p@ z+J=~k0k`177KZ6db}phKx=T4&`*zvTOB;%AP`q|)rI~`UojPOih za)67MFB^HX#%U@>AR&@;+i^moED`~GOpicUbla~d&|6uA6HlIj)rU^P{IM1FIR!NP zGuU|R3S58jJ=lEb8kCKdOKY5m3_y!(RRQvnbUw#zqEfJ9AdnZz2~DMrTL`&O3?^e* ziD|Nj@SZ7i35Nvwf$%=xuq`+gU~?|l?e#D```owXXyjIJj@#SHd#O!MH*D@rCa^HS z;KJp&Q7DU2i>y!)O9K@(Sd_(RbGCQtm+Nnzcx3t1;gxd!>ZG2r8FHf4D@K&IdmTWM zhpeS9F)ZRU#{Br;kk>&nwl_4~(p@C`^1O>Tm+Ek+nvBKT=Ef)2$2-T@##?Z6>z4X^ z4Z~4s+b*PSg0u{78`6mqP49;2moUc4ELzjSTg#{jB``qc=)*>V=}a))nxb+5ZD7~d zt|>3JX>ROfe;*R5E{}M-*GT*t(j7=G>0dLJj@~ucXd8k!fy7Rt8RhsPbY`hwl_X`W3pXd-C8$E zZcpc2YT@a4UN|&Lx`Jmk&eX&dcAF`r<4&C!gw9|{1Z2>2-n3SrGB9m` zH*VfE5b@HYh~tDLr4tde?a-G2_=q6QcsH4o&a+tYa;z4}!b8X56aU#Cz{yXYhk81L zz4fWeL1yYv7;jLHdT{jVb8zB;lgi7@!>|ABd$7=yw$qvoY~Wm4E)9f!hH)IE7LJpA zN+U^E_P{W$BI$~kzRkvUItb^n4EWQ)el-KB^Tm|`%%53?z6x(QFRVdxYo-jK2*w`H zQ+iT$5h(^zy7wg`p4!wE?CtF7mL#L^Vb#IV3Ib^ZQ&Gi0t%*6&vt8R3we9tLy>pv8 z`}e%HedXArEB78&A>=i+AxR-b2icYQ(77)Sju1|KJ-K_UuXSS#Ag>BiNsL)UgV-^CVw7R#-utuv^dG?HwVUw4cVAYE zYT!^pHg+~Dzc?Fdt8nrADJkxwR+b=kQkxo>XL*QNRJH9lRzi+cbTHl@gH&O9|JVYY zP#eLqr_aKn2k(KS$5-K%zjz+r_*Xv$v0IzS#0w9__88%YbzmS&2YMV@sf6*~o>fGy zYLm11t^gtn2`dq#)WH0Z^dI+1zrx&ht$ z^OB0l+L#HUm9OQa9C8X)k}ycf)PeCXNoVsJS*$CrbP)E=bUfIq_Qw;dbP8NzL28{z zvlvQW$8rg?Vc4s;IXsw$rQrfh#}#Zfx0Dy0x`Y@5giMJdFQ#alI}d#Z8sN|&hgRWD zm0&w2aAw4_yem4~FkMR$wOv#V!mOy_{1={t)o0Gb&c>G0fwxy*w}I$QUvpG+sHhWu z4R+uV;WPiqS7Gg?_d(vAS`MIKgxwCZS(3Cmn(+=(SvefBJz{Pcsc^4JL|mqswtdDq+=Jo&qygp1#Q1$JIpgJIuMzkqza8C!Sw zENX~rL*I-2@fa%g*^PvNHn75xnVpbNU-V&weRFOD6^@RSN2|5*^yTu#{`Qi}#}-e_ zEw_L*;G*5!QzK@x#yX-do`>@?6LA7}*Q_WuFa+q1v@{=FXa;P_6PKEypp0f`HXiI# zHB;-bCKW4aRa#A}(d-4i@I5u4aaTp}VtweDP-I~=GBVJ+Dx}a}!rX`kLpQG9#$wwF zG62MeoSG)3Z=x0pMS93IZ=9y77oh+~uO z@#?MF_A0^2p7N4S5Ex>Ki6Jz5S`%h9*~IXmLmsZ3yrq$2HnJB|3B|j{MU3M4n7CLw z#Gz3hY`-^YYL(8*+7QMiy7nn6W)nnf8tH1M2zvbJ?#x2DdI3yS9#iS!WMcyTuq$)^ znaudphKGoD+K7(#wY)V7uhAzgHHg@NJ)%}if2(Vi7%1Ra<{JGxeC`AcjvrQ`&lH9# zN!JV#dw#-(sEsQ^q95aA(Wr^~e8--=A1-|R7eEyY0W&-}9d6K4FxiJ%8FTN*0-X8u!|=dYKM6;lJOhmiXUC>HSsBw_1>>u?;KmylVgJ%P z%&u&~-Ul~rr?+9{i&iZ($glG5YWH0ed`&sV^GU&iALg2%<&qYt!@@$N8?Zc>gH!W| zVQ)4ov4V4#NveSg z=Q}R$Rt}bJU4BAHrHp2;n)GU_E8W89)ou0G)wsqz3nev1_dy%NSS`{Wbu(tlBzLtu zwc4y^vNj$}L%8TeAW04p@F)%>0w5M-u{TXWSMS!ywBCbc8n9`OESIBn1b194$F!VsGEnO$%UicQGi+~6Qn zuH{7`zg2_wYkE>!N6=7(#i&vIQza0Z zVbe3yTu^!RJ}jtwObfj2^=2dWZp)k1{*juRyqEVI z6leW1j#SbE#}G_}?smdEWMElUmbLY3g6?iLDJyDf6)vLoXh^nc(sac(He56u!bX}9 zVn>XjRz}!UZ#UJ$9`v{$u${9)T6Dn^VvBH}Qw3KHgSs8pP2IeSUO;Tj4DsQ18A*!I zCFUE34<&ao6#oy`ft6FT135`3d#0CsBkz+ zjI%|6?}dLJ(T_*J<4v$vs9ztf&-xVT_8HC^;Y?Rm&{ zwgq($9jf#85VvTB@33|eL>O?cu1)1M@I!N>4%r*F>h-mBgl?Uy%`|+ka#m@OTcoL; z$*q!boA0$Jq0nua_6b510U2NAqD)H>(>5kd#qXoR0o^HC!~^=lfHx!(<5#K#xvBKR zUR0sMP<{~ht_o54DeU8Lm+c<5qVPrF_p;F#e zUyH_jPQj5C%eW_rOzus_^J?V~Op_5AbmP43^4ViBnX3Du9t-^uy#LZ`@csYg-@xp` zIvgGxf`z%EiXe0-S%to^!nQ|yB{SE>AdQiE;+`*pldn6u4h6&B_xq~4?;^BdNhV5z z3{JOs^3HS&H^)2XJoeQ?Ln}Hruc>kOJ;evc1?c~L*p9_JwwWw%ZY2g^VhV%I@ z3CQN&ftS!@Il644y>}Soh&h6nscvj-YpC+BdsQg7pg|u4(L19Gl5EJDeSz!+MC;+q zm{_wA(SQ|#&BO|QU*tM^DAE)>LP!)@CAx6Q+ZIg2qYq~?&z1~x_igzZO)+=~wL|Y+ zz8NMm2DM&_Tt^XhIGcY7=e$7t=HPI1~mT4kH zl1uunMqj}X1PQu~ytI&M;3D|n;CS3fR*WuF7UxmF(8-P;@ zeK)R_kQonH=>d&f>HDS00)_8EbCThvfGu@1sq2`KHvje<%El>Q0m~Il>A~gQo8w(u z`!qGYl4iAfCyLvqbgEwINN7yBdc;s};`tZH90}4Aaf`Q^8c$R>CN4_eJ$m>~7#L+G z0%FW?Qgk{caDz?2Kf^967)I{9!`doIH#9Kxj^zYI6t`T&kUdj?K^>Ona2 z*u5}VT7dHK92|S@Je>UW!|>3BPr!w5JP+^v;3b%>Z9!QzMxob^vZc6zxbx8-S);&M z`rX|E6mADH#A+@3P&zofVNg9J;k=3l)TTP23UtSX$-Xt%D0&7kGas8UnH-gmc*U+Z z8$Jb-E!Zd%5j`{}*PAlQDvZLpY|-PLNWlKKez2H4#z`y6YfO7Z?|RZr;7P0AFdV~x z3M&TzTLZ++*taOwFAFuA@5!^Ss_W|6Ai#3&B+@VCbgw6HBTwVME1 zggnQfk}@**^$SnxsVU1$s5YpqKIZ`%&xl1T|L+jnOnbxUVwm=LCBiuwy680}`SlIl z&43DvWaI)tlvT)$_%4Kql+?ccQt6384txDeB^I@xZ11OySdy9^f@d`njr1CV%PC^~ zEDQnI_kKmjJY*M@5}H&rd~FD&dX63jPM!$Ns5Y21qU8Nj7$wsvqIqVw7EYZ>mkZQd zc=s=V1}C053k#=LVLF}$Wh&uN0WS(;9N|FcUVHHJpZ^eOdkUq|2#ML_fg57NI*1cS z2-9SnH@=7{hh^LN)rM25_g@|zGM{JT=U3pTzxoR}{@jc3;MbmkV^2K*%EUkmd5s=E z2~R(K4?OVh$KmyV^GjG$;bl2(%v2gm&@|*3Z)+M5I;}@N7g|RWy67CD5KK9K(`M!v z_N30EaR8P{El@-K0*bUp5A*Ss2dEcPiZLeWF=y$tx-?^n--AV5X55!9@eWX>#NVNH zEn%f=!GgV~cR#bZOKVIzwm0*Oi?};rU^2&=i!m@J;?m6vgJi61XhzgX zr6!Ti&pXmCgcg54?J{poh}C}?yrZATrI`>>@z)m1HRL{h=LY=dfBh?%-rRxSyfWr_ zl~eTuNIkz;Kh)wN9S5qXp$uYyEc}Z zO*GZ7Iw*Qn3L*JDB2Lf@*Ci^H5(Bn4!;?5@hEatJ0h7?1ct=BM_Ke{oQ5H?zEwXP~ zey}^K3J>YfaDG>XXM1YrS}Dr1XX;vHcR|rB%y$E63z5uqCc`__wJ%p2A%K<)KV>6( zk#z=>4Mp`$np2DV@XE4lca9nwUW0;j*7$IV{3Aj#B_=denfDngFSPOeTkzBW=Fi}% zfA;ra;n9-7KYtS*_}bHO<||J_u{v)$A13=V z6+T9^Hev4g5Y>;&Wy1yiL)+eHZUp^dUwLd7@*~^Fgr(L^bTL{pU;>B`v|XCT{#@NJ zd$k`?l>k415yy6#Ra9D!G>K;mUjW>%!qZw9OQZSS`Le&S z46H7c8`FeWtjQ$xh1uC1eaHz+24PQKD>LdfWLOeMNwEXu~v`L zasTS-suRTY409(K%I^a|eTN?o8btR0r1Cy`9HbrREPM8;D@Ujo+v zAV%7lfx>6CK^YH?=AquI;I)7GV|ec;ufdt$dmQfj{9|zax9_SvY)j=sMPlNeby4!u zJ$C^E9+@Q8vYw2>@NQcK2`wESRdr<$$ko+* zOzdx^%*@1x?wyC?M8!ODJes(>_T5+D(sy41=(`5b#!|a9*i!U{ z23Z4^hvZF3UJ4KSmfM+|rDN*+a0k5r!~9yO6=~>2C?WvB6nst1i5lCdrT%?*ehJ3! z?Z7Ml`(MNRfB6E?R6CDyg`si_Ssdi!iNI<#0Mc0$Sw2oIpqhD-xeE zka)+`J^EoM_SXXL1Y7F11Niv*@Lk3_$2z>Qu(dK=xG^kxQeRkIiMPmJVREC2eoGbbQK0__teY~VBvwUSG9{KX^k%~ zbgo~~ZnCQ#*G_FH&<8eJws3FS*cN&0QoF=_usfLz!nE522j(~iG`FEUG9s~t%ct3s(Rtd ztRa61c)^z_8PZ3TPVgc|2=|eLBrao;iq8 z!gwvNspdS{_GXy$qYG73;dVc6B)qj;@9Gn!Q0STn9@ro)z*$z2AbA0)nf(rfhp zLcNVAy%gNHK1LTU^$k9D&poiRyliwPEDA)~E=RY;M>`wp_zXAlDod6@Uuk+zJ+lN? z^QglpAWF`k7+~P8rn}h_n!%wbPQvoXPQt91u|6wcx+B6MYr5}E=P$j}OYqn~|1vB+ zcNV5QiQ{6WAhgUjtEWh8a&t z8){*3v{o$BFsdI${%X9)vAzmH{3b;weP1Hthwlp$w}%j|Q3=Po@TYQUFt>kve(9A% zVlYvObfqN$%x+lNnuy-<8!w!x7NHr2r%Aq=gE*~WP%Cdx44sxClPwDmU#P0K3uR!8vUKlwbI z`~9cjnScHjm_K*afIMOKc;JK5YZx{xCl42G-XXG0lj!MFe*Zo9kV)ayjlCxNZ{x|- z4BuGP1Llj;mN>|IHq!@K+nj4>x^D~2dXug6L%p8vBvb}=bhrqI`lH5&vBZUUIwn$v z94eWR6N8j=_yaLE39&+UI$V-hf`~XbG^?xLaSxWa??asSF(^wqv3&HE<-XaeX)Pj8;;RlOSSc(V67=iYnHt!j`7&}bIldo|v=_w2K$@B6m6SjOe$ zlVQi2!Vw(Zrzvtw|vKDhR4-GvSlPrsu>`ZkmK3oX)Z0Ns}6G+fn*+%%I#q! z2F%yVz?6)+*l_52r}9Sq#NAO3`te9q5yfUpRHZ0pc9J>=n=`EG){;0$GVE}H+~8o0 z(bbK@K*MK11YoAHYH4FG21zQV=8}j#E3q4NVDRlFTry@9ieqXnqL7^d?iZl5?{- z1i=XAW@g~-d+yFdApPWXLxW6fD#G|=-hyYu4fv-~(oG9V0sVI)a!Jk5vk>`IGs`fi zT7B~h9DMW~RMAk}Ba3j~-+ckrUjG1=U;7Y}wLKvVQkiMO^yx!z?Adc*9jFTg1m&CW z{O>P8_wt%hYRa_fbGWAVy&15vJ_J`1)SQ{Qtqh6M+{25d~#lx>$TD@_xGaMZ8(7=Xi z#P`Ax6ZsbbIcZ0ac~JnsT+Bz~Txd;;N|^LYwt`PFtrEtxQL)URm1Ox9smTroeYo@N zSvY(86ukYDi%_dKgw6+WKCzFNdoWcwvPz&^o~DM8FlLf9_50H_HK`AJNIk(Y1q2Jd z0m(=&edit6_+S<8`nAVl;i2PrJPw{F{J!m)kme4^b6^PU`gnQvhVc3yz6{sC`4eai zZP8*hLRnT4CgVl9Ifq)Msm5YtIpmA6)BwyfO9NP|NC8y-Ey7q(xc9jbU+d z5n8P&LFVL&)y^_r9KKnA;owStTM#^5%U7^A;xO^86I1)v4%Vk{;^#$qPmk#WewvL# z88$Xa6iVA5N(0?3t!TqEh}- zA4I!3(&=wnM5xS?e3r%YYVk5maHpIGEGh#d?LLB3srXuY1=BJH>ycF0?(Qy3 z&&mVF?<~%eH&k00C!eux*+U&yS2TO4CNAE(s)P*KgR9`DCw@(X{ z0_dR-8)NV0xI2p(&3({I;aWk6W$Ib1D2>xKq3Gf0(ZjI+zR0)h11J9a%G%PS8=c-)f+U?zGqkzX zLeVOMsR$1bG-qM9J|&vELIPX#Ss>S;%X@AX(-^#FB2POKRkU`{z-Sl!m0$j4_-Fsi z|15S^qR|V~0PtH-RmQ)MIs8r+#wqABMP+v!(qx32I^dW?Y50JQ|H=D#R20B)aObrf zu=Vyb*liaoO<$n;lOTqmGXy?Lpn@OXph-W}YzAY(ju8&&BBpGT$fky3G2m}$IwoHR zrCt_S9-At}^gA`*j~qV&`}ggGX0;}w5UvVZT!^-&P{T0xW^Wr-(Su3$gpeCZ;viZ% zvv9|adlv5a83wx>A&*k2L}l_&i*h|TC&zM=`!z1mp^2C3Sv8+j;4+PxjTzo2*Qd9B zPHbrWOQG;cv`bOadT4g>(j%wu`NO*He-AH6r;bKfb=<_U9lRET?Ik&10f#25z?prA zp@kQP`ejt!wZ$L@<*78Hn=zc+DB-J07hYtN?B4El;Lg*h;g`PjOAzdJN%veS9N9V8 zR-YHeSV`O^CyQ#;ByCqTMT2ur+{EZyS0Dz)CW>hm{i{SYiYs^yD%5vgA3=Dz1K|f- zz;En9Z3xi7{m&(o4E2yG_u&QQCoNT93z{I9y&+cUjp!ULRzlV0Eew8(8sadD@1z(>zj3oSU; zJ^**_KMp?KN6;D}@l}L@MNh&E;qsrH8>4@MAcVEGb!cLk^7sFXzYBh&C6V@MWMp8V zC^IaVA}cG$G6R;AR{?ps>S$_1=*I$D4#rFD1Fk7yF)*KBu%Q!C&Ucu^dAfLR9LvfP z{-Wfa@VsZ3Ci)OR(9Q{HW%!~wpAXnG@R>Xx&d2ldNPLXBkm|%&khCMpeNUY`1*h(~ zLxhKP9qG7ev!&CQs6H9J<;~6pbfXao2a$^0h(+<`#}A)-?aAYJy^0=fDV8FdCB?1s zbXaW?C1>T>!PwM_?_BWZbVdfQ10Of3gS~$L0ZcER7z_sYMnPC5ph8FkBh%Cb4BxgY zqM;o#i8@_0{6w;k26TS^$rlfOe&M|fH-7fbi#M-4w$|OedoKx&_xE}SpdU=lP1oVx zgLlCGnK|N=$wi;Z3rv6)abB=|Q$FtFxxy!Q(Q9pPZ^Fa(Jp_O6KmT3$AO6Wdf!4kQ zz@t#UKS8+EPvCqkku~Te%tU6J%ptQcXBMBzQ$e)Koz}@T@NZfoot@t#NsWmnEH$fX zWeqxjPI((V=vc|iJd!$H`;`t+f@U-}#OeEwVeE7KRzD+ejlX)R_{kOGv-?E3jH zS`KgvfN}{hL1C;PCGi7Wot>vfgW;K~U+Fp(=b~-f9mjF5lWpMH^p zO;`+L;4cIAPd$*fUtf9l)PsNW)E)Q!Kf^e3Lr9P8ba$Rzx_afey_h{*b*i4yY0AUQ z3^~oYo;TzoVA}4(4CFhtCQ0u+iV|2^U4^r!&%l5AkNzQi=cSk6(tDSnHZ=`t7!>1K z^+j1itutm)a+uD`Vv`kO%dGSAVxJU78uO?4By_zwqbk=cw3rD@@OS^teP8(xcQh7XjnZgTw0(KbJSgW$x*nn_qsBCP3fF{W$02ee z{IhKzBbx_SH#UB4x3l|DqgJiqbJ3_^&$8Gh*LAK_szQY`$}eq!&t#uikK^Z->3OOA z3wpJ!1oanxrBXSP#Lb$nA3B!(;oMB+!^$wJ^me-YFf2F_r%5A2hZk@aExY6VV|Lp@ zzdk3l;FJ3O0j#bp!;vG$;Gh4~{}F!m8-Ei9yIt_>4Tyu00Ia4Z=vArpqZJ|a3KTP? z-q?iDp%g}BJgqTJ4XwlvYEo!fCd@eR25*Uvm68oZ&O%y?74mO6a)phn;Ev55V49pA zK*QeK?q<$zl>nQDHU0d9I_z&89NfqycnoNWJ$+in z5yOcScmWnuA@`Pfl+@T+%fQp3d0N`NH7*rBME$qb)fE9a{_=Cr!~gLw{{{T+-}?v9 z+1P-3vk4&qkP8&6rdVuhw=rG2yhPU0rJI>iBi9+U%y?U~x5JDIW?3px3{B8HrarWS za}%w>bt;D56Br|CngCZ0CK^I&K3aLzF-OiQ?RkOCa4dNKg)hJh&wU<_?mGxI*MlgC z1TrThE~fH*aqr;)uFd!FJ$Cw~ieGsNztdZ35-;QZBLH)}FEgSdu4TH+%*izI(6Pb; zW95EDeim@8`Mu%b@#WRk=YwHzzE-WYI`?s7K9S)6StMBy@p*g)@@A`+W3P}_ z&!=Etw<^AzP<1cx0UP9w@!+Vq}jYjoRL;dFi&`tgkHz?U<;hv&Up96EVim##7QD8I;$e1Y< zl`mAwQWOsY0icJX3zfPyJp0AZ!*Bii*Wt{ulhE)h0=!L{_^B!!Y32De=IIKv?VsS{ z{%(@S--_e-Dn2&iI<&atxyObl>a-O-LSihZ%Rnjr;@Wx>Q(o|5W3G36WqtKI%DeE- zMdAzRRJ$Z)n5Ot~5}%CX=y42PKNrWzv$#f|z=u!DaXp_r2eWQzUQAVM*u>~B51*L! z&(Z+7MIIuOOx|v{-@+a7B6`Ix?iidIiL^e)s|@-qy7Y{h`H-HqWb}-|aD+y-1k0;8 z;qeDQ1Ap{~e*pjWKmM=4uT-Ijd6~dN5)0e5^Kd9v`L(kCm6hv>=shj4is$Aqs?rH6 zPvg}4P{2w9_u$r?bXGDh25@V{iC*wvx1i#TZ69Dn0hTJm+r;U^TY%$(lo*YyNS8XJ9zHiL1P$lRRrctOX^Ru zCb{NW))frX-@zx`%;SHqO*zn}Wa?DqvRI8yMpvT`l`_{@M+eL#l_Uv7X!zLL#>TUl zC!WFFzfKZyc|TJ1QIjOe-pa$rr>Qx}Zib`e7jHu$# z00umFH&<6-r@IUL78c==haQ2u&zy(9{kQ)X{K=nw6aMU*e-1aUU4s-u9mn^MIPPsWoshl5`DuISdF*9=_tDH0F2BKH~v=%~E5Mi3AO#w!RTSCHEx#X?M z3gr2gBb)p*`6R_Wwmm%sPd)PteB;-D10KHTerQrih>s1?xCVov1XJtdL1frQUnazz zf61@-m(YW~Pjfyvj8`wst5woJEXyI5^cBk$!%WgG2lUGMVi+36$JaMEp55K;J%9_0 zhWk0J82p^Gye$PXNEHgjt9W~!?_S3zMxVy`*pD$VK7lWt?Lz7TaZ`g%FO7~5vX+&% zN~L;jc6R2)m6f#vuIpAY%$TN1PKN9jRVXIKKQr>5#fp(S%pm0z)j6*BKa~C+Cn zcTX%13>9GC;y!r#$*17qhaQIC`JLZ^AOGl8_~DO!1h2n&0j^xW3WM#AtegoMGEZP7 z9YL$n>>=d^){%2#LI*eCzgjUNcZ^n%>y<|76z^Yb%dFh0Dlb!3^nWtLNp+yTRpQmz z%s{2n_@^jL#hBOm`2~3R(Z}E`zw{+|^3g|Nae7Wf62svDMz{$P+96ixk+e+Sq^p~z zn|R(Xd!F|;e$)-RBim?mbO9j1`G9Hc6lo!Wx@Duyaz9+cqzf}71w#jjp*9Cs<9xl=hW>~?^C`8c_a>B4hb z-Z7c;$;$0MIa>-}j9%==xD%e*-R;i1p4$k+$YZLxB{)S&=#pj3VVSHIrF%WhFuJYA zo+#1$YVlFtwX@R^hDNZe*_jzQck&Ln_v|_N?ce%MSl`}+x8HsT-u%f$cxnos@3XSxSlWJU18Y+GGN z{v}YgIG;j{w$dH$J+{2Q_G}b}XDgoHaPZ_1+g^vl37L0G=xp*NNkja;S8KJ}TfXnV ziO<DuX`I7#q4V5mqr z7$~~E%Ej~r1z@t4jY=ddHio=k#L|2Sg&2Q}AyB8&5xa6_(}PyC4J|adPa8@^Mv`QM}-~0E|G_t1q3h+@loK{vz%o_IR`1 zf`#d6nBTVmCyt+hGpEnMo#)QMspH3Cadr-BwTkpugTBz)2*N<-C|njla)sTj;wkeO z`Y*)4K5F>>kE@l+EBGC6QvRa*a;A->lJmROKzm-shfe5cF>M-Xre&xXNbJJy@7b>f3YhD2C8H}3u;BnQ-Sqn^yh z)P@T=05BNOIwLuwzDTtuCZIjBC^bj0)7eF1^M$rVtx^;0+(Y~K3ADoFpLtjeZ>JDx zh&L)tZ*FbC#`-$!>~^8Iw+B00JFvd74qMwhVkrBdKY#)5{6oxF2Ej;hr$T()C=7*M z8;PdI$_NDhP?H1C%J#DPU7S^6teJtT>L%%6cqI6Vp)5();qEJ>#7RbtsQgpyDa@x@ zViVbqIp~qYhhYDKeK6CS!cCN530o=~+}_Ua_Cp)n zTaV mI3w;Pqw%dlG^|=&`9s%1H}GwlhTD=y>jRJoY!x%U#4{4|Ruf3yq}PQAK2C zTAJYoOY@t!c650(3B`CED5jbO%|`1*jOnfQwKbX`_t}Ic`;sJfxo~4vuBVvErZJ6h znN46Udop7Zq}~A5cS$7}lg;-4S3s!05)yEOM1(`OvnzHw*K@$bUEiZcNW%^A)~Nb0 z)vUw*#d)yL-;+;tB)vzHbBlzai=3H7a&FZ4Cc`8HqsrSz85_|(2mv7w+;JoTv_kq# zGdix1@3lx~+?E7Y>f;g3$MuDQ)T&i60HcC8T;$NGH=$9hN+D2MP>!4vK*w*Za}d)Q zSMpGU5p;KV#W4z_k7JczrTJzwKB}7+tZ$iy?RXXc?M9>VS9q>o#z%v4L86RlEh8{Y zq`yX<)Rdsg0A~~ZLgAk64}##Sm5q((N5j#bp5r#<+h-(ETSy*=jmfjK4FbO^U#nJY zm;8$V7XC@>>eR^b*m!~EQ!_ADBCzoZdf#Q%X#prRb)uN22T{B}4QAWz@1#j=ZEkK^ zxB#Ax02cAXToxycTd5i>t^s~6W)SL_R$VM_HcUgw=ha6`_p6x@Oe#YxdXN@7fB=1> z56Y*uWPCM|e>t{L!X=3{l8Tl&8U9W=k*GpegdvE2pwJ$)CIlPu`y^Q?r<$i((>3KK z1zLT-*YYePDq0p4^4TgENtC=A87`?mjW8r8&qT&gVKU`mu~L8a&)@nB(ADby;~+qWa+ZrGb!- zt!}M-Kms z9qU)D+zbdih7mV;9x$%N3KRQ1oXYH@*{SKjq#ni2);499PeH;K#f%9me2}!Bm}Oj4 z4CR6?6vyfWg=vM>LkFUuyJMULfTQHLcV@i)2_)uD+*^0!a-no5@d;hR;nzzY1lw z3m*Bdf0_E47>RrjzvFdu2PuQnBv-9?k%jFRXd{j^IfFDC*j+-hWO&bXj36r71r>bh zqwAZSzlO&3T*Y$_<7VQluWho$#W|R6w#-|LWk$&Y)z?JuV^tfH`ro@CV4hw0Ss(rHl?#f zF)43#C6P;AG0Rf#X{jc!j;+k+nrUR$9Fz<&;oKMBw?NP+=o6^ zvY54*T3Y2;hSE^M&FYbjot>|(ZESoV5B7MYR(G4V1|ydH*3Pa#`p?hKDp8)C^-V43 z(8t^h4eXL_vCFu5glqy2i2VY3UH|_)7?^Wzb>jeLZ1!V2ZXO2cM#jqoMV(S0hI6Bl z&3)UkY`(s=h1WQEigV{ck|wSyPvnIFF~QZbw1o}1CONb8p=<{B+9Xa+uxu!$5w6^I9smjTDHIKmC_dV8w<;>^s{A3Lt|3O*+&&W}l> zVLD<%6Bx>grrJ5n(3zMjRKh4aFc=OFVovMgXWqaNZV9jXK*HMuyvN7;9^2`4e|dRz z<#`NyPgKxiXjRl}HPMElO6d0XHf(L}Kv)f6Zgxg$hv0(4*JAEf{mAv)_t99F7%Lp4 zDNj<&A^6nkD&?^|Y)oZj5=pa2Oc@)?bPe%-od6^zhcGsp#&8iYV7jrfnd}YvPr0^p zm^=87$4qGHC^2ITauL#e zM@sdHAK3<@mR>4*H+k`Z6@s9aHtUL)AwZg}cOfM1};CwV|)+8LbT=dsOx*@&;XxXURil27zHP5zVF)17Ug*H zKKKRar>9_UW*YW(`_S3lf!%Hw+RYY?TjG_9zwUYNH9P~$GyrCjKsI9&>3*LkT?Gs> z-Z%!TkTc_@#l&4DVluIB-IT9}$@L4R*gh|`i6^nE|V>k)D_Aw1Q4Z29JR8+8!{9iQ1nIK?NU zTlp>ME0cBtlitEgl_Sl&a0u%4+DiwW{X;wv7+!BaobbUZhr4t5a1*yxoAT*2O_@#$ z(GmPy2gR1KZ92#_A05QXWIYqglqcGXote#~Hk#_Zgm@Ok0?@`2m1MPnYn3r>>ThV{ zMY3;_7cq*;gfMo zC-J`H`1JSi{f97yJz}3qr+K1*4WdveLXpZ5pRhP?g%f9HoqP=Tq7y20rm!w-EMoDL z1)l4?wr_5JtyZaCTHo4ubbF_BFFtw<-**O2P#t$z3aA<8Ws!Wh2v6ff+B$XitR1Yt=JzVj?dm`ka~ck*uGPSJEt_up)~_ zT1d>t5%XzQA}z!HQ?oQ>s8kh+QjCkagPv-s(QI5m&-Gng^sn2@x*qd1D696lPRFWS}GYk4(oP0$2K>&9>Bb-g)eZTII%ENsl-Wg)|H9w9r%5x@piOPtyLE1 z=I#DqNSrQwy{BdyC~r)mt;Nxc$(9B6GHV1Rs8$K)X5+azh2Ixby=c{OoMk+WH;J=m zL~opbY(n&j3``)a@xXmf0-3G?;PP>t7?egK=Q}JbC>G;f7L&NdjbHPNg{NqQJLrc~ z?bh{Lt@_hut9AeO_SPevZtvbO2##V*FpW>Es+|r`G?#5M%@e7aiBkQF$w744-&R(r zP$OrJ?maUnMQUi&Vo)8V271JhYb`ggw5R1EpO&Z^px6{taYCoUr{)lAy@8&Gr!1EQ z&#RA?4EwT~%Q~@C@Zz)K9156$fmV#?br%=>I(n_ktycTObi4Jvs_(ywp>2?IDYM2( z6@hXjl^RL7R(ZCVQVUDVxvtwE3{GR-b$|qF1Pqr(1t&2>1FN7Boliv^NpF=>yPX|a zScGnW4?3M4d|OZDxm+4n7>Rx#<-3G#p(&g+s9m&4EJAnKRW#mnXkc40#H;B2Ht}#T ztI5)4-gtYUQcNVwLmD=R8Dj-^2omp4i*ju#vE}1rKSP3+mwFqM%gyCgnR1~mFv1x8 z67E=6+Rf%qI=j0MZ0&44w%ggg7q=jhpW!i72vnv9&M^tnW13MEBVwhk$VRZO1ZphR zD`5siz2u=dE6KW9#t`+f@+zBvgW=r#nN2(?^UCdABF zaW-NCy`qI)W-gB7)96j=2}^z2ED2d6AZ=m9A{!qO{Y0iJfgn&S`{w4L+uao)?rPPC zG>%{xj0B>l;#Z*AYM|GW*-5#*7wm4`q z1`SQaWD>k=ieawTZj>Bz2o;pET(>w#J|WCymMJ|c8>Ij7{9qIuOp>^XXTO54@@e&_vc}Fbq8(&RYf6MuOMr>#SpuzL6(w~`c9P{@ zW|fn8Ff<5_ug1#NIwzBWi?}GS;28?hd!#&@fvsnVnkrVIp#ft>qRgWe zC2x}og+|RrK{$m`$sq^hH#BIQf(pWl5}Zt+WnA10LzXkT9RYh{&}=jWL~LVoQ_xTZ z+zaoYYPQh0ngX9G(giBqShB7FJXL!8%tpICfX8+ZhLr26m8|1)FW~!+%rRHD1uDh! zyg;<=sq7KUMA%njhjLY>l3t-+nKL#sW=e4x{Wewznv}ce8OvDa2??5pHNqJDoqDDE z!Tj{>h2GxYeOsMvGPXN={k_A%D4**GAwYX8GE6)>{L}1ZPa+1&~Q&T-;$X=kXQ&IwA)~M=N z-mUu852xEx-|sK>k9B&x=eBn`_h1-#X0P8rgcoiKceM(akqt%!xLq>I4-%7m(5hG> z!W8k&B1RW*CS_o{wg}7E$i-+S0ib1;s$g2cEbXgVW>0afJ#?$`D+6tmk5!b7EnP}n zp>4B1oK`sCjAts-xr%@sc*6Vm(jmU@0C(=)daZt=Rd2qFVc?tfTJ;^*bw0vPClpG7 zNtzc|%9qV5HCZ49Z3sCtbX9q&oNSrwLbdlX0z%JV-qZlqc_FE|jCDY?8J215kyJaE z^PuTV)o9;nHJU%#KeumRZ!|cvx7Rz<>2>eybh~$Sd%XjLet&*A8cjuU z22b$;N~pydx0nT@v6xk9lBM~tb&d|zA#lT8rTvfv3 zdYYCNrSuFaYsugZ(oDFSsdkj?JW9oQFljt^JW@jf1V9=OKEgkHT!_x_5gy#0ZMmDi zUtO7ZLPvxiPB9l~A}SgloomX$_JScStt_Jl8^XfuERf`(0661wT*t{@ zLn%SZWxrKW1TEP?B~UjSDE)EBaorPm%Fp2@@Btp)hrA#o-X^f`7_P3!|3lnKI2jqn z#$s}zUZ&OhL2MFDwKVR6X_|K?ia&*7M5w9+s)i}on%#;*kt$W$1Vfzbv(4!n)6J<@ z_btY==*4F70_^MU^-kgK?ryJpy4UX?-r4Og>_A7ss8GyRM)5uEQPMji~wJ zFh*ohIo3C8u8S~(vOCP=)Wj1)<68x}Q`S*6;ANg4=~KRo$#5bfu(BRf1}5efY4-K; zfhvAQhp0S)ET~ZUT*1Qe@d#hIC3jwHh(_KQn(MB!?YTbXSIg+_KdM%$OI6p~b{)5i zpRj>14@nEkn6#^Q?!0ASK>GQ3JxVXXmRxnw+8Dq5bRUHiXb|&35Z!_I*75l3^sR{2 zL+8sQb^;n2q5sn$6<*14LtJx?818XnbqPMadPPjx^eet-B#XfrV*WQl6B4m;N?MWB zceG_l3b@%q62`PCGBivdFZE$7P42)8c@oe64Se?SlV@NbQ$?Iu-P2BLL>D;<(a0Pz z4i-|bNo;ZBq;kE_10_v`Ong#8%y?g!P*-MYqqd4^b^>_XZet7W$Yt~zikGo$5x3rq$ zLrrk6B(bsLdZMRdyDszOO;o5{b-yPxJZvs7bNEvl8mR7@a91=l#g6 zS;p^z2IX`dDn-+PS~=2J?B$G`g`8Zh$Fe3$7!-MOL2@mmx%JMT*`CRZUpgXKP9kPe z9M9l2*^h_h;pwuoprO0hgS4B9LIvekg4;$>M##ic>vVf?>GB66S6f`%Cp;8!>m2k- z)W1#1gQ;@AjPPv{PLd1+85mt7!KJHI(et@@O6`Mq4ExaFkK-YHggffbxY0HI)Cre5 z)TPiSEO;O?OdCWpro{!RBA^sZAu(obfMco?#+VE?L9YR{tyCBH>TXS0&Tz2}9=>8fKGYgO>}~z!zx7_b{9dKnXeLP<`xahBv;giB z#?8SS{-i1@UTBUNn$ds*VNkh|IdY(&D=~4npqZ_(hvFx-26%~w!V3&zl8Jjb2kb3E*Ezy z>4lnSu^H2Netax?F^7o*@QIB*t27O;%*QM7y#(%&DS;!&DvOfHMI)nm1$L$|Mo|$l z%ZV|*+uap2scl<^@w8ruiN3wNgGRUqGt*O|*pW(D3Iu3GwjGK*&P~zN#qxU&@f`Mc zd*WO-n+;KAtkr7bd6uMRdgvjmm_jr$2R&gq&Q-i~$Zt!L!;ow?D_TasQ>j0p2N3sB zg)tjpV-n~zlsHUMdgfXWmF4JaR+3ESd;(OWgh9*-FCqy>|Bs6v*PG>Hdb|(>;SmfC z?p)g0{LCBg|McFK&gQYITN;!o@Y`s(H?2Y2C(!?cu>%Su26wriKPaUW+P()Ho)V_$ZD9 zFr1_pgqWe{Wl5e?C5}^}xe=m~QJ6^;%8F+}ZySxV+Z8aW*_ml*x7)&V36CiAYWgyv z>)0ehV&Ur#;O8u&!C(KRUd;J82bK+>=4C02(q@*53S%IQjlwHqA_zlx!KvecXea^f%UDev%CG?p=%o}trsu6 z>aO%Q+0;}6rsrq9?XaJ+hM2D8xzchZh&&hDmq-M0tiq7EJbOw^CDUl+mOf-pl11g5 zGR{QaoZx$=X{N`cKol8dQD$D40|=lYX?0~_m*WY6|e}9QpY}Y?__^^F=Zoj=S zKcBXm4enRGJ2A$2058A~s3{Ec)e0(Bv=f+SNafy|-i$6l@u@aqp0?w$Q+PY}Viqsua+;h6qBmySbxaKLu#yEl|2J-1 zh{zSnAm4FdTw+VV8iXk$|^t$KP*4H0hUSECk#`4O2`1_Hq?VWmmG+?BS5vC!`)fyO{gs_8Q!$!Z)B23tL z&B?ma<}e3k2y!&7>ZVaLZBs^N6{BkD0cmt%N8rpT%?gbZ__6ZADLZf&sC=zjs4YX^$q;sD+w7I z?gN&RO33yJK32KaRGaiZma>La&v}(dqu4aE2q)!+lsPZACen;33i)-EfazRUpTa@{ za0?HXGscyQGkGx##Kll`Ul>jR7J4Ug&(!bg_WEL0H-(CI{5z(?g_2ZQTU*1&yD&dH z2Q%oo9LLEu`{k6>j2S_-Dqg{$1ycroK#Eaw%nMT&H>u?IG@5m)31<^$b}|?tDT$ilYD^O+NC5JHtcXpS9J3AWB$(7Z$$8Rn#J-4*9@|o?<_Mz== z*CWUcRhQamR1FLbNcd&C)rJGJ^RT+J1smZWtOq^V3qqFK3AZX3Rtf}xtMunHkcoo$ zFfNDESz-l?V;6!n;y&q_#8@h-_p7kXmWoj1u9(pHuoQdRwsKm89Bd#|jv3`l0_sE_ zFXj+=30|xQ1ib)O*&f?k`I)uaU2m;#tyjCd=ckS=9GqX6nmIi)HT~xN%-k!a^M^)u z4c}&zO%nwJhNY2SClY7N&0~cs;U*o38G^HHY|6{TX0299CW05;IFw+kN^ZeYVm}UP zu%)(=kdykEwMr~TzxMX}u)4kh2NxHihF*^VzH6JCu(`1%;8e3Svm%tI#xu9m9Q7e0 zSh;D?kyr6Mp5mhUg9gvjRs1gV_}CJBf-v#p2k>d}9n**=V<8@xgu5__#0e))W@Wgy zTx13eT84nKXdlKcra8(IDqAasWS&8AfSBcXc6T4WzI5|fmX}sOx82z})$8rmDVz}F zYB4p8HSG<$XfAi4>DS>vdlvf92sVQ~SRHJ`dViPirlbNDF+#8aRqT96$bK;;pQf&{ zI!;wYairptocc~(HkRWb1xmzGeKYErIcm{(5*3pwy}no}&k8g+dag7^^N1+vK$CVb zl%kLm?Uv>uv%Q#Y^!gYGdv3jfQB9i6ZFP2Q-QHk7?&_xw%rBmqnVx>pvYppR#Vhy8 zRwf>76$K_X-i}Fi?f6{m6Y>FnIgH54-qz9-qqtEy7PHnA-9~TV(XZ%LV!VwYYxXHL zjYQf=p&`*Ow0v1?>FpZAz zGKA-g-;MB68yuc}l%)^`Y6^w>CCk7!HTWXe>>(|58{$l_bKmWtazj zVhUB$rtv}?!9^b?G3y3>wiR|^v)_fCs1KcB2zwak4@ij9iXp-%lDndb1C}cVEr~AS zxKep4TO4BDS}a!sCeo7Y+1wS9+zzK64{1eM(h~9gBN$+a9pFu1>d^iUu|n2TO}l9M3M!&v|J0l6}ne zS}l~Yj9`)?OOKDk*fcf(he?vWr5|c@rcgRnWK;p#braX~`i$ZO^>98}Qwavqa6byo z#d)O>wFKECK(FV6ABB>EO*E@fAO?Pr!Br|A%+1bp#{9jH>w|rKi;Yie!j(ceVPyEfDnY9(rl~g-W>$M#7pFc>_Di=V+Op#a6OxLVAGD zU|4&^JwQqX7?q{C=!eh^2Cy^QgRQ|XbVB?cZyoe-d+A6(#|R7^;C3IT(o2aEK+F+& znd3QPsJ89dqT=OY=;_+7$Q2s38q~cC0VZj0XI(Vn4K(Utn20bY4A7V{=Lq9~@h}!c zDrmErsx@IB<`d`l9fhaQJ_v`K^PD9N9jpX;*_Q3$_nN`4)!N$Gu2I%LKRbhu+b?5? z6e;D!G@mKPK1KwB7wwO90z#W}Q|BnxDPiLhGK^V8>&B940TooMuE!g-`rfv)#g%M; zx;9qMbdZgA`93r&AQ6Ms6G1_#;KoNDiZ)UbVoj8QD-CEz=dKidaVHYXM`C^Vr@Nxsffs3=>UaOKKl_f^a%d;hfk@M5PojWj~P*zjK+dqDRvUkZe{_#qZ?uZZws7fKOCWP_3^nO z__Xk1JPwXV=`^9$_MkcC^92lP57lPb$@wF&A4A&;J|l_}a>L^I<)94OjVwX%a2Pv2 z7z{tx^Y@%aqc-a~-c>xqD|kNEiSL_&1OijsXN<1U8u>ah!(z;sfehu=XwiDk<dI7onu3Lia?`Vgv@#aX5O@&8p|!L}R^#Z@iX6rao1uRLLFvfA^H;j9oG^ z(!|Ey%47(aCXA@vizG+=-k^W5-|rvmbi3#F_WF12_4m%vX>K+e@9*EY@0#a&?Be^E z&UCxod*-KG$AT!RB}q)Hj8Vf^2y@aD4JR#TKW8z%GB7(cFkw{G9Cic{m~tr*|68`u zA`sX&8X{P4QGtSs6bmoDXlA2J4R|C%yM7#Cm^TuIo>3Z#A$Igwh!>R30m+&sl23l3-FoAlgE?%96_|8&0kALmE0o zTSmjK$zHRiq3)G7gd&#kY{DZebN~dN!paR^D)r{Ocb0>SaY>xhu1nOcm}nOG+-j|= z>hD~(6&TMoDJ-T$PvyKKbmr_BzK4Hhb!l{Cb2*%Cwg*kC(rwlooAqk#X1!j2)35sP zpxe8Q#}g?+MGD%cl;yyzWGn?pz+`CWWh_n16O^CCb8PL62FE|Ve(lScJAdi=%FVM2 zv(w&znK_bxke``K5g;o+hBQa$$dR?OSxACdq%_2L5!bk6gInrNg|}C5FmBQ z{qx?i%MvTGuJzX7oz8WLdCZjdr9=fYmT~sJX+f3X zqGez^oW!90F|M{=RM2X@x!@?Z3epOmj-v~6^XW_Py}_4vH%8~?4&o^fN5lTGI~WY^ zt5mD+R;!h_E0xNJ7$)6BFTRTJBT)8b)O==84915E-ZJG-bKEFm=m!J;^7U)aUb}Ja zOQSG2|FdhC+c!3RnEK4)gNR-iu^ z!shmtcrNMp3so~UC@qh!|Md*PlCZNPj#f4Pa?e(5s-`sd%dwctlet&R0PLnn+ zJda{Co=w8ZIs}ds9jjP1eiWmc*H=DZJGfiDwsaZpJah~eDpQg$$%-OIHsIT~t^9uB zwV2dZ%1x?5s>I5Wq?@Uzh@o zIm#S?U9Pn<#!I=e+3W>l-c~NQqejVLh-5EL&)RpLJ;VR_mA`bV6|Zr6y29NwUW}q> z0T=C&C==CJ$Z25^Von^S`M1xA?qu#sl!&9Dc7>ym zLwezjS{=M9na@;J-0k+nkc+C1;dY~rYp)_Ikk-r$Rgoz$j_@KZp(lR>bGaYkv3{c7 z*Dnl}nnc(dXD?6A)7T`%7xe^+hiNCn9HG+b?tWo?W8>F*z5b(N812J_fpMPTc8k(B zV@~dUvQ9 zeM%UlEpxKWljWmU)lUP=YOt9hR{$3Ovze-1aSLBDi=oru0Uw@@ zg6KSYv-heM{~~&{cX4;Pj>|U4b`D-5iQXbV&_zY5NL0CUALXj)OEm@-)~eVIlcESlzp zo;~Q*H4`?a=@u4`7t>(O0qiBSG*zp3Q794v4sE7kAv6A*nnr>fTP?1@7w&%q{^_^> z5H4l2_(7BD$xN?1Y>C3AttG|uc`4PM>E&Ncs-Dt7J`FUvfn(+4W`|#KAFTz*O zKPCvh1izC?*>YlOB0O6nLVL=sYz0pPt7sosQs+b51VQ9+g-Nqt^&fhWSH^JmX;I}kTb9=)br8?Z}Ki}UQ{N^YaJsF1KVjLxo zsC>D0p73XVB}-Sd;8G&mk`hNSQ=bMVDCP<54EDs%M^ba3)O)QwJS=i9#Y8rYEH^f7 z0!pUaq00ual5_ww42_u*(hSUD2onq@S33e7=E+Ki$xL-POH?6^Dy$3>xj6}k=YtYt zQ`dGN8V=yzeJA1I{62Vl>m#^x?kKeVhE#~e@YRyKwRjKWG-?{4mWTExzKDjzlV=sC6OPCv0KBuJ{1Gg=MMohknmjZ%CleW5OhR+8pmi{ z!{;&A`Z^ld6TvWC#6{o;BB3$_$5stn+s>gg`u9zQE*ma>3@w_q8aTcz$M@5bz!~b@ zm_kFtD&4ely&e+gUQ^TJ(8m>NUW{q)FSYTDLdaMgm&@Fl(_f5mT4N5#fiafGb1Mxa zuuN(ZvzdVz%tAwpTaZC(2}Mo|wZUs}-_djMuPN0>h_va2Dhdb5|L1%3MAzJ!heQ*K4j{}b$z6<;q!^9+%C3#!%?i5mnYg|KM zS)UF>|5KjBtjHzvf8x3pn*E}+xotbnJI!X}CC~SNlu&4>wW)5KbhhANjjfF_d3?ml_~d9f+K+F5_9S&J993==!_sUg7n89{UsB3X5T4M6 zR;>T`$=}VUj9T zsErlyIbI6MGbD;^2hsJ(k^iEwfg9)5R;&4=TBG>}Pt&m2^tcAZy&Z(hF?Q;>B!e!t z$vK~_G@v2v3C=R?_x)ji^!4Fj_*5_$9l%|ml%ANaCPh+QZHZn~(J+-@UfalFsEnR%I} zcW8>>*kx_fBFu!6VP-~_PfO7yFifetvM7z2Iu}w;mgpD7aI7^C2WJ*wb!QFMh8@`N zPh&=6=L6`4vXuIMxU*CA?xXSTXH*D@VJ3+qFOFlUTB*E**KY(+W6;yW4#V&*cKKPnePt5)d*=_4$GX9y)XmuAaOJfAaQA;4M@j!hGpFAHFHB<&%fc zLsd*VO%!Xs7@#Q588H$BDqcnviD>^YeTa>Goh@1g)B_=g@ReGl{t6n{%XpU@Rpy#qgZ&`XS$5EqM6*b(> zO5;|Bme=IW(156li2r;%`7VKMJz}mp;IoQCtJ?}>tI*giqsX(Ok*h*aqfk)}-Sqqm ziXNCg!q#{k*ewi<6>@d}hJhIckVytsniM%H^!`O;A3y;)Nh}5}3|e*-?%H<@u637S zE!aV?a8xY#RMWf9<~1wKC=-&B98WU93ECLrAYy|!wJWvCU!yl0iCP4GhT!VJksB+^ z&uwgOJ&x|;AjN$72sACH`;dr_so2 z7+DfVG>=Bq_Yz7cOhyN3)gxN%6uKmucj#xEZX11}tgFJKJ*+rtrhE?}hK%Cf?PW83+_ZYMJc zRp}G|Y&{2(Tq+86DwZoh8;x=rL&av=P}LmG(1 zh6IyyDgCc}F=>?gQt5rXmt;!5u@d=Bcx!UAmQ?JeW6sL+OlKsN2cJ=okFqg3Kj;kZ zo<9aJUAX{jy&c$_8A82M5k^*&_ql3C$zgs<>5BBUe0=aYUPYdysf!{1EL!MWRp0v% z*FZ@0p{1S8N0(Mto{WR|up?tZ#e(Wo-7Y*S@OYg$W z!UDt|gFn6aD)a{dJagh+n6`XOH6m$P6#6G-*e+7hlwfR@Xy|C;C@`grBitjdH=6bD zPESw$d)sw>h{r!-U=%!!KfgX04j;z_ z(G+2!=3#8t$*X)aPqK4dT^T><*z&o=^P-a(LJUsThv`-eYDraqbhG}IkV?+-E)Ah= z+%%?}IqH>}M#fl<7N0cOPA|0=3yI~04bbzfvN;x8yEaj!RBbdugW|PWZ;s@@McBx+ z@+If07$ON?SsM?tBjD&YPPO;Jf%-h`^!H$6u!~-;4pmh}HHPzLB_k$7I&=h--uP%U z$6W$xf;BT33{K+;{%NgN`*3@(cLqblFZTz-yIhMka(c235YFZ6s4p7AD71vm{L%I- z{O(h~0rvO)7=DaK)}Eb#q*;S+|LhIe?swtyC+>p-wP``Xq_zX7{-qp@Xp71+sEVp3 z@oU2W;{Q?3b)(U0d}nTc?vGs8`F@lnqfAzTm#zxGomE6r2$YQ>IXEBpc8raaS)x(M zFxNVZ#`R0MLp_bj@tiF@m*%}x)0a+8h82_{p(#`;S<8;7BH2nMmXu$6cyYXH1?p}^ zl;^#y&%tc1pkb9NfTgfc8)TvQlI9pj1{3CsLTNNE*Vxad`iSxWY&^%!LvB5cG&D&} z9-XeXP>i1iIWH_5j$SHwa7}}e3fM$eob_68N9!P5+;|%{_qJgX^RP4_WV&qDas_4v zC}i`*=f`5hW0;#&QtlpgZI>z}`v?8>R1E1y*H@PA-0ke%hi83GAO&<4+HCYwY8I^q z;Xtk}GP0>T_y?c=EtvYjH{ssL3OGMKfZYhmN(bov#0NcyXFr=%c@9~Up3Go zAuwI43RZbBC?O^8*z>&`Q`0lwnVy;YBaD__j(HMfRmW73d#$``TL98;i>j2a7*y_P z8af*vQN1Krv&<5CW(c{5L(?KfRyvSj z7{cn_F2^@baQC!*d~a$dLaAEFH0?gaV}WkOFxJy5s)XYqps5|A6RU15k)Y{$8e~Mq zH3oyyr4Wiv7BdFHjfIS*f|KZBDczGy&d$^pDWFUA9)4=Wso4YY>N^);d(eeJb0j<- z6+vht%buU|M24XGXeimhchZ>Qb@mfCIe;7GJYI^i=H@3Fs@NMQCYG8>=p@+$) zR(-~PA+$w*G?LLvrw0odk^KGVz5z$@`2NFNFG2@~(3oz(O0Nfhbn#_)d;TCic<3~o zoY@c4m4?tlq|TO2r;s)Wp&KOjh`3U%F3r!)z0_*A{v96o_erlLk2E+K^Re}7%Q?S2 zgiF1=%vpv8C{_fUSgF!7G$J4lM_v?$&kje!F9tz$nn1gGvp2gjOAF^R@5Sc{OZoeC}#2cFdZ$_*;K`2Zrp7K;!pNaYQ&1GwLT?)9qi4A$ zsW1kqfHB@1=u~8e$b7O}eZZGaUapZwFr>3UAg}dc^mwCl%i+jJGZCj%O$Zk+a0o}(o2VzlJL=xh*#F9czDKiWk@6g4bI z2)>9sN^9^6@U507^*ZM%pBTb^v^Luotcmcdqez*co*>n`&1N;svD9 zMVfwr_KRNa)59$==LvH)bfT~=O&}&sbBeicL`+A$sBn*I(?moFq6#?F$03bB63HCI zxM3urcr6uesaXK2|j-2 z+V&Q7=l4W!(ZTae5e9iOsRUMpidg}L5e=WoT$$wE@jA0%6fm04B{t8Z#~_*MU=_dx zRvHkbsaugmTR#}!-4%d6E-KIu9XCEA8-*E-Nz ztwP(Y0ZHzSFf-r5$R_P3{5Kx>{MA~$b^+i2O@evZ+SuU9rFdpA0B+9^biAY=sYoiWq4R>)t4Mml1%5TDAwFL*8bFhes z{9KT3 zC@R^KLq$fJa&zboNVO{32ofg7OXZs-`RFpu$$)_66v8P!+38hN0!A`i{AtE=z9;U%YU|efK1NqW~QS@AH97HdSMx$Sf=0BFJ$SHi_9)NxZo}*xAHI@nhUT|DYQC z|J_urc_0xUh&8xkS#iBU!N&#$8@-P4p%>hcfG#SEg|{n;JSA~d^^pQ}iow&(y>)nN z^$NVcb{UAn){O>YdZ?(RDn64n+yrL_1eFPQhX#Fl_yOz2$-rvfJt90&2XjE|bFdyu8_h8T;z|8bC{OUcQ zh0oq`Km2U%I=r)b6|QtvU_0sxZF>li9NdhC4qg|m-rx4r647>>4G&kr-UfdQ^& z+1%|S+_sDj^>&o$IlDFWWCFlAvv|z8oMwXo-1tZ(eyF-rczhLwsGcUWFRw7tLxF z7lSAGNsbC#NNR3`VNq{5fD7AK;DhaDIM+T1j~qA)cg!Bf5YpF>25gM;%H_a4N#4wi z46+HApdtWuOC+;*p*1bQ(qv#kHI`n|lhwUMkWsB70>~AMCBQh7L34>F!iom4(0$~^ zXx?myA=4Z0Mp>NaRbERgq6woxFGh2}=(=uhZGqd_g{kQlJaytOcoJ{DZ~$FW`AuT+ zIsy<+RT>;O`)IG%O&!M_S&{)|fUe7f?Zd$5(Ap*C?ByGWF#g1NHl%UOx=9)X>rPew(2$$Lw)2GtYV#M8l zJ08L}FTDc)=ELv9CiG#Z)q*L^c}PvFhIwesrku;ga0b7x!rFo3i|S$0Y{M|Yu&399 zH@hDRzSL90yWqhCXQ1KM<+RN_yVLSzEEI z&Jr7^dZo$)@V|I>)K9K&EcfgrwJRZSA8O7`lSDu)yCEzKhb&7Ek>rkKm@-Xblyd%2Z;nsE1afnTSgixX)sbm#?l!F#=kOCpqOOJj4-^Qo>*1^ zk7t_VGgUT8P}CK$I$7O}2??Ug`&mS!s5cY7(oTnA_&9p6Q^ZQnc~&|1iWyXqjGqGvh|8d8 z2xlHn3QDTa5r!ec2TIvDwg$WXweHr29rOP1OMl{eB~YttA$Fe{TTayYUXRyawEN=$r`46rnjEg072$76UaV@5Dl@GEAWD z*WlpPJQ{W)sPz(JM){Ymy@%O{qiYwdP0`6f)p|o;kAGN!VqwPgm8>1Z400;7m=PE- zZDs-W%QD%u%wuPa30u*OKjJ_DaHM6_<@ zxD2MQ>eDb@qP440r%@2PNfbX6hSB{=lFZ1WpokCEm(MVSmR3fVM9OS*YG!mYoOGo2 zLXzfQC(ZK<(zuN~OFJ9!)=p>O?8W}U>deWRdfURV)XC8ACekGzzoOAyw7g5(RCH6~ zyclJhRg|*4rd&V(z0xz2z7TrP96n(e`C zy(PkYD$P?rN=SIq@g~J4Bl&JjRb@$~No|0(0^M4=ag6sC%QFVbRl8D|CXZ^2YFsDoGTQF^kN*2mk<3qr+ zi?16cFBUoR+18>V{HwSA0y^$M7@HVtpk&7A__L-h85d;;?(GcsFf~d<71YU?=LIQC7A&k143Trqr%t#rjfYj>NwZ3@lLX1>w{;(#y2PT* zUO4U621gbTh`yI)I~FTOm**9jte2(thVn{bwtI2ii)p!;UesZF=)y}K9z1@`UH;>x zAHdriAAnzLfSp3I=v3Y#c3J|*#av)1WAS$?yn!&XR2m!oBLfrzA*hOqp&U&TVKlP0 zS%phGH{m*YF*_@3j{})u65R&CzqIuUMTV>ff&T?MPwexNcT(s0O<@yGw$+TslXPLDz(im8+RI6|MRqvlLYq~-5>>2FJAaBH)%x|dW$*nD-k+<+&hHKsx7ZdD!xLPZ9`E_T8`I2B3bXT=BU%6-N_ zKMo~)WUk^`%99y4dyd&4jXVrha4k!tBFS|}CvDsH{SRvO`UTtwuBNJ*Gr3ryZK$9R zfGmnJ{C4DE`DrkssS+a$7Yl)T<0yVG2!i|20NTQFL8j!)a*9Vj*xbslmt|rEb5&dk zG|h90EHq5yNFQ2&gD9;0^z!@F&DC|9F8|-(vH$r0OWSufTXtOMt?s=~zH#o%oru8z z1A>4+fJw}$M2SU-ww$VLS$5f$Oa1}2pY19=xm?zRpRCfGRpqiDEZLT1(ke;Jf(QnH zA_B=ln8D;6Za!h3-OIf?uI|0>fJMvzb!yH%ai6_=^-ACRzNb5~9BD7v1DpC~U?_-O z6)aD|5MyJgi1}C^G45{3N%kI%5s(T;3D?y*(knO5!P%YbmJE}?LWTb90)l+1_fD0v zo{R2NpJ%Fno1xbx6bx~t*Om5MGuR$a;M0emg)ct(5%}PN_rRx)KMOPJlaZ364nKc- z>pa}rz2}I5z>R0vXwH|q6FO}!BkT4czc_WSVv14ZNoyCLdz$KS-rPAPh|;yKJ20+i zMyHx1B7B1zx#z+(;YD8j$Na==p@rL?>e|$2XlK1NPs!-s*aBPuwr*S|B zZ(Qs?Z!pJ%9Rl{x=VEl*o{pE^eEaP+y&?~vJbv=Pi9_pseZ4yc0UhJiecJC}i9H8) z=AstO-3nWui<8H$FhgbIvSUyhGg+Ie8V$!Q(uH@($l54+%tKB^9;`ARi;5nVA!MbJ zGvO_X=YDIl2OmH5KKPYKKWsucieH{N@;LO%KJ1Xhk^YV=;|+N8_GPm{m~kKeyBqmK z8p4H5(B!;mkSdv&poltVEa zlUrMx^6Itg-NQ$Y7SBELlpK^jA*|!#$Xe!kFhQC}@8}kdXK7M---sI}+kIKC5PBVj z#TlO2xCDa4`nq~ibfgjQGdMjGev^bypTcx^8)kdE1S!)V44mhx4GLkR4Q&sn29@yX z!|#VLKJtsEIZl8kkVkXl~i4!Q_p8gR5=Nii#^Y$6GMfzbT9sIWJWR$dIgTGeaJzW7-Z9vHrsI z##l_kU?`U9tMK72>iZ-~f4y6Fzqc@2__J=e`=VYiQy+PG@H0O*7SP>-;s^x@bk*jE zG7r{H8{mJen%8ADt3ETE&3<`0ot@SXc7jGR3p==CA!oi#o^#Tx!r|xmS4TJ#xSr8m z+}XU>sjHdz;L}gT;gwaYUg=HPIj?+7BBzQ z!L_{&o$z;UA*U!}(Mo&rdOU|`4xEI4^3>01G*aR66q0l{k^33?`HP3Dw) z&^9C+b4y|$-_YLoC&Pu&SNntBxAd1yxhg9-rBngw7)oG86S0NCry>9zCS?G&k4u|b zuj%x$YFd4MI+=b%8`+8wf<;auDRT?(6+=5LiH@-;C4nq#VvT%w*eY!JLm=-)JN<>L zSH+;)gA<33*|837T!9bXoqW||WY}Qtacu^)jpD-7G`kDq5efnM;XD4)0$oGD#|?vS z@9x1i6(IVS(aJC#85#^_8pe+=t;4_j_&$4OpbKog*ZZrg`j|E@5)E*+<6$L0lp9y)Zzc_?(uH zKIp=3?d`!E=g%7g|Dj%#g_$cW({Lh9omj0{z&lNO_|VT2$nXZP<)-jC1+eC_Jug$8 zI}r4f9ZAp~e6nN$isa_UJqwRl|r5dAvsah^W#DEym`{sVY?bP&ecNKTBF;I}{U zEAW~1_d8u*^Um_EUpR|D-@-QLU>zSYdLNbAC1{?j3>)Wldke6tTbuMDw4?koKI6CG<$pR!n`~roz~VvPS1Bo#t}a5iD$s(r^lrl1vX? zIR6H`a_gts;JOy#q~G`4>S_45pZI(5>yLc|e)HL1f)6d9(h=8Gug%(o&j0Q8uftc! za}kiLqB4JN%IGvgGIzbgnw9uXBbyXGgONp0uky$2@PqWc|1EyUr*n$#Ij2Wvdvne( z&ACx_LYc^gHgDYKMKMCzmnhLwJ%K8@~ z89!`2Wx3G1$j*d_Wolk}4qicjXRn})gia?Rc590)G#6Gsq66_u)h7I>SHBEz?p>ml zY(i#w{?yy8u0Ih1J z5@)!^i1LwXk&lV~g+-~tet1t7U93dv7(LpVA@Xz$b+{*Sp6R%FKfa7H|8)2gjKq+fix9H2HwurD-?a z+S`G17cb~V(1UK-v+s90T;8LY5XtYybaTzb>@-PGGn(Mqwhvf_X5$x&k@Y41zlZTZS7|DR!47?lIC|#KzMGHKwIV)zl z6Jrit+tDS`1f1({x;Pw;zB?L@eqa=@U0cDUPeNrFoAYOg{xI@MgWyOaOW4C|G(#ea zZC_Sb^YwXEy>C7zsiC^`;LgAj<>rB`Tu`K}=HxTsfTfQnFlqSu2B!~ylxfE5@bK0x zxS?-L3yT(Q@5WiI^LLZF9=jNt$&i#|c`kwZ){d!CNK~Lt7X!NLrefZU=BQQ&3vhC{ zO3*PIo>jF$r=vDL3hk&GSPKO|x^of!<4gYs-qJ=!6-RsVYgjD%u%*Mp-~XGhz@MLg z5s(BesPIA>(BumWSNa2eF%VRFCCTqBp-q3hx+{ zH$5>6xlsAI#%Fl@)O1l z&0IZ1Dr#KNUh9R?;=(tJvN(^xR5(Sb13P6yk${PLu}fxp0)bjR^nBiZp+oNLyE6Qx zvZ(nZ+Q6QiPpf6D?U&nmE^ugSx9}m-UE3chA*Fv7Y!EQ!1lAMP0c=V;G~8p5;(V-*==e~TAr8}!tHQzC|8G=)q%IMImJ zyAz$KYNM)@xlywV&CNEEl@1?Yy#5yahZp`k{AlNG)2JP41KAc8{Fj&h6ux==RgnFz znQ9}$s>y2=j@QsPwk>f5=raNDJ#Z5Gvb4POVWK#z)3N*~ncET^(28HwW8J5Ugc;6_qpe?*l$6f9yg6XT&P zN-vFhby6>;M|DWh^8yLrfuQH}3iM0Q?aT!aCX&GG=nDtI{=Cmxs(D~;%6j4%{3ZB-bkU@Xg{imX!Mr1~(?KNl`&v(%V?4RT3W7S`;J z$MD4A6Yxu?e-S2Id$vG8VO4Fbe{R6d7+Ph*-#J_#--ZADgZ~Zx`dhyTe}3irrguhA zTQe;t4X7%Fz@h2x7O>EsEl1UJqmvN8)qV!gih?iWM?dhP$?21i z%!b;)dYuj#nRTA7VrqYrf*)*Zaomfdl_@VKn-dGDr=~u2TdXc=%Q1N#Pul7+8~q+g+okHL{TM z-GhD0`&BnQ7lt(DX4T!<+kJ9pXJ@IJR%Xbq)H_(APjcOvYh#$`aHk6$9X9l}r%^gt zCTRxpHYUsnGO)^)V*QWylmG^B&W+9HU=$wS-MnXZ(4jW4lGF`L&rT&9GPR*H#e@}n zk;aCRMLf|hr9;fTUc;dYktl!qehv|$4q*ZQ)d&AJeEsbAU`)k;q7z+X?R3G=8VEIs zNeAs_wE?&8Y#8rspk%7DJR)`BCV*use}wd16})%x2z*2vSSd=w&+lbaMaywZh7mO9 zEL3TQ@j6l&PezmW)ro_LUs_ohiC*W&$F5wtc6d_F`=RpdFxgc-gSF8ztoN5;1NUHz z6@26Mm*CXeQCKgROio&`Ya?=01m@ThheMRnYe^5P5wfC!Mz25k#-T$8|7bKEehZN_ zl$%n!oW?!iADs)9?nMe!Yo(EIqA1#R8GfGsir@L2-)THZ>K6P2(C()fc<%VdB z>XoPTVk&lYKD4E8+tV>j^=|41{!AD|sPL+Cy_!G+8kV`=$P}j&^CfWQ!^6ttUzqT0&iW@%T804CUd_zk?Q0@*F(N!d%Qw zz6ZpfSUqHBke`3+2hbak?m_J?E;h=)RKzIFs+G<-ygwCbBL}TyEO|nylQTI{@S9Km zGJN>Z6Skk_P%vrzJN34FhtAs}hKF!Aqejt8`zHDju8qRVwAp+Z#IOvIk*69d7JZY% ze0pIx8hzuSHLh>#H%yhULTCDGn9FD7lzWU0!dDY>`Kh*)%WRYnl#?UFVv#rL_xrnt z53hgY!lk!AeRpf~@P)0L@cOk21`WQ{8N%{t1cz4-!qJt3aD43`99&w}hA@Dc-eKil zY0%n4y+x!YH8DnU63UQts&J!1Y*o>PADiO9pf@mwLuz1!-^{& zi!E4vwhCh4zd01UP1{P`P>Q)}zZ7O$_ z@u+sgR5nDdqsTkbqBf;z+a#r`?5+-%|Nf~@!e@^?Lvg!lWhB=Oxu$m8ESt-ZO*rS; zDn=-tgzHczQsSMRy98$Ox#6zdNXW|1eq7^x@UylnJO@SZR!iVF*ww(t0N1lNfqNi8g^qDBq(eW2cSvN8gPHYc;SfGB93cu}~nLb`{ern>RoEohz^X z!o_M+j7yzIENG9i)Pr(aFY>h>z=1C8^%1V)mfqd2!y-6Mf9f(=73qX#?C7 zpmHR4tISc(IMl2)8t+Tof(MsZ;S-O&AGUYK@Y0pnOdo4Ohm3R_C?KUcb66uIpMw?* zB9$MAl;-oMI#mABBOis|e(u-dNN>fq0tkZxcQc$EgWPN4LaEV;sxg!)&g!f9g zKHh|j_in<%!U&EXSU1dA6!QB>RydM8X$J<;XjCHZ>Dyg>9WRfTmYzSfe(;Z1S6BW* zd%&NdH!cBL3q_>p93c|OR;1tv(2^u?OXZK53>1Itp%~a~uQB_iV{r1@!=KzZ_oeS& zJM+w)dQ50*A&IAs!nS?~x}%;x!Fs_jYVWc#fMTTw^-#jS*$%vY<0@RZb`9=rZ`vQC zYa;ao=v7Y?qion2fQ-PRjlSBHPZ+AKz$fjWM0 zopNJwcV|oI<9Z|P6nJ;C3vb=JA~wgnu)4Ae%L|KGijusP$tLFjyZUpt^p|Yt@3>Wz z#l_*`=+(7@YhTmG^?!%M(O>9`bW54?Jf|K&R=sT@kmcvW&#FO0n4~!I2&*kLvxmT*kC(HufNY}~!4f7=TD z%Mbi2eC&~@;ZNUq0lxb7i*WhYHK;oRbQq+6bJygQwxU{FUA5^%@97GT^q1k+PJJ4F z?TJspvnwZ|R|}$PNPdGzAeS-j!EACnBEA^83?rGb)fG``XXaY)})z-fPE? z9e+c6v)8X&yZ+0!Z{GUQbTU0Yg6`n6r``{btR2H2T{~A?*uF8C)YAnW2^C*E|1zB0 zxQWlLKQ=$LxW2p4>Fte*{?=$Xygld-ZtH7)S9_^zy?*zt;c$3X=js==VcgWmH3dv@ zDQQ+LT8--+@B17*Cqfk>6+QGrn&Kf*XA2Yw!Wnf^Fr7_5xVN=?va_eudZ$++DGcgB zp^IG*Q+uImIXC)EW4$qV20DaW(mB)W$W;C2#}teonZn(xci`1GufmzvF2LgpN8tS@ zo`h3}Pr!geJ(@w#;ScTbr`FeD>*9Hn@_VLVa4bjU`D&N1VXtRP@Q!N{GaWN@WoF}v zog`2kyvA&jITjfVyKgdoV1(y=W2He9$SA3-go#t`t}68XXU6-JW-Vmt792iIKc@>bUM62 z2{sP~BFz>tOad?pZ8wwXiH4A=Z8DEYHNVMdVexBEJ+b)Xy<^8t-??+|efKst-ZvhP zkG^;1c<<=qn!Gx>*SoQMcVT0?JJ_8~3N=T(wYxbP7Tt~I^%GZ*9Xs~sfu)r*+ACht zAAMJ!;|=W%Z&THW`d-S7oWuy%&6g>EkW6FDXO=>=4hJ{FdBIuJQimcS6$N}}k@=s~ zz+yri7ELi7k3Waw*_VFl^mFSUKl!BUmR%HODXheesX&?0_Q`x^$oOwhci{GX6E<`x zIlVQ3d!1dViGWmlx5dM&h79K7n-}5A#h<|WdspD&JI@-AR?dYPxp?}C_rmhhl34)c z4UJdSuA2BPgoD+fgXMS3*m?NiUJU8P&xn@hQml!GOD11)$Pw;umEZ0g7Fj<*c|Mul^|!SrEuumo#^MOf$zj8+#FO=k7f4u(pZ zNyCZ&6%d^6uT48F7^FYaI$<^T5fX32%M2IjdrDL)*E`Rp#l@w|OQWUl96x&G=;rp$ zp`G2`rD|R+pO(iCPxN-Gw~e)$=!~Tt_sib()!x#D1B1m2I-IZ#TH6yQew_F5D@asGT-17In5U2?vo8>$H8&0}T;f4&c=@W*0v?T7#z^eHi=OpwyC~-d0 zU4~=571LN&4sl>4yU9aO<~j$P8UqF3+Ru;`WO!Qzva)wNs-v9ZO57M!qV+7Tix$X` z>9IE?61<-><}jz?Qr+qGF06NZ7Z2)Oi;7eF{W_$9L;A%lK)7!qE4>ll)A7X}+K1=b zr77hhybu_ZbEvYo4~qrY^P=-*bqstuBliszBRv2cQ;Ma-*hWxI!TSg5!GvYkFLt)} zKD&8u^IuFRlV8z4U=2I=$f%ZD2_4p8X$A=Ce!`T9+V{SMP!@vjzN1@m2}8` zhiL?+GbPyYGNu~lJaYF6mV6ziy{;BckFdkF=r@Tp6y?Q+&|PsC-Hd)zQy5n>J9*%+ znud))rkZ2G&?tV9NLEfx&<2^DKvFGZWHEqTWipvEM}3S@iJ9{u=A-!Om$(`~LW=@^U1)8*DYC-ZDcNq9b);na}#rF<^U%N_nPM*AmN z%pCh%Y-}YQo=84JB%2$)>oBq1z^F`In*zciov1&qzj)+`Yy?4Oj4Gqk=S^Crt~lZX zX}9RKX};B3n9S>IUt7%R|_^NEgBQ<0`l3hha_J_;a5FJQP1lm z+Oe-&Qc-lY*CIv4X1hHxA$fe6+0hGsq>(3n?(m9aNDIXPo;`6IHuiR4XLHB=$Wec2 zX`PMtlTjK6ETqQ|c$PAf=AU;ISW4I zhb*7Nv_~bfQkGzRl8w)lBGnG&0A;2z9ja@{3N#qG0g)?kJ0mxnrIc6@zn~DL+P@T9yZQ>uDy(D`yziIQ&@?ck`18~BYP-C z4ny>!3VnN2AIGv0hs;DT5K@v(GAx5Ykk*TtYMo$*Ym4BZLOs-`UwV zBPpGd_;kDx*`3y-EX;x^oT#4z@CK%*mJ=iVR5OYY;$Xr` z3@s7QV17j8?Fb%r@+pM<1f;VOe9WPrEhLth<&`|A@sO0}h6Y%JFOo44146S6nn@ig z#Xpl%89<;>3!7Z_n#foMi9Q(s#-YOGSTQaB@Nzz*L~}C*O6P!;&AyxCBw<-sTOt08e8*2+lBJK0c`2+>77EBAk$F&kk_d2@0}32zYSYr>=D8`T^~ z?IL_*IHieBwpx)w^bit|DJ0@_yn@)e)45Pl?E}dc*sjvTux~=7lhnvd#9`D76R#H* zm_TR_8Di_)T7$nAV-6-76=M^It4>i=qOH4{6-?r$)G{uPm{jB-@$$S^$j`S(Jz_H4 zmx$Fh1R;KwG9OoIiLHi62NwuZnPk$wGh+|RgQSonyr)y%=zm zk4a#&yz5UUCl?8V*`zmGi!KW_m!###2#f87Co6v=9U@ch?jCXX+^S|y6uDu#Cf_mB2DQOwxB^}e&f-c zmQ}&jR}2xFCyB&ZrZ*v~Eperw-(Ea zkwrH6i2GFKQU}k~SK4a>&;m~r`{i9NMu)Dc&2~+8Z5f-?1L=b8Q{*u6I`v!|ms&to z7cnGIF$Bo+hm`s(?PavF2?^mPjd))P!45%4+u0|>DvLy{51h`HC&nQor2Etplb%(| z;|CQc{f$O$BvL*IVA{NqlOZ466OFvimv^N|OBn_}E11Xt2M~MX%XU-SY|er`jPz&d zTOUa=Aw`trRmS|g8YpslrVw>4kZcTXJ3^lWRR{h_39%(3SzE?;^|93uQUO1~)JBC! z+;v*?NY0sHX)m@-2sOMZw&SImMUUh<3kEn4Y*z6hl#s1YBZdh^PQ5tTFh;x%+C{j6ZSif0bN(DLseWd!ea1>g|-vAhT70J11$+PoDh2O+TG)WBVsIZOymZjpeEd2-Z%=p@ZB!6JjO zC5GSTVMF0@h(hl9xCZc1DJd#_qG#HA=9as}BNl0ZPk(Hoo--w?HH#q*RWzN;saypse94lXK5XMaD%0r4&|5+HuRYe5*vb z^45smDv)WP$pby9%9pg(-0u!$e38rEcybk)8WXEp7A-s(;6hyK1jV_EjLdM5f`yU^ z1Par_zG(4Kn3Tv_&ZB}LAw5inbx*&9=mIv=)h3b(t8$duw$Eu5a0*|M^mGJZA8XYH zW1g#(14FPm8EDgBNG&w-ShgEj<98&(sLWf4HX6BikN9o(V2A^K0S=QcTk0c4vRm67 zvPfdtP()yh50fM|V@+_2--LzW0k1O9zZH%%Lb_o@Tjh6@&f36AB7%{YjMFfq2|J!1 zR5`LYx@ay+WTWUZ0~0IEV;Mi+gbC6h z;fqN0s{~sFQBH|BPV7M^>sVSt-G%c} zFy4p^bCR=u!4Y}loupd8W#cPW^>iC|-)di?LJqC@mCC%7`gM z)?DN|ao7kXFmF@FGl;lvm?&}{1&ll$5D2bRi^qBx;tw=Xih-mclM~2&Fbf4yrHBY@ z_Gz4rhEJ10ZN-O~j-$fsR(XyJKk~s8SPXT5vbk883qxCq;RKV|O52qpCbA|J!&9~N zxfFyle(-iGR=NxqJ^~ZyLxws~S&IOfCR+SXg{(*BrjC<9Tco@P$of!-8Eh(NsEuq8 zzJOnwdWH!6a%rBo&l+o$>qV6oB3u!|f){El^sYBf(zGV#u(<;%h(w1Dk~WKIuE1t} z<7dv5*pCX9%)b!h?Ey5J@1xO-F#_fJm5r<#&AygqgLAPmdrF1o9cC3r+fWukID*RW zV1%wqD>UH=MV*zIW7KI9<{^nm7S}L@{aUSWPgi)B``u5$#T$04g8J4!#7!Yqn zEB6|als@|+_Ul85 z1icY8j?q;ORmz8wq^qpWV0xGkl0T2=55%U)_`lev=pYh&DP-X=G_Z{bIRa9oc<_)b z-!DFSa30JwL97qss#7Xs9k#T6ep)t86(bXs_90G4Yj`yaMWdtdD{?m1vfO25X}wqw zHV7bA5{j@*Vb5!yA7d^NmIM))o^0-Y69fma`5_k|Av+(@+hk0&$#I}}eogvjhCuO# ziEKA-z+({`-kEQ>4Nc+(#3>10Y4USJ-@l{pZ{}m!iCB3{UnvoBz%+T{X98RHE9GE$ zGm?O!26s(O?zvU#%#JmcvBjn#?8k1D1tckMEjw1sJ5LV_>>G&He1+AinlU*b8{bDS z;%Tor05!md0-`~+ck$hjpHGBkh`MaigIJd&sX}r-dcmllo!&4wsZF`kPHD%(ZE7Nw*X4w`9QVf;k7B$d34?Bq zQ#45j-q!bS>(hE&|9$&ss~w2e7HAt@G*+9HS07Jp(Qhnzxsm8P8JICNbE^$=bYSfj zq7fvCeV`m(8?GS&$BbJgJ+ib<)(R_oa0VvqM$1;7@;kThhp3o#gnt=)$+}#TEIf{` zNc2Ye&j|We(IBm~sWfPNI|n8cZgS-eUIim)<_L-%1J6;g)vjWDLACFf?s=C#V0&H? z?kd%oWePkQExRu|FQ$^+-iJob@H$o$Ap$5}w2(Kfv)@aHLj5o8z2pt!%^G{SV3|-V z2P=3ni}mFjnXU@G@Pz4<(k8! z1Zl{1N`G?#d`zr7=sYt|8AC%A!l>w7RVrVrFdLs>&IXZ%!d`PqCBR&jVEQ2$*#Tzw zfsWj7Ozdh0+BkHGPi*E1`DjfnVlcb{wrCHtYn6p@GreHP=HL6_0CWF!-&v`2p(5+k zsXVL{`_vK`mk1R?s3=0ZYvkdGy-Z~-7|g5H=%(u%$JBXT$3S6`G;t*8(mhUgGh(Gv&9?+ViOPnWVtLp zkb=j4RYa;(P&_`52gxKoF)Gw!+9Pd~Tnc!PR=zs6!}}hU^r3=#u0zU}M9*DV`ZglG zseO4@#u*+yOlgGTXtElt!Po&xWF#jLkp8AEz+^>Dh0nze7>YLN#_KIRkzh?Bqcm(*GRRUT zt(W#9p8JX>cVu2OeJCEx?RdL3(!)?Vpv9t`(xIPTwrog6mJ_gcDGPgqO2d|9q9EJd z{DPsyPM_Z&^q}09mQzsQ$beKr+hxtzhp<#}^uSk>u{jJ9iZ~OdTrTHBM8i^rX+KMN zBLj=zFrVpoIut}Q7a8Chipljj9chJuW6tL!+#uR6zF)ll{ph{0a=^$O3#?d21Vv1J z12#DJ=G=Es7vP2{2*DvQQ=!AY1(UI!WCkG<;8HCBrabqEY*MVRCGz>Z%$xA?0x$Tn zH6nxlw4NwEtdNMQkJmxLY60MiXiNG9mK<7m2$n7d1w%!tH-dhdCYf-tc#9r zKFsH7=2{mr^IFj8k@5Co;HJAFw0$n7 zPlc{{L28$tfYgxmNa2tpyy4F|0(sb#!LjHg1l}Tul>XRjLfN8vP=;{P9ePy9D?o{ zkrIXau+|gz@XaaCra{8r1dOlIhSDPG+xXm6h*Wf(wf-nLnd!IP)X_7MgWhUk+i%Qi z-8VPObP^iTVq$!vfvQO{t1|mAOxoMv(8vMD#}YYXXe5h8hUSKNi=d{%3|U~)d@&`( zc@QSIkAdmsBV=ti@pN=I;ir>gf8Yb}fr;1$^DI+>Z>zl7ijb6pg=@NY`#mEWI~z~6 z7wbY*&aK&m3Eqf%G!?-j>R-t~i=DPvF<|>-$lb_$)$U)<(4I8Qf#+U~!(9j`!{m&N zR}%uGEGD56wwyE3!n`C|jFgO=c%(!iZow>IV^>S?v3V18UehP$lD>aVzvOlj@nOam zm@y^;!z)IiL!R?xh>H6l^J6JJdjH#$>F*WL*-Mj6IZGnZA2j=bUfp zH@z)P{|g%}1-6TQJZJ+$TPm-t^Kc{MDh468a3B)=8wbpqYe@jyqDb_Rg9&lc8B8=jn9WM> z8R+3%y%^36M#BT$PWKTV0+hW@N7x(;+p&1GM@tNp-lV=L@(NaMolA`v##;=Bj*LU2 z(t9aa@CZsAU=7W`Ph!5qw-;xH>)+YffQ`oFyy$InQ>+9JITyb%YT-O**3QMGVoNrB zi{xVUCicPb2I>Y34aL=qBW3o97!r3Eg^=(1a2^q3i0z5!$)u&f2< z(f#$DL>z0w{bI1KdkFNP^Gz3QEuG(K7GkaqbY>+o}~IWARd zwXN*zmjaawpfX|=;Ri6OAik&n{E>d*)!v{#7>xR#nb+0HZl^43RSM;BYxeWO4_*c1 zvQyEPSKb_<^EUqC7?0s{FMo{64}hAAr0vUjDgbj|*#u6an-lXJh?|~H_&JO*MNep} zebNA#0~ZM+5%qIpfm%iM_*>i`KM+YiTX2g^{RAP;YxjZahE|$P`S?LJuUjLG{tzW=bJ$#y3#{ z;n6^@_7XlPYwntyQl|J%j}i4?5WQfo3c9XBoYfEB4lqZCXvq6k{JbVjWCm`G)78)- zSYjW*FP9xgpnOl3et<-pYWH}Lp2*nT^1(~X2A(Ke_;pAXO2%*@}F7(Y% z@S~9sH;-qeh2|1!hb6SV2IV;3%YbG#BJybGxL*^;uXWv>d%DnD zU<9ntoPVloNHoqcK^F^K+>r8heW}g`aIeTYH?ReJh$$!_wvii)z5#&Fktb;;2K#Wn z0kmd#qUjAV=fP}wN#hvO?>6-AG*gHc8A62+fe)!nZw$PUwGI>CM$3H`;87N=P`F8A zo0AytMU_ImOyt3oKBo~1Lf4GAIeg_Y$o7Z`r81>^ zWnfauxQW76B|~#LHi$)D?eeJhgz1UrC@IseS+ar3StdKJ`E*QyvL40JP#@uo(}sEU zs_UW@Z;FomiGJ`3nqVCp^$WEIL(|&+($4 z5cl(GEH>x84Ut4k8CJ`r8)R&yKr1{V9|q|SSad{D7B}?nu4=@>&wNp+MZ)7`SaVYl zsEn~qaT>xF9|rhcfmBoIM&si1pc!Vv8H3K~bfDkuL6(sd|W@~&pvCaUm=M!16DG3NV)fa5&Js9OqGrgZz zlL>T$C8V*@mgR=8U2n?F@l+LzXH%n?3hKoGF*Zq2i9<@ zKbm*Dy^9M=i*J^_@|7Cv%WT9;hov|z%JSvW;=+s5@#K+Ct&A6&&uP!8%w%(;JMVjM z)Ze0DOcfDA#p&gMt2o-rYG?=?B0h|hID?TM*z1|5I4KLupS)}nFlMP{f{q#3Dr+of z;NZajj*yJfhkP^${h4p3sfe@CDSs$B;`{osEA+wyZIKL!im>=N`DSxW1(RM|O3srS znaFx*Ii-`x47e3GetQ9#UT$h>u3!XUn*rihkSAVp$y7;{Q?w@LpHx=mQ zKhcCpV%~g zv%U_)n25kovI1+WbJ8pto#0#Gw?=eGqU{ZPGpGr!f%3kM%YJ2NZeertpxn*!7ae(Q327B`f zY|r*!Z#seLbY>1^FzCbT(h3|tv>*I*VkcSY53>OIx8i9?CIy~ z-D-+gZr-SF+_`mjd9?WIiNlBgszUW09b#TZn=B<2C6bRWdfn%jSC<~0O=l+&>%qL! zStJ7sQc4Q5oWWHtWlCc}o#g`D{FjJ$MMKPDn1YMYK#pi@Utz?st0MZEWDUt2E2>~n z$cH;clvEW=L@;U^+krvBbtI9a`b_1DK6YU+Zmxa82YVF9aV=D*GZ}08CGM-c~h(f+0 z3o-R41)2CBTB*kA!s5dB$2;R^H>%l@Znra>tGOT}vm+f*w#V0(O?5ta6W2U8q)@g% zf?nj@(Z|`oor(;(=)IXKr#ONsVXBS5!wtvd4pIcZJzG)qY~FqdG)nv!Z%V45p&bzC zt-?e$3i{DshLOy?vu2>0nu@54Q}KZ3cTH~PR0%%T`fFANVHJoIgy*Q{-BZEtHY_2C=0 zZ>;WVBO44yaQw(IIJ9~Y4lFF;u;0U8(S@!k4WR;+^p&X5lGW%`QIH0(nb(nJVN6Bm zcE#&L)^F*Y?OL@vxu(DJ+(33-8`t%Dy$GjNt&<=cZr{n==*3gKvAnwc!gz1|_`IGE zJDtwD&ikbHw1RT6I7TkwFelM$M{z(51gOFqiZL|93qm6;g@H{GmNG1S7v|CL@p(WH zTo92`%>3%1ScVB!GGa#|9`2u09M_Tm~vITK}ZU3Q8~ zLKc?@wUIBqRC5{Hkrhd})v{QHV68E(ku5UAYX47Q6WY{jUq8!o=cxXC41|f8G(M~i zBZ?16T}61NqDqH}Md#+x^&@|HXzkFs&F!sU-P+my{PJ++^oikUH0%$cUv^O&l`3jE zBY0J(=uCRDGc6@St;AR_rm_COsSe$ydeP5{qCSdP&MbowzM&V^Yx<&HC{SM0tNSX@ z>vf#9r}H_&`q5f4?7oa6N4VOwUQ)9`fAGVlrN!fu$)uwt-H~M)P9W4d96G2C`pIK&Y(m%| zPq?M;1;)Ll32TXH-b%nL86z}-iB@nFv=JUo7(27Z#!y9VbA8|isZ~m`r9Jf(ir_rW0)M?my86HMxA} zy4`&u$tNwT?rb)FbZ2k(qjjaeSV(a|d!(^mU=v9k0p^qom;1@NHiqQE&Dr*4X7P%Y4vrf%{?u~(vwatwm63!P{ z&x7W9H*b(C&6*g|z1JGDB})>1{)IUY%R%YB$6!*uAx5zWff*gVPnbBhUO0xrLUCwd zd!i5Uo__Rede?YSzj-Wjg&PscN<<^yX%S**gY0F)lC29{+{fi4@Orqf&k~KMcKRBT vAwTZdE5_XGiDpDee;(1P&(Hhc{}*5YpB|nXQHC^L00000NkvXXu0mjf +