diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..927c6c4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,182 @@ +# CLAUDE.md — 企迹 (qiji) 政企周报管理系统 + +## 项目概述 + +面向中国电信政企客户经理团队,替代 Excel 的周报管理系统。覆盖每日拜访记录、下周工作计划、小微业务商机跟单、要客拜访计划四大模块。支持 PC 端(支局长/分管领导汇总查看)和移动端(客户经理外勤填报 + 企微入口)。 + +## 技术栈 + +| 层 | 技术 | +|---|------| +| 后端 | FastAPI + SQLAlchemy 2.0 (async) + Alembic | +| 数据库 | PostgreSQL (已有,复用) | +| 文件存储 | MinIO (已有,复用) | +| 认证 | Casdoor OIDC (已有) + 企业微信 OAuth | +| 前端 | Vue 3 + Vite + TypeScript + Element Plus | +| 状态管理 | Pinia | +| 定时任务 | APScheduler | + +## 项目结构 + +``` +qiji/ +├── docker-compose.yml # PostgreSQL + MinIO + backend +├── 政企周报管理系统-设计文档.md # 完整设计文档 +├── backend/ +│ ├── app/ +│ │ ├── main.py # FastAPI 入口 + lifespan + 路由挂载 +│ │ ├── config.py # pydantic-settings 配置 +│ │ ├── database.py # async engine + session +│ │ ├── models/ # 8 张表 (见下方数据模型) +│ │ ├── schemas/ # Pydantic 请求/响应 schema +│ │ ├── api/ # REST 路由 (12 个模块) +│ │ ├── services/ # 业务逻辑层 +│ │ ├── middleware/auth.py # JWT 鉴权 + 角色权限 +│ │ └── utils/security.py # JWT 签发/验证 +│ ├── alembic/ # 数据库迁移 +│ └── requirements.txt +├── frontend/ +│ ├── src/ +│ │ ├── router/index.ts # 路由 (mobile + desktop 两套布局) +│ │ ├── stores/auth.ts # Pinia 认证 store +│ │ ├── api/ # 9 个 Axios API 模块 +│ │ ├── views/ +│ │ │ ├── mobile/ # 移动端页面 (Home, VisitForm, ...) +│ │ │ └── desktop/ # PC 端页面 (Dashboard, WeeklyReport, ...) +│ │ └── components/ # MobileLayout + DesktopLayout +│ ├── nginx.conf # 生产 nginx 配置 +│ └── Dockerfile +``` + +## 数据模型 (8 张 PostgreSQL 表) + +| 表 | 核心字段 | +|----|---------| +| `users` | id, casdoor_id, name, role(manager/director/leader), wecom_userid | +| `customers` | id, name, industry, address, in_use_services, monthly_fee, remarks, created_by | +| `customer_contacts` | id, customer_id, name, phone, role_desc | +| `customer_assignments` | id, customer_id, manager_id, role(primary/assistant), assigned_by | +| `visits` | id, customer_id, visit_date, visit_method, time_range, communication_content, customer_demand, companions(UUID[]), photos(TEXT[]), manager_id | +| `work_plans` | id, customer_id, plan_content, plan_date, manager_id, status | +| `mini_business` | id, customer_id, product_type, amount, follow_up_detail, status, manager_id | +| `key_visits` | id, customer_id, urgency_level, description, progress_status, planned_date, planned_visitor, visit_target, manager_id | + +## 当前进度 + +### 已完成 ✅ + +- [x] **Phase 1: 项目骨架** — 完整目录结构,FastAPI + Vue 3 均可启动/构建 +- [x] **Phase 2: 数据模型** — 8 个 SQLAlchemy 模型 + Alembic 迁移配置 +- [x] **Phase 3: 认证系统** — JWT + Casdoor OIDC + 企微静默登录 + 角色中间件 +- [x] **Phase 4: 客户档案** — CRUD 全链路 API + PC 端管理页 + 归属分配/批量转移 +- [x] **Phase 5: 拜访记录** — CRUD API + MinIO 预签名直传 + 移动端填报表单 + 移动端首页 +- [x] **Phase 6: 计划/商机/要客** — 三个模块的 CRUD API + 移动端表单 +- [x] **Phase 7: 仪表盘与汇总** — 四卡统计 + 填报进度 + 四 Tab 周报 + 按人/客户筛选 + 时间轴 +- [x] **Phase 8: Excel 导入导出** — 导出四 sheet xlsx + 导入预览→确认→去重 +- [x] **Phase 9: 企业微信** — 静默登录 + 催办/公告推送 + 每日 18:00 定时检查 +- [x] **Phase 10: 部署** — Dockerfile × 2 + nginx.conf + docker-compose.yml +- [x] **Casdoor 对接** — 已配置 Casdoor OIDC,前后端 Client ID 统一,登录/回调正常 +- [x] **MinIO 对接** — 已配置预签名上传/下载,bucket 就绪 +- [x] **PostgreSQL 对接** — 8 张表自动创建,数据持久化正常 +- [x] **用户管理** — 支局长可在 PC 端「用户管理」页面修改任意用户角色 +- [x] **客户档案增强** — 详情含客户经理、点击名称查看、备注字段、收支费用(金额+单位)、联系人管理(新增/删除)、多选批量转移 +- [x] **客户导入导出** — 导出含全部字段 + 模板下载 + Excel 批量导入(自动去重匹配经理) +- [x] **路由修复** — Hash 改 HTML5 History 模式,Casdoor 回调正常 +- [x] **网络部署** — 后端监听 0.0.0.0:8002,前端监听 0.0.0.0:5173,防火墙已开放 + +### 待完善 + +- [ ] 实际对接企业微信(需填写 WECOM_CORP_ID/AGENT_ID/SECRET 等) +- [ ] 前端移动端/桌面端自动适配(目前路由分两套,user-agent 判断待完善) +- [ ] 客户查重前端集成(API 已有 `/customers/check-duplicate/{name}`) +- [ ] 缩略图生成策略(MinIO 端配置) +- [ ] 企微聊天侧边栏(设计文档标注为"后续扩展,首期不做") + +## 验证状态 + +| 检查项 | 结果 | +|--------|------| +| `import app.main` | ✅ 成功 | +| `vite build` | ✅ 构建成功,输出到 `dist/` | +| 后端运行 | ✅ `http://10.10.10.14:8002` | +| 前端运行 | ✅ `http://10.10.10.14:5173` | +| PostgreSQL | ✅ `10.10.10.14:5432/qiji` | +| MinIO | ✅ `10.10.10.13:17051`,bucket `qiji-photos` | +| Casdoor | ✅ `10.10.10.14:18000`,登录/回调正常 | +| 企微推送 | ⚠️ 待填写有效 Token/AESKey 后测试 | +| `docker-compose up` | ⚠️ 需要本地 PostgreSQL + MinIO 实例 | + +## 启动命令 + +### 第一步:配置环境变量 + +```bash +# 后端 +cd backend +cp .env.example .env +# 编辑 .env,填入 Casdoor/PostgreSQL/MinIO/企微 的真实连接信息 + +# 前端(Casdoor 登录用) +cd frontend +cat > .env << 'EOF' +VITE_CASDOOR_ENDPOINT=http://your-casdoor:18000 +VITE_CASDOOR_CLIENT_ID=your-client-id +EOF +``` + +配置项说明: + +| 配置块 | 说明 | 必须? | +|--------|------|--------| +| `DATABASE_URL` | PostgreSQL 连接串,已有基础设施填真实地址 | ✅ 必须 | +| `CASDOOR_*` | Casdoor OIDC 认证服务的地址、Client ID/Secret | ✅ 登录必须 | +| `MINIO_*` | MinIO 对象存储地址和密钥 | ✅ 照片上传必须 | +| `WECOM_*` | 企微自建应用的 Corp ID / Agent ID / Secret | ⚠️ 企微功能需要 | +| `SECRET_KEY` | JWT 签发密钥,生产环境务必修改 | ✅ 必须 | +| `CORS_ORIGINS` | 前端地址白名单 | ✅ 必须 | +| `VITE_CASDOOR_*` | 前端登录跳转 Casdoor 所需,必须与后端一致 | ✅ 必须 | + +### 第二步:启动 PostgreSQL + MinIO + +如果已有可用的 PostgreSQL 和 MinIO 实例,跳过此步。否则用 docker-compose 启动: + +```bash +# 在项目根目录执行 +docker-compose up -d postgres minio +``` + +### 第三步:启动后端 + +```bash +cd backend +uvicorn app.main:app --reload --port 8002 --host 0.0.0.0 +# 首次启动会自动创建数据库表(lifespan 中执行 Base.metadata.create_all) +# API 文档 → http://localhost:8002/docs +``` + +### 第四步:启动前端 + +```bash +cd frontend +npm run dev -- --host 0.0.0.0 +# 页面 → http://localhost:5173 +# vite proxy 自动将 /api/* 请求转发到 localhost:8002 +``` + +### 一键启动(生产模式) + +```bash +# 项目根目录 +docker-compose up -d +# 后端 :8000 + PostgreSQL :5432 + MinIO :9000 + :9001(console) +# 前端需单独部署到 nginx,见 frontend/Dockerfile + nginx.conf +``` + +## 关键设计决策 + +1. **双布局**: `/m/*` 移动端(底部 TabBar),`/*` PC 端(侧边栏菜单) +2. **照片上传**: 前端从 `/api/upload/presigned-url` 拿 PUT URL → 直传 MinIO → 表单只传 object key +3. **同访人员**: 选同访人后,后端自动创建一条内容为空的状态副本 +4. **权限**: JWT payload 含 `role`,`RoleChecker` 做接口级鉴权,前端 Pinia store 做 UI 级控制 +5. **企微静默登录**: wecom code → userid → 查 users 表 → 找到签 JWT / 未找到引导 Casdoor 绑定 +6. **前后端分离开发**: `vite.config.ts` proxy 转发 `/api` → `localhost:8000` diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..d9ca1c2 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,41 @@ +# ============================================ +# 企迹 (qiji) — 环境变量配置文件 +# 复制此文件为 .env 并填入真实值 +# ============================================ + +# ── 应用 ── +APP_NAME=企迹-政企周报管理系统 +DEBUG=true +SECRET_KEY=change-me-to-a-random-string-in-production + +# ── PostgreSQL (已有基础设施,填写实际连接信息) ── +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/qiji + +# ── JWT ── +JWT_ALGORITHM=HS256 +JWT_EXPIRE_MINUTES=480 + +# ── Casdoor (已有,填写实际部署地址和密钥) ── +CASDOOR_ENDPOINT=http://your-casdoor-server:8000 +CASDOOR_CLIENT_ID=your-client-id +CASDOOR_CLIENT_SECRET=your-client-secret +# CASDOOR_CERTIFICATE= # 如果 Casdoor 使用自签名证书,填证书内容 +CASDOOR_ORG_NAME=qiji +CASDOOR_APPLICATION=qiji-weekly-report + +# ── MinIO (已有,填写实际部署地址和密钥) ── +MINIO_ENDPOINT=localhost:9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_BUCKET=qiji-photos +MINIO_SECURE=false + +# ── 企业微信 (需在企微管理后台创建自建应用后获取) ── +WECOM_CORP_ID=your-corp-id +WECOM_AGENT_ID=your-agent-id +WECOM_SECRET=your-app-secret +WECOM_TOKEN=your-token +WECOM_ENCODING_AES_KEY=your-encoding-aes-key + +# ── CORS (前端地址) ── +CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"] diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..385ccd3 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..b18f0e5 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +sqlalchemy.url = postgresql://postgres:postgres@localhost:5432/qiji + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..100dd2d --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,57 @@ +import asyncio +from logging.config import fileConfig +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config +from alembic import context + +from app.database import Base +from app.models import * # noqa: import all models +from app.config import settings + +config = context.config +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..af9233c --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1,3 @@ +from fastapi import APIRouter + +router = APIRouter() diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..208636d --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,93 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import get_db +from app.schemas.user import TokenResponse, WecomLoginRequest, CasdoorLoginRequest, WecomBindRequest +from app.services.auth import ( + exchange_casdoor_code, get_or_create_user_from_casdoor, + get_user_by_wecom_id, bind_wecom_user, build_token_for_user, +) +from app.services.wecom import wecom_client +from app.config import settings + +router = APIRouter(prefix="/auth", tags=["Authentication"]) + + +@router.post("/casdoor-login", response_model=TokenResponse) +async def casdoor_login(req: CasdoorLoginRequest, db: AsyncSession = Depends(get_db)): + """Standard Casdoor OIDC login — exchange code for userinfo, get or create user, return JWT.""" + userinfo = await exchange_casdoor_code(req.code) + if not userinfo: + raise HTTPException(status_code=400, detail="Failed to exchange code with Casdoor") + + casdoor_id = userinfo.get("sub") or userinfo.get("id") + if not casdoor_id: + raise HTTPException(status_code=400, detail="Invalid userinfo from Casdoor") + + name = userinfo.get("name") or userinfo.get("preferred_username") or casdoor_id + role = userinfo.get("role", "manager") + + user = await get_or_create_user_from_casdoor(db, casdoor_id, name, role) + token = build_token_for_user(user) + + return TokenResponse( + access_token=token, + user_id=str(user.id), + name=user.name, + role=user.role, + ) + + +@router.post("/wecom-login") +async def wecom_login(req: WecomLoginRequest, db: AsyncSession = Depends(get_db)): + """WeChat Work silent login — exchange wecom code for userid, find bound user, return JWT.""" + userinfo = await wecom_client.get_userinfo_by_code(req.code) + if not userinfo: + raise HTTPException(status_code=400, detail="Failed to exchange wecom code") + + wecom_userid = userinfo.get("UserId") or userinfo.get("userid") + if not wecom_userid: + raise HTTPException(status_code=400, detail="Could not get userid from wecom") + + user = await get_user_by_wecom_id(db, wecom_userid) + if user: + token = build_token_for_user(user) + return TokenResponse( + access_token=token, + user_id=str(user.id), + name=user.name, + role=user.role, + ) + + # Not bound yet — return a redirect URL to Casdoor for binding + casdoor_auth_url = ( + f"{settings.CASDOOR_ENDPOINT}/login/oauth/authorize" + f"?client_id={settings.CASDOOR_CLIENT_ID}" + f"&response_type=code" + f"&redirect_uri={settings.CORS_ORIGINS[0]}/bind-wecom" + f"&scope=openid+profile" + f"&state={wecom_userid}" + ) + return {"need_bind": True, "casdoor_url": casdoor_auth_url, "wecom_userid": wecom_userid} + + +@router.post("/bind-wecom") +async def bind_wecom(req: WecomBindRequest, db: AsyncSession = Depends(get_db)): + """Bind Casdoor account with WeChat Work userid after OIDC redirect.""" + userinfo = await exchange_casdoor_code(req.casdoor_code) + if not userinfo: + raise HTTPException(status_code=400, detail="Failed to exchange casdoor code") + + casdoor_id = userinfo.get("sub") or userinfo.get("id") + wecom_userid = req.wecom_userid or userinfo.get("state", "") + + user = await bind_wecom_user(db, casdoor_id, wecom_userid) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + token = build_token_for_user(user) + return TokenResponse( + access_token=token, + user_id=str(user.id), + name=user.name, + role=user.role, + ) diff --git a/backend/app/api/customers.py b/backend/app/api/customers.py new file mode 100644 index 0000000..768f2f4 --- /dev/null +++ b/backend/app/api/customers.py @@ -0,0 +1,507 @@ +import io +from uuid import uuid4 +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, or_ +from sqlalchemy.orm import selectinload +import openpyxl +from app.database import get_db +from app.middleware.auth import get_current_user, require_director, require_any_role +from app.models.customer import Customer +from app.models.customer_contact import CustomerContact +from app.models.customer_assignment import CustomerAssignment +from app.models.user import User +from app.schemas.customer import ( + CustomerCreate, CustomerUpdate, CustomerOut, CustomerListOut, CustomerListResponse, + ContactCreate, ContactOut, AssignmentCreate, AssignmentOut, BatchAssignRequest, +) + +router = APIRouter(prefix="/customers", tags=["Customers"]) + + +def _split_fee(fee: str) -> tuple[str, str]: + """Split '5000元/月' into ('5000', '元/月').""" + if not fee: + return ("", "") + for u in ["元/月", "元/年"]: + if fee.endswith(u): + return (fee[:-len(u)].strip(), u) + # Custom unit: separate trailing non-digit+non-space chars + m = __import__('re').match(r'^(.+?)\s*([^\d\s]+)$', fee) + if m: + return (m.group(1).strip(), m.group(2).strip()) + return (fee, "") + + +# ══════ Fixed-path routes (must come before /{customer_id}) ══════ + +@router.get("/", response_model=CustomerListResponse) +async def list_customers( + search: Optional[str] = Query(None), + industry: Optional[str] = Query(None), + service: Optional[str] = Query(None), + manager_id: Optional[str] = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(25, ge=1, le=100), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """List customers with filters and pagination. Managers only see their assigned.""" + from sqlalchemy import func + + base_query = select(Customer) + if current_user["role"] == "manager": + base_query = base_query.where(or_( + Customer.id.in_( + select(CustomerAssignment.customer_id).where( + CustomerAssignment.manager_id == current_user["user_id"] + ) + ), + Customer.created_by == current_user["user_id"], + )) + + if industry: + base_query = base_query.where(Customer.industry.ilike(f"%{industry}%")) + if service: + base_query = base_query.where(Customer.in_use_services.ilike(f"%{service}%")) + if manager_id: + assign_subq = select(CustomerAssignment.customer_id).where( + CustomerAssignment.manager_id == manager_id + ) + base_query = base_query.where(Customer.id.in_(assign_subq)) + if search: + contact_subq = select(CustomerContact.customer_id).where( + or_( + CustomerContact.name.ilike(f"%{search}%"), + CustomerContact.phone.ilike(f"%{search}%"), + ) + ) + base_query = base_query.where(or_( + Customer.name.ilike(f"%{search}%"), + Customer.industry.ilike(f"%{search}%"), + Customer.address.ilike(f"%{search}%"), + Customer.id.in_(contact_subq), + )) + + # Count total + count_query = select(func.count()).select_from(base_query.subquery()) + total = (await db.execute(count_query)).scalar() or 0 + + # Paginate + offset = (page - 1) * page_size + query = base_query.order_by(Customer.name).offset(offset).limit(page_size) + result = await db.execute(query) + + return CustomerListResponse( + items=result.scalars().all(), + total=total, + page=page, + page_size=page_size, + ) + + +@router.get("/export") +async def export_customers( + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Export all customers (name, industry, address, services, fee, contacts) as Excel.""" + from openpyxl import Workbook + result = await db.execute(select(Customer).options(selectinload(Customer.contacts))) + customers = result.scalars().all() + + # Build manager lookup + mgr_result = await db.execute( + select(CustomerAssignment.customer_id, User.name) + .join(User, CustomerAssignment.manager_id == User.id) + .where(CustomerAssignment.role == "primary") + ) + mgr_map = {str(cid): name for cid, name in mgr_result.all()} + + wb = Workbook() + ws = wb.active + ws.title = "客户档案" + ws.append(["单位名称", "所属行业", "单位地址", "在用业务", "收支费用-金额", "收支费用-单位", "客户经理", "备注", "联系人姓名", "联系人电话", "联系人角色"]) + for c in customers: + mgr_name = mgr_map.get(str(c.id), "") + amt, unit = _split_fee(c.monthly_fee) + if c.contacts: + for ct in c.contacts: + ws.append([c.name, c.industry, c.address, c.in_use_services, amt, unit, mgr_name, c.remarks or "", ct.name, ct.phone, ct.role_desc]) + else: + ws.append([c.name, c.industry, c.address, c.in_use_services, amt, unit, mgr_name, c.remarks or "", "", "", ""]) + for col_cells in ws.columns: + ws.column_dimensions[col_cells[0].column_letter].width = 22 + + output = io.BytesIO() + wb.save(output) + output.seek(0) + return StreamingResponse( + output, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": "attachment; filename=customers.xlsx"}, + ) + + +@router.get("/template") +async def download_import_template(): + """Download a blank customer import template (public, no auth required for download).""" + from openpyxl import Workbook + wb = Workbook() + ws = wb.active + ws.title = "客户档案导入模板" + ws.append(["单位名称*", "所属行业", "单位地址", "在用业务", "收支费用-金额", "收支费用-单位", "客户经理", "备注", "联系人姓名", "联系人电话", "联系人角色"]) + ws.append(["XX科技有限公司", "信息技术", "XX市XX路100号", "云桌面、专线", "5000", "元/月", "韦柳柏", "重点客户,季度回访", "张三", "13800000000", "技术负责人"]) + for col_cells in ws.columns: + ws.column_dimensions[col_cells[0].column_letter].width = 22 + + output = io.BytesIO() + wb.save(output) + output.seek(0) + return StreamingResponse( + output, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": "attachment; filename=customer_import_template.xlsx"}, + ) + + +@router.post("/import") +async def import_customers( + file: UploadFile = File(...), + current_user: dict = Depends(require_director), + db: AsyncSession = Depends(get_db), +): + """Import customers from Excel file. Director only.""" + import uuid as uuid_mod + content = await file.read() + try: + wb = openpyxl.load_workbook(io.BytesIO(content), data_only=True) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Excel 解析失败: {str(e)}") + + ws = wb.active + created, skipped = 0, 0 + errors = [] + + # Build user name → id lookup + user_rows = await db.execute(select(User.name, User.id).where(User.role == "manager")) + user_map = {name: uid for name, uid in user_rows.all()} + default_user_id = uuid_mod.UUID(current_user["user_id"]) + + for row_idx, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2): + if not row or not row[0]: + continue + name = str(row[0]).strip() if row[0] else "" + if not name: + continue + industry = str(row[1]).strip() if len(row) > 1 and row[1] else "" + address = str(row[2]).strip() if len(row) > 2 and row[2] else "" + services = str(row[3]).strip() if len(row) > 3 and row[3] else "" + fee_amt = str(row[4]).strip() if len(row) > 4 and row[4] else "" + fee_unit = str(row[5]).strip() if len(row) > 5 and row[5] else "" + fee = (fee_amt + fee_unit).strip() if fee_amt else "" + mgr_name = str(row[6]).strip() if len(row) > 6 and row[6] else "" + remarks = str(row[7]).strip() if len(row) > 7 and row[7] else "" + contact_name = str(row[8]).strip() if len(row) > 8 and row[8] else "" + contact_phone = str(row[9]).strip() if len(row) > 9 and row[9] else "" + contact_role = str(row[10]).strip() if len(row) > 10 and row[10] else "" + + existing = await db.execute(select(Customer).where(Customer.name == name)) + if existing.scalar_one_or_none(): + skipped += 1 + continue + + # Resolve manager: by name from template, or fallback to current user + assignee_id = user_map.get(mgr_name, default_user_id) + + try: + customer = Customer( + name=name, industry=industry, address=address, + in_use_services=services, monthly_fee=fee, + remarks=remarks, + created_by=default_user_id, + ) + db.add(customer) + await db.flush() + + if contact_name: + db.add(CustomerContact(customer_id=customer.id, name=contact_name, phone=contact_phone, role_desc=contact_role)) + + db.add(CustomerAssignment( + customer_id=customer.id, manager_id=assignee_id, + role="primary", assigned_by=default_user_id, + )) + created += 1 + except Exception as e: + errors.append(f"第{row_idx}行: {str(e)}") + + await db.commit() + return {"created": created, "skipped": skipped, "errors": errors} + + +@router.get("/check-duplicate/{name}") +async def check_duplicate( + name: str, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Check for duplicate customer names before creating.""" + result = await db.execute( + select(Customer.id, Customer.name, Customer.industry) + .where(Customer.name.ilike(f"%{name}%")) + .limit(10) + ) + matches = [{"id": str(r.id), "name": r.name, "industry": r.industry} for r in result.all()] + return {"matches": matches} + + +@router.post("/batch-assign") +async def batch_assign( + data: BatchAssignRequest, + current_user: dict = Depends(require_director), + db: AsyncSession = Depends(get_db), +): + """Batch transfer customers to a new manager.""" + import uuid as uuid_mod + for cid in data.customer_ids: + result = await db.execute( + select(CustomerAssignment).where( + CustomerAssignment.customer_id == cid, + CustomerAssignment.role == "primary", + ) + ) + all_rows = result.all() + if all_rows: + # Update the first one, delete any duplicates + first = all_rows[0][0] + first.manager_id = data.manager_id + first.assigned_by = uuid_mod.UUID(current_user["user_id"]) + for dup in all_rows[1:]: + await db.delete(dup[0]) + else: + db.add(CustomerAssignment( + customer_id=cid, manager_id=data.manager_id, + role="primary", assigned_by=uuid_mod.UUID(current_user["user_id"]), + )) + await db.commit() + return {"detail": f"Assigned {len(data.customer_ids)} customers"} + + +@router.post("/quick-create") +async def quick_create_customer( + name: str = Query(...), + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + """Quick-create a customer with just a name. Returns the new customer.""" + import uuid + customer = Customer(name=name, created_by=uuid.UUID(current_user["user_id"])) + db.add(customer) + await db.flush() + db.add(CustomerAssignment( + customer_id=customer.id, manager_id=uuid.UUID(current_user["user_id"]), + role="primary", assigned_by=uuid.UUID(current_user["user_id"]), + )) + await db.commit() + return {"id": str(customer.id), "name": customer.name} + + +# ══════ Parameterized routes (/{customer_id}) ══════ + +@router.get("/{customer_id}", response_model=CustomerOut) +async def get_customer( + customer_id: str, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(Customer).where(Customer.id == customer_id).options(selectinload(Customer.contacts)) + ) + customer = result.scalar_one_or_none() + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + # Load primary manager name + from sqlalchemy.orm import selectinload as sl + assign_result = await db.execute( + select(CustomerAssignment, User.name).join(User, CustomerAssignment.manager_id == User.id) + .where(CustomerAssignment.customer_id == customer_id, CustomerAssignment.role == "primary") + ) + row = assign_result.first() + manager_name = row[1] if row else None + + # Attach to response via a dict + out = { + "id": customer.id, "name": customer.name, "industry": customer.industry, + "address": customer.address, "in_use_services": customer.in_use_services, + "monthly_fee": customer.monthly_fee, "remarks": customer.remarks or "", + "created_by": customer.created_by, + "created_at": customer.created_at, "updated_at": customer.updated_at, + "contacts": customer.contacts, "primary_manager_name": manager_name, + } + return out + + +@router.post("/", response_model=CustomerOut) +async def create_customer( + data: CustomerCreate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + """Create a new customer with optional contacts and manager assignment.""" + import uuid + customer = Customer( + name=data.name, industry=data.industry, address=data.address, + in_use_services=data.in_use_services, monthly_fee=data.monthly_fee, + remarks=data.remarks, + created_by=uuid.UUID(current_user["user_id"]), + ) + db.add(customer) + await db.flush() + + for contact_data in data.contacts: + if not contact_data.name.strip(): + continue + db.add(CustomerContact( + customer_id=customer.id, + name=contact_data.name.strip(), + phone=contact_data.phone.strip(), + role_desc=contact_data.role_desc.strip(), + )) + + assignee_id = data.assignee_id or uuid.UUID(current_user["user_id"]) + db.add(CustomerAssignment( + customer_id=customer.id, manager_id=assignee_id, + role="primary", assigned_by=uuid.UUID(current_user["user_id"]), + )) + await db.commit() + + result = await db.execute( + select(Customer).where(Customer.id == customer.id).options(selectinload(Customer.contacts)) + ) + return result.scalar_one() + + +@router.put("/{customer_id}", response_model=CustomerOut) +async def update_customer( + customer_id: str, data: CustomerUpdate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(Customer).where(Customer.id == customer_id).options(selectinload(Customer.contacts)) + ) + customer = result.scalar_one_or_none() + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + update_data = data.model_dump(exclude_unset=True) + assignee_id = update_data.pop("assignee_id", None) # Handle separately + + for key, value in update_data.items(): + setattr(customer, key, value) + + # Update primary manager assignment if changed (director only) + if assignee_id: + if current_user["role"] != "director": + raise HTTPException(status_code=403, detail="Only director can change manager assignment") + import uuid as uuid_mod + assign_result = await db.execute( + select(CustomerAssignment).where( + CustomerAssignment.customer_id == customer.id, + CustomerAssignment.role == "primary", + ) + ) + existing = assign_result.scalar_one_or_none() + if existing: + existing.manager_id = assignee_id + existing.assigned_by = uuid_mod.UUID(current_user["user_id"]) + else: + db.add(CustomerAssignment( + customer_id=customer.id, manager_id=assignee_id, + role="primary", assigned_by=uuid_mod.UUID(current_user["user_id"]), + )) + + await db.commit() + + result = await db.execute( + select(Customer).where(Customer.id == customer.id).options(selectinload(Customer.contacts)) + ) + return result.scalar_one() + + +@router.delete("/{customer_id}") +async def delete_customer( + customer_id: str, + current_user: dict = Depends(require_director), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Customer).where(Customer.id == customer_id)) + customer = result.scalar_one_or_none() + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + await db.delete(customer) + await db.commit() + return {"detail": "deleted"} + + +# ── Contacts ── + +@router.post("/{customer_id}/contacts", response_model=ContactOut) +async def add_contact( + customer_id: str, data: ContactCreate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + contact = CustomerContact(customer_id=customer_id, name=data.name, phone=data.phone, role_desc=data.role_desc) + db.add(contact) + await db.commit() + await db.refresh(contact) + return contact + + +@router.delete("/{customer_id}/contacts/{contact_id}") +async def delete_contact( + customer_id: str, contact_id: str, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(CustomerContact).where(CustomerContact.id == contact_id, CustomerContact.customer_id == customer_id) + ) + contact = result.scalar_one_or_none() + if not contact: + raise HTTPException(status_code=404, detail="Contact not found") + await db.delete(contact) + await db.commit() + return {"detail": "deleted"} + + +# ── Assignments ── + +@router.get("/{customer_id}/assignments", response_model=list[AssignmentOut]) +async def list_assignments( + customer_id: str, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(CustomerAssignment).where(CustomerAssignment.customer_id == customer_id)) + return result.scalars().all() + + +@router.post("/{customer_id}/assignments", response_model=AssignmentOut) +async def assign_manager( + customer_id: str, data: AssignmentCreate, + current_user: dict = Depends(require_director), + db: AsyncSession = Depends(get_db), +): + assignment = CustomerAssignment( + customer_id=customer_id, manager_id=data.manager_id, + role=data.role, assigned_by=current_user["user_id"], + ) + db.add(assignment) + await db.commit() + await db.refresh(assignment) + return assignment diff --git a/backend/app/api/daily_notes.py b/backend/app/api/daily_notes.py new file mode 100644 index 0000000..2f5df98 --- /dev/null +++ b/backend/app/api/daily_notes.py @@ -0,0 +1,131 @@ +import uuid +from datetime import date +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.database import get_db +from app.middleware.auth import get_current_user, require_any_role +from app.models.daily_note import DailyNote +from app.models.user import User +from app.schemas.daily_note import DailyNoteCreate, DailyNoteUpdate, DailyNoteOut +from app.utils.timezone import today_cst, parse_date + +router = APIRouter(prefix="/daily-notes", tags=["DailyNotes"]) + + +async def _enrich(note: DailyNote, db: AsyncSession) -> dict: + mgr = await db.execute(select(User.name).where(User.id == note.manager_id)) + return { + "id": note.id, "manager_id": note.manager_id, + "note_date": note.note_date, "category": note.category, + "content": note.content, "time_range": note.time_range, + "created_at": note.created_at, "updated_at": note.updated_at, + "manager_name": mgr.scalar_one_or_none(), + } + + +@router.get("/") +async def list_notes( + date_from: Optional[str] = Query(None), + date_to: Optional[str] = Query(None), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + query = select(DailyNote) + if current_user["role"] == "manager": + query = query.where(DailyNote.manager_id == uuid.UUID(current_user["user_id"])) + if date_from: + query = query.where(DailyNote.note_date >= parse_date(date_from)) + if date_to: + query = query.where(DailyNote.note_date <= parse_date(date_to)) + query = query.order_by(DailyNote.note_date.desc(), DailyNote.created_at.desc()).limit(100) + result = await db.execute(query) + return [await _enrich(n, db) for n in result.scalars().all()] + + +@router.get("/today") +async def list_today_notes( + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + query = select(DailyNote).where(DailyNote.note_date == today_cst()) + if current_user["role"] == "manager": + query = query.where(DailyNote.manager_id == uuid.UUID(current_user["user_id"])) + result = await db.execute(query) + notes = [await _enrich(n, db) for n in result.scalars().all()] + return {"count": len(notes), "notes": notes} + + +@router.get("/{note_id}") +async def get_note( + note_id: str, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(DailyNote).where(DailyNote.id == note_id)) + note = result.scalar_one_or_none() + if not note: + raise HTTPException(status_code=404, detail="Not found") + if current_user["role"] == "manager" and str(note.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + return await _enrich(note, db) + + +@router.post("/") +async def create_note( + data: DailyNoteCreate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + note = DailyNote( + manager_id=uuid.UUID(current_user["user_id"]), + note_date=parse_date(data.note_date), + category=data.category, + content=data.content, + time_range=data.time_range, + ) + db.add(note) + await db.commit() + await db.refresh(note) + return await _enrich(note, db) + + +@router.put("/{note_id}") +async def update_note( + note_id: str, data: DailyNoteUpdate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(DailyNote).where(DailyNote.id == note_id)) + note = result.scalar_one_or_none() + if not note: + raise HTTPException(status_code=404, detail="Not found") + if current_user["role"] == "manager" and str(note.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + + update_data = data.model_dump(exclude_unset=True) + if "note_date" in update_data and update_data["note_date"]: + update_data["note_date"] = parse_date(update_data["note_date"]) + for k, v in update_data.items(): + setattr(note, k, v) + await db.commit() + await db.refresh(note) + return await _enrich(note, db) + + +@router.delete("/{note_id}") +async def delete_note( + note_id: str, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(DailyNote).where(DailyNote.id == note_id)) + note = result.scalar_one_or_none() + if not note: + raise HTTPException(status_code=404, detail="Not found") + if current_user["role"] == "manager" and str(note.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + await db.delete(note) + await db.commit() + return {"detail": "deleted"} diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py new file mode 100644 index 0000000..bff1ec7 --- /dev/null +++ b/backend/app/api/dashboard.py @@ -0,0 +1,45 @@ +import uuid +from typing import Optional +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import get_db +from app.middleware.auth import get_current_user +from app.services.dashboard import get_dashboard_stats, get_reporting_progress, get_weekly_report + +router = APIRouter(prefix="/dashboard", tags=["Dashboard"]) + + +@router.get("/stats") +async def dashboard_stats( + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Get dashboard card statistics for the current week.""" + stats = await get_dashboard_stats(db) + return stats + + +@router.get("/progress") +async def reporting_progress( + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Get per-manager reporting progress.""" + return await get_reporting_progress(db) + + +@router.get("/weekly-report") +async def weekly_report( + manager_id: Optional[str] = Query(None), + customer_id: Optional[str] = Query(None), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Get full weekly report data (four modules).""" + return await get_weekly_report( + db=db, + user_id=uuid.UUID(current_user["user_id"]), + role=current_user["role"], + filter_manager_id=uuid.UUID(manager_id) if manager_id else None, + filter_customer_id=uuid.UUID(customer_id) if customer_id else None, + ) diff --git a/backend/app/api/export.py b/backend/app/api/export.py new file mode 100644 index 0000000..35ef63c --- /dev/null +++ b/backend/app/api/export.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, Depends +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import get_db +from app.middleware.auth import get_current_user, require_director_or_leader +from app.services.excel_export import export_weekly_report + +router = APIRouter(prefix="/export", tags=["Export"]) + + +@router.get("/weekly-report") +async def download_weekly_report( + current_user: dict = Depends(require_director_or_leader), + db: AsyncSession = Depends(get_db), +): + """Export this week's report as a 4-sheet .xlsx file.""" + excel_bytes = await export_weekly_report(db) + return StreamingResponse( + excel_bytes, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": "attachment; filename=weekly_report.xlsx"}, + ) diff --git a/backend/app/api/import_data.py b/backend/app/api/import_data.py new file mode 100644 index 0000000..e0ff7ad --- /dev/null +++ b/backend/app/api/import_data.py @@ -0,0 +1,43 @@ +import uuid +from fastapi import APIRouter, Depends, File, UploadFile, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import get_db +from app.middleware.auth import get_current_user, require_director +from app.services.excel_import import import_from_excel + +router = APIRouter(prefix="/import", tags=["Import"]) + + +@router.post("/weekly-report") +async def import_weekly_report( + file: UploadFile = File(...), + current_user: dict = Depends(require_director), + db: AsyncSession = Depends(get_db), +): + """Upload old weekly report Excel and import data. Only director can do this.""" + if not file.filename or not file.filename.endswith(('.xlsx', '.xls')): + raise HTTPException(status_code=400, detail="Only .xlsx and .xls files are supported") + + content = await file.read() + + # Preview first: parse headers + try: + import openpyxl + wb = openpyxl.load_workbook(io.BytesIO(content), data_only=True) + preview = {} + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + headers = [str(cell.value) for cell in ws[1]] + row_count = ws.max_row - 1 # minus header + preview[sheet_name] = {"headers": headers, "row_count": row_count} + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to parse Excel: {str(e)}") + + # Import data + stats = await import_from_excel(db, content, uuid.UUID(current_user["user_id"])) + stats["preview"] = preview + + return stats + + +import io diff --git a/backend/app/api/key_visits.py b/backend/app/api/key_visits.py new file mode 100644 index 0000000..2fed5d3 --- /dev/null +++ b/backend/app/api/key_visits.py @@ -0,0 +1,107 @@ +import uuid +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.database import get_db +from app.middleware.auth import get_current_user, require_any_role +from app.models.key_visit import KeyVisit +from app.models.customer import Customer +from app.models.user import User +from app.schemas.key_visit import KeyVisitCreate, KeyVisitUpdate, KeyVisitOut + +router = APIRouter(prefix="/key-visits", tags=["KeyVisits"]) + + +async def _enrich(k: KeyVisit, db: AsyncSession) -> dict: + cust = await db.execute(select(Customer.name).where(Customer.id == k.customer_id)) + mgr = await db.execute(select(User.name).where(User.id == k.manager_id)) + return { + "id": str(k.id), + "customer_id": str(k.customer_id), + "customer_name": cust.scalar_one_or_none(), + "urgency_level": k.urgency_level, + "description": k.description, + "progress_status": k.progress_status, + "planned_date": k.planned_date, + "planned_visitor": k.planned_visitor, + "visit_target": k.visit_target, + "manager_id": str(k.manager_id), + "manager_name": mgr.scalar_one_or_none(), + } + + +@router.get("/") +async def list_key_visits( + customer_id: Optional[str] = Query(None), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + query = select(KeyVisit) + if current_user["role"] == "manager": + query = query.where(KeyVisit.manager_id == uuid.UUID(current_user["user_id"])) + if customer_id: + query = query.where(KeyVisit.customer_id == uuid.UUID(customer_id)) + query = query.order_by(KeyVisit.planned_date.desc()).limit(200) + result = await db.execute(query) + return [await _enrich(k, db) for k in result.scalars().all()] + + +@router.post("/") +async def create_key_visit( + data: KeyVisitCreate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + k = KeyVisit( + customer_id=data.customer_id, + urgency_level=data.urgency_level, + description=data.description, + progress_status=data.progress_status, + planned_date=data.planned_date, + planned_visitor=data.planned_visitor, + visit_target=data.visit_target, + manager_id=uuid.UUID(current_user["user_id"]), + ) + db.add(k) + await db.commit() + await db.refresh(k) + return await _enrich(k, db) + + +@router.put("/{item_id}") +async def update_key_visit( + item_id: str, data: KeyVisitUpdate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(KeyVisit).where(KeyVisit.id == item_id)) + k = result.scalar_one_or_none() + if not k: + raise HTTPException(status_code=404, detail="Not found") + if current_user["role"] == "manager" and str(k.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + + update_data = data.model_dump(exclude_unset=True) + for key, v in update_data.items(): + setattr(k, key, v) + await db.commit() + await db.refresh(k) + return await _enrich(k, db) + + +@router.delete("/{item_id}") +async def delete_key_visit( + item_id: str, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(KeyVisit).where(KeyVisit.id == item_id)) + k = result.scalar_one_or_none() + if not k: + raise HTTPException(status_code=404, detail="Not found") + if current_user["role"] == "manager" and str(k.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + await db.delete(k) + await db.commit() + return {"detail": "deleted"} diff --git a/backend/app/api/mini_business.py b/backend/app/api/mini_business.py new file mode 100644 index 0000000..95c9c37 --- /dev/null +++ b/backend/app/api/mini_business.py @@ -0,0 +1,105 @@ +import uuid +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.database import get_db +from app.middleware.auth import get_current_user, require_any_role +from app.models.mini_business import MiniBusiness +from app.models.customer import Customer +from app.models.user import User +from app.schemas.mini_business import MiniBusinessCreate, MiniBusinessUpdate, MiniBusinessOut + +router = APIRouter(prefix="/mini-business", tags=["MiniBusiness"]) + + +async def _enrich(m: MiniBusiness, db: AsyncSession) -> dict: + cust = await db.execute(select(Customer.name).where(Customer.id == m.customer_id)) + mgr = await db.execute(select(User.name).where(User.id == m.manager_id)) + return { + "id": str(m.id), + "customer_id": str(m.customer_id), + "customer_name": cust.scalar_one_or_none(), + "product_type": m.product_type, + "amount": m.amount, + "follow_up_detail": m.follow_up_detail, + "status": m.status, + "manager_id": str(m.manager_id), + "manager_name": mgr.scalar_one_or_none(), + "expected_revenue_date": m.expected_revenue_date, + } + + +@router.get("/") +async def list_mini_business( + customer_id: Optional[str] = Query(None), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + query = select(MiniBusiness) + if current_user["role"] == "manager": + query = query.where(MiniBusiness.manager_id == uuid.UUID(current_user["user_id"])) + if customer_id: + query = query.where(MiniBusiness.customer_id == uuid.UUID(customer_id)) + query = query.order_by(MiniBusiness.expected_revenue_date.desc()).limit(200) + result = await db.execute(query) + return [await _enrich(m, db) for m in result.scalars().all()] + + +@router.post("/") +async def create_mini_business( + data: MiniBusinessCreate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + m = MiniBusiness( + customer_id=data.customer_id, + product_type=data.product_type, + amount=data.amount, + follow_up_detail=data.follow_up_detail, + status=data.status, + manager_id=uuid.UUID(current_user["user_id"]), + expected_revenue_date=data.expected_revenue_date, + ) + db.add(m) + await db.commit() + await db.refresh(m) + return await _enrich(m, db) + + +@router.put("/{item_id}") +async def update_mini_business( + item_id: str, data: MiniBusinessUpdate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(MiniBusiness).where(MiniBusiness.id == item_id)) + m = result.scalar_one_or_none() + if not m: + raise HTTPException(status_code=404, detail="Not found") + if current_user["role"] == "manager" and str(m.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + + update_data = data.model_dump(exclude_unset=True) + for k, v in update_data.items(): + setattr(m, k, v) + await db.commit() + await db.refresh(m) + return await _enrich(m, db) + + +@router.delete("/{item_id}") +async def delete_mini_business( + item_id: str, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(MiniBusiness).where(MiniBusiness.id == item_id)) + m = result.scalar_one_or_none() + if not m: + raise HTTPException(status_code=404, detail="Not found") + if current_user["role"] == "manager" and str(m.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + await db.delete(m) + await db.commit() + return {"detail": "deleted"} diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py new file mode 100644 index 0000000..c979403 --- /dev/null +++ b/backend/app/api/upload.py @@ -0,0 +1,36 @@ +import uuid +from fastapi import APIRouter, Depends +from app.middleware.auth import get_current_user, require_any_role +from app.services.minio_client import generate_presigned_upload_url, generate_presigned_download_url + +router = APIRouter(prefix="/upload", tags=["Upload"]) + + +@router.post("/presigned-url") +async def get_presigned_upload_url( + filename: str, + content_type: str = "image/jpeg", + current_user: dict = Depends(require_any_role), +): + """Get a presigned PUT URL for direct MinIO upload.""" + import datetime + today = datetime.date.today().isoformat() + user_id = current_user["user_id"][:8] + object_key = f"{today}/{user_id}/{uuid.uuid4()}.jpg" + + url = generate_presigned_upload_url(object_key) + + return { + "upload_url": url, + "object_key": object_key, + } + + +@router.get("/download-url") +async def get_presigned_download_url( + object_key: str, + current_user: dict = Depends(get_current_user), +): + """Get a presigned GET URL for viewing a photo (1 hour validity).""" + url = generate_presigned_download_url(object_key) + return {"download_url": url} diff --git a/backend/app/api/users.py b/backend/app/api/users.py new file mode 100644 index 0000000..346c97d --- /dev/null +++ b/backend/app/api/users.py @@ -0,0 +1,82 @@ +import uuid +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.database import get_db +from app.middleware.auth import get_current_user, require_director +from app.models.user import User +from app.schemas.customer import UserOut + +router = APIRouter(prefix="/users", tags=["Users"]) + + +class UpdateUserRoleRequest(BaseModel): + role: str # manager / director / leader + department: str = "" + + +@router.get("/", response_model=list[UserOut]) +async def list_users( + role: Optional[str] = Query(None), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """List all users. Used for companion selection, assignment, etc.""" + query = select(User) + if role: + query = query.where(User.role == role) + query = query.order_by(User.name) + result = await db.execute(query) + return result.scalars().all() + + +@router.get("/me") +async def get_current_user_info( + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Get current user's full profile.""" + result = await db.execute(select(User).where(User.id == uuid.UUID(current_user["user_id"]))) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + return { + "id": str(user.id), + "casdoor_id": user.casdoor_id, + "name": user.name, + "role": user.role, + "department": user.department, + "wecom_userid": user.wecom_userid, + } + + +@router.put("/{user_id}/role") +async def update_user_role( + user_id: str, + data: UpdateUserRoleRequest, + current_user: dict = Depends(require_director), + db: AsyncSession = Depends(get_db), +): + """Director updates a user's role. Only director can do this.""" + if data.role not in ("manager", "director", "leader"): + raise HTTPException(status_code=400, detail="Invalid role. Must be manager/director/leader") + + result = await db.execute(select(User).where(User.id == uuid.UUID(user_id))) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + user.role = data.role + if data.department: + user.department = data.department + await db.commit() + await db.refresh(user) + + return { + "id": str(user.id), + "name": user.name, + "role": user.role, + "department": user.department, + } diff --git a/backend/app/api/visits.py b/backend/app/api/visits.py new file mode 100644 index 0000000..5f09e10 --- /dev/null +++ b/backend/app/api/visits.py @@ -0,0 +1,209 @@ +import uuid +from datetime import date, datetime +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from app.database import get_db +from app.middleware.auth import get_current_user, require_any_role +from app.models.visit import Visit +from app.models.customer import Customer +from app.models.user import User +from app.schemas.visit import VisitCreate, VisitUpdate, VisitOut, VisitListOut +from app.utils.timezone import today_cst, parse_date +from app.services.minio_client import delete_objects + +router = APIRouter(prefix="/visits", tags=["Visits"]) + + +async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict: + """Enrich a visit record with customer/manager names.""" + customer_name = None + manager_name = None + if visit.customer_id: + cust_result = await db.execute(select(Customer.name).where(Customer.id == visit.customer_id)) + customer_name = cust_result.scalar_one_or_none() + if visit.manager_id: + mgr_result = await db.execute(select(User.name).where(User.id == visit.manager_id)) + manager_name = mgr_result.scalar_one_or_none() + + return { + "id": str(visit.id), + "customer_id": str(visit.customer_id), + "customer_name": customer_name, + "visit_date": visit.visit_date, + "visit_method": visit.visit_method, + "time_range": visit.time_range, + "communication_content": visit.communication_content, + "customer_demand": visit.customer_demand, + "companions": visit.companions, + "photos": visit.photos, + "manager_id": str(visit.manager_id), + "manager_name": manager_name, + "created_at": str(visit.created_at), + "updated_at": str(visit.updated_at), + } + + +@router.get("/") +async def list_visits( + date_from: Optional[str] = Query(None), + date_to: Optional[str] = Query(None), + customer_id: Optional[str] = Query(None), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """List visits. Managers see only their own, directors/leaders see all.""" + query = select(Visit) + + if current_user["role"] == "manager": + query = query.where(Visit.manager_id == uuid.UUID(current_user["user_id"])) + + if date_from: + query = query.where(Visit.visit_date >= parse_date(date_from)) + if date_to: + query = query.where(Visit.visit_date <= parse_date(date_to)) + if customer_id: + query = query.where(Visit.customer_id == uuid.UUID(customer_id)) + + query = query.order_by(Visit.visit_date.desc(), Visit.created_at.desc()).limit(200) + result = await db.execute(query) + visits = result.scalars().all() + + # Enrich + enriched = [] + for v in visits: + enriched.append(await _enrich_visit(v, db)) + return enriched + + +@router.get("/today") +async def list_today_visits( + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Get today's visits for the current user's mobile home screen.""" + query = select(Visit).where(Visit.visit_date == today_cst()) + + if current_user["role"] == "manager": + query = query.where(Visit.manager_id == uuid.UUID(current_user["user_id"])) + + query = query.order_by(Visit.created_at.desc()) + result = await db.execute(query) + visits = result.scalars().all() + + enriched = [] + for v in visits: + enriched.append(await _enrich_visit(v, db)) + + count = len(enriched) + return {"count": count, "visits": enriched} + + +@router.get("/{visit_id}") +async def get_visit( + visit_id: str, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Visit).where(Visit.id == visit_id)) + visit = result.scalar_one_or_none() + if not visit: + raise HTTPException(status_code=404, detail="Visit not found") + + # Permission check + if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + + return await _enrich_visit(visit, db) + + +@router.post("/") +async def create_visit( + data: VisitCreate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + """Create a visit record. If companions are selected, creates draft copies for them.""" + visit = Visit( + customer_id=data.customer_id, + visit_date=parse_date(data.visit_date), + visit_method=data.visit_method, + time_range=data.time_range, + communication_content=data.communication_content, + customer_demand=data.customer_demand, + companions=data.companions, + photos=data.photos, + manager_id=uuid.UUID(current_user["user_id"]), + ) + db.add(visit) + + # Create draft copies for companions + for companion_id in data.companions: + if companion_id != uuid.UUID(current_user["user_id"]): + draft = Visit( + customer_id=data.customer_id, + visit_date=parse_date(data.visit_date), + visit_method=data.visit_method, + time_range=data.time_range, + communication_content="", # Leave blank for companion to fill + customer_demand="", + companions=[], + photos=[], + manager_id=companion_id, + ) + db.add(draft) + + await db.commit() + await db.refresh(visit) + return await _enrich_visit(visit, db) + + +@router.put("/{visit_id}") +async def update_visit( + visit_id: str, + data: VisitUpdate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Visit).where(Visit.id == visit_id)) + visit = result.scalar_one_or_none() + if not visit: + raise HTTPException(status_code=404, detail="Visit not found") + + if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + + update_data = data.model_dump(exclude_unset=True) + if "visit_date" in update_data and update_data["visit_date"]: + update_data["visit_date"] = parse_date(update_data["visit_date"]) + + for key, value in update_data.items(): + setattr(visit, key, value) + + await db.commit() + await db.refresh(visit) + return await _enrich_visit(visit, db) + + +@router.delete("/{visit_id}") +async def delete_visit( + visit_id: str, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Visit).where(Visit.id == visit_id)) + visit = result.scalar_one_or_none() + if not visit: + raise HTTPException(status_code=404, detail="Visit not found") + + if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + + # Clean up photos in MinIO + if visit.photos: + delete_objects(visit.photos) + + await db.delete(visit) + await db.commit() + return {"detail": "deleted"} diff --git a/backend/app/api/wecom.py b/backend/app/api/wecom.py new file mode 100644 index 0000000..5f03204 --- /dev/null +++ b/backend/app/api/wecom.py @@ -0,0 +1,68 @@ +import uuid +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.database import get_db +from app.middleware.auth import get_current_user, require_director +from app.models.user import User +from app.services.wecom import wecom_client +from app.services.scheduler import check_daily_reporting + +router = APIRouter(prefix="/wecom", tags=["WeChatWork"]) + + +class RemindRequest(BaseModel): + user_ids: list[str] + message: Optional[str] = None + + +class AnnouncementRequest(BaseModel): + content: str + + +@router.post("/remind") +async def send_reminder( + data: RemindRequest, + current_user: dict = Depends(require_director), + db: AsyncSession = Depends(get_db), +): + """Director manually sends reminder to specific managers.""" + # Get wecom_userids for the selected users + result = await db.execute( + select(User.wecom_userid).where(User.id.in_([uuid.UUID(uid) for uid in data.user_ids])) + ) + wecom_ids = [r[0] for r in result.all() if r[0]] + + content = data.message or "📋 请及时完成今日拜访记录填报。" + success = await wecom_client.send_text_message(wecom_ids, content) + + return {"success": success, "sent_to": len(wecom_ids)} + + +@router.post("/announcement") +async def send_announcement( + data: AnnouncementRequest, + current_user: dict = Depends(require_director), + db: AsyncSession = Depends(get_db), +): + """Director sends an announcement to all team members.""" + # Get all wecom_userids in the department + result = await db.execute(select(User.wecom_userid).where(User.wecom_userid.isnot(None))) + wecom_ids = [r[0] for r in result.all()] + + content = f"📢 支局长公告\n\n{data.content}" + success = await wecom_client.send_text_message(wecom_ids, content) + + return {"success": success, "sent_to": len(wecom_ids)} + + +@router.post("/trigger-daily-check") +async def trigger_daily_check( + current_user: dict = Depends(require_director), + db: AsyncSession = Depends(get_db), +): + """Manually trigger the daily reporting check (for testing or manual use).""" + result = await check_daily_reporting(db) + return result diff --git a/backend/app/api/work_plans.py b/backend/app/api/work_plans.py new file mode 100644 index 0000000..386b0ea --- /dev/null +++ b/backend/app/api/work_plans.py @@ -0,0 +1,105 @@ +import uuid +from datetime import date +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.database import get_db +from app.middleware.auth import get_current_user, require_any_role +from app.utils.timezone import parse_date +from app.models.work_plan import WorkPlan +from app.models.customer import Customer +from app.models.user import User +from app.schemas.work_plan import WorkPlanCreate, WorkPlanUpdate, WorkPlanOut + +router = APIRouter(prefix="/work-plans", tags=["WorkPlans"]) + + +async def _enrich(wp: WorkPlan, db: AsyncSession) -> dict: + cust = await db.execute(select(Customer.name).where(Customer.id == wp.customer_id)) + mgr = await db.execute(select(User.name).where(User.id == wp.manager_id)) + return { + "id": str(wp.id), + "customer_id": str(wp.customer_id), + "customer_name": cust.scalar_one_or_none(), + "plan_content": wp.plan_content, + "plan_date": wp.plan_date, + "manager_id": str(wp.manager_id), + "manager_name": mgr.scalar_one_or_none(), + "status": wp.status, + } + + +@router.get("/") +async def list_work_plans( + customer_id: Optional[str] = Query(None), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + query = select(WorkPlan) + if current_user["role"] == "manager": + query = query.where(WorkPlan.manager_id == uuid.UUID(current_user["user_id"])) + if customer_id: + query = query.where(WorkPlan.customer_id == uuid.UUID(customer_id)) + query = query.order_by(WorkPlan.plan_date.desc()).limit(200) + result = await db.execute(query) + return [await _enrich(w, db) for w in result.scalars().all()] + + +@router.post("/") +async def create_work_plan( + data: WorkPlanCreate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + wp = WorkPlan( + customer_id=data.customer_id, + plan_content=data.plan_content, + plan_date=parse_date(data.plan_date), + manager_id=uuid.UUID(current_user["user_id"]), + status=data.status, + ) + db.add(wp) + await db.commit() + await db.refresh(wp) + return await _enrich(wp, db) + + +@router.put("/{plan_id}") +async def update_work_plan( + plan_id: str, data: WorkPlanUpdate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(WorkPlan).where(WorkPlan.id == plan_id)) + wp = result.scalar_one_or_none() + if not wp: + raise HTTPException(status_code=404, detail="Not found") + if current_user["role"] == "manager" and str(wp.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + + update_data = data.model_dump(exclude_unset=True) + if "plan_date" in update_data and update_data["plan_date"]: + update_data["plan_date"] = parse_date(update_data["plan_date"]) + for k, v in update_data.items(): + setattr(wp, k, v) + await db.commit() + await db.refresh(wp) + return await _enrich(wp, db) + + +@router.delete("/{plan_id}") +async def delete_work_plan( + plan_id: str, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(WorkPlan).where(WorkPlan.id == plan_id)) + wp = result.scalar_one_or_none() + if not wp: + raise HTTPException(status_code=404, detail="Not found") + if current_user["role"] == "manager" and str(wp.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + await db.delete(wp) + await db.commit() + return {"detail": "deleted"} diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..76b7398 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,48 @@ +from pydantic_settings import BaseSettings +from typing import Optional + + +class Settings(BaseSettings): + # App + APP_NAME: str = "企迹-政企周报管理系统" + DEBUG: bool = True + SECRET_KEY: str = "change-me-in-production" + + # Database + DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/qiji" + + # JWT + JWT_ALGORITHM: str = "HS256" + JWT_EXPIRE_MINUTES: int = 480 + + # Casdoor + CASDOOR_ENDPOINT: str = "http://localhost:8001" + CASDOOR_CLIENT_ID: str = "" + CASDOOR_CLIENT_SECRET: str = "" + CASDOOR_CERTIFICATE: Optional[str] = None + CASDOOR_ORG_NAME: str = "qiji" + CASDOOR_APPLICATION: str = "qiji-weekly-report" + + # MinIO + MINIO_ENDPOINT: str = "localhost:9000" + MINIO_ACCESS_KEY: str = "minioadmin" + MINIO_SECRET_KEY: str = "minioadmin" + MINIO_BUCKET: str = "qiji-photos" + MINIO_SECURE: bool = False + + # WeChat Work + WECOM_CORP_ID: str = "" + WECOM_AGENT_ID: str = "" + WECOM_SECRET: str = "" + WECOM_TOKEN: str = "" + WECOM_ENCODING_AES_KEY: str = "" + + # CORS + CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"] + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + +settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..086add2 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,19 @@ +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from sqlalchemy.orm import DeclarativeBase +from app.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: + try: + yield session + finally: + await session.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..72527bd --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,58 @@ +from contextlib import asynccontextmanager +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.config import settings +from app.database import engine, Base +from app.api import router as api_router +from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits +from app.api import dashboard, upload, export, import_data, wecom, daily_notes + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup: create tables if not exists (for dev convenience) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + # Add columns that may be missing from older tables + await conn.run_sync(lambda c: c.exec_driver_sql( + "ALTER TABLE customers ADD COLUMN IF NOT EXISTS remarks TEXT DEFAULT ''" + )) + yield + # Shutdown + await engine.dispose() + + +app = FastAPI( + title=settings.APP_NAME, + version="0.1.0", + lifespan=lifespan, +) + +# CORS +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Mount all routers +app.include_router(auth.router, prefix="/api") +app.include_router(users.router, prefix="/api") +app.include_router(customers.router, prefix="/api") +app.include_router(visits.router, prefix="/api") +app.include_router(work_plans.router, prefix="/api") +app.include_router(mini_business.router, prefix="/api") +app.include_router(key_visits.router, prefix="/api") +app.include_router(dashboard.router, prefix="/api") +app.include_router(upload.router, prefix="/api") +app.include_router(export.router, prefix="/api") +app.include_router(import_data.router, prefix="/api") +app.include_router(wecom.router, prefix="/api") +app.include_router(daily_notes.router, prefix="/api") + + +@app.get("/health") +async def health_check(): + return {"status": "ok", "app": settings.APP_NAME} diff --git a/backend/app/middleware/__init__.py b/backend/app/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/middleware/auth.py b/backend/app/middleware/auth.py new file mode 100644 index 0000000..9be0dbf --- /dev/null +++ b/backend/app/middleware/auth.py @@ -0,0 +1,31 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from app.utils.security import decode_token + +bearer_scheme = HTTPBearer() + + +async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme)) -> dict: + payload = decode_token(credentials.credentials) + if payload is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token") + return payload + + +class RoleChecker: + def __init__(self, allowed_roles: list[str]): + self.allowed_roles = allowed_roles + + async def __call__(self, current_user: dict = Depends(get_current_user)) -> dict: + role = current_user.get("role", "") + if role not in self.allowed_roles: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") + return current_user + + +# Pre-built checkers +require_manager = RoleChecker(["manager"]) +require_director = RoleChecker(["director"]) +require_leader = RoleChecker(["leader"]) +require_director_or_leader = RoleChecker(["director", "leader"]) +require_any_role = RoleChecker(["manager", "director", "leader"]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..d5632f1 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,21 @@ +from app.models.user import User +from app.models.customer import Customer +from app.models.customer_contact import CustomerContact +from app.models.customer_assignment import CustomerAssignment +from app.models.visit import Visit +from app.models.work_plan import WorkPlan +from app.models.mini_business import MiniBusiness +from app.models.key_visit import KeyVisit +from app.models.daily_note import DailyNote + +__all__ = [ + "User", + "Customer", + "CustomerContact", + "CustomerAssignment", + "Visit", + "WorkPlan", + "MiniBusiness", + "KeyVisit", + "DailyNote", +] diff --git a/backend/app/models/customer.py b/backend/app/models/customer.py new file mode 100644 index 0000000..16880d3 --- /dev/null +++ b/backend/app/models/customer.py @@ -0,0 +1,24 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, Text, DateTime, func, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID +from app.database import Base + + +class Customer(Base): + __tablename__ = "customers" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String(200), index=True) + industry: Mapped[str] = mapped_column(String(100), default="") + address: Mapped[str] = mapped_column(String(500), default="") + in_use_services: Mapped[str] = mapped_column(Text, default="") + monthly_fee: Mapped[str] = mapped_column(String(100), default="") + remarks: Mapped[str] = mapped_column(Text, default="") + created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id")) + 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()) + + contacts: Mapped[list["CustomerContact"]] = relationship("CustomerContact", back_populates="customer", cascade="all, delete-orphan") + assignments: Mapped[list["CustomerAssignment"]] = relationship("CustomerAssignment", back_populates="customer", cascade="all, delete-orphan") diff --git a/backend/app/models/customer_assignment.py b/backend/app/models/customer_assignment.py new file mode 100644 index 0000000..33cda2a --- /dev/null +++ b/backend/app/models/customer_assignment.py @@ -0,0 +1,20 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, DateTime, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID +from app.database import Base + + +class CustomerAssignment(Base): + __tablename__ = "customer_assignments" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + customer_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("customers.id"), index=True) + manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id")) + role: Mapped[str] = mapped_column(String(20), default="primary") # 'primary' / 'assistant' + assigned_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + assigned_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id")) + + customer: Mapped["Customer"] = relationship("Customer", back_populates="assignments") + manager: Mapped["User"] = relationship("User", foreign_keys=[manager_id]) diff --git a/backend/app/models/customer_contact.py b/backend/app/models/customer_contact.py new file mode 100644 index 0000000..382094f --- /dev/null +++ b/backend/app/models/customer_contact.py @@ -0,0 +1,17 @@ +import uuid +from sqlalchemy import String, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID +from app.database import Base + + +class CustomerContact(Base): + __tablename__ = "customer_contacts" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + customer_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("customers.id"), index=True) + name: Mapped[str] = mapped_column(String(50)) + phone: Mapped[str] = mapped_column(String(20), default="") + role_desc: Mapped[str] = mapped_column(String(100), default="") + + customer: Mapped["Customer"] = relationship("Customer", back_populates="contacts") diff --git a/backend/app/models/daily_note.py b/backend/app/models/daily_note.py new file mode 100644 index 0000000..96a1ca7 --- /dev/null +++ b/backend/app/models/daily_note.py @@ -0,0 +1,19 @@ +import uuid +from datetime import date, datetime +from sqlalchemy import String, Text, Date, DateTime, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID +from app.database import Base + + +class DailyNote(Base): + __tablename__ = "daily_notes" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) + note_date: Mapped[date] = mapped_column(Date, index=True) + category: Mapped[str] = mapped_column(String(20), default="其他") # 行政事务/合同整理/发票处理/内部会议/培训学习/其他 + content: Mapped[str] = mapped_column(Text, default="") + time_range: Mapped[str] = mapped_column(String(30), 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()) diff --git a/backend/app/models/key_visit.py b/backend/app/models/key_visit.py new file mode 100644 index 0000000..b864aef --- /dev/null +++ b/backend/app/models/key_visit.py @@ -0,0 +1,19 @@ +import uuid +from sqlalchemy import String, Text, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID +from app.database import Base + + +class KeyVisit(Base): + __tablename__ = "key_visits" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + customer_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("customers.id"), index=True) + urgency_level: Mapped[str] = mapped_column(String(10), default="一般") # 重要/一般/紧急 + description: Mapped[str] = mapped_column(Text, default="") + progress_status: Mapped[str] = mapped_column(String(20), default="未开始") + planned_date: Mapped[str] = mapped_column(String(50), default="") + planned_visitor: Mapped[str] = mapped_column(String(100), default="") + visit_target: Mapped[str] = mapped_column(String(100), default="") + manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) diff --git a/backend/app/models/mini_business.py b/backend/app/models/mini_business.py new file mode 100644 index 0000000..d9e6b8a --- /dev/null +++ b/backend/app/models/mini_business.py @@ -0,0 +1,18 @@ +import uuid +from sqlalchemy import String, Text, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID +from app.database import Base + + +class MiniBusiness(Base): + __tablename__ = "mini_business" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + customer_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("customers.id"), index=True) + product_type: Mapped[str] = mapped_column(String(200), default="") + amount: Mapped[str] = mapped_column(String(100), default="") + follow_up_detail: Mapped[str] = mapped_column(Text, default="") + status: Mapped[str] = mapped_column(String(50), default="跟进中") + manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) + expected_revenue_date: Mapped[str] = mapped_column(String(50), default="") diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..8cafad7 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,18 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, DateTime, func +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID +from app.database import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + casdoor_id: Mapped[str] = mapped_column(String(100), unique=True, index=True) + name: Mapped[str] = mapped_column(String(50)) + role: Mapped[str] = mapped_column(String(30)) # manager / director / leader + department: Mapped[str] = mapped_column(String(100), default="") + wecom_userid: Mapped[str | None] = mapped_column(String(100), unique=True, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/visit.py b/backend/app/models/visit.py new file mode 100644 index 0000000..d0af6e8 --- /dev/null +++ b/backend/app/models/visit.py @@ -0,0 +1,23 @@ +import uuid +from datetime import datetime, date +from sqlalchemy import String, Text, Date, DateTime, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID, ARRAY +from app.database import Base + + +class Visit(Base): + __tablename__ = "visits" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + customer_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("customers.id"), index=True) + visit_date: Mapped[date] = mapped_column(Date, index=True) + visit_method: Mapped[str] = mapped_column(String(20), default="上门") # 上门/电话/微信/出差 + time_range: Mapped[str] = mapped_column(String(30), default="") + communication_content: Mapped[str] = mapped_column(Text, default="") + customer_demand: Mapped[str] = mapped_column(Text, default="") + companions: Mapped[list | None] = mapped_column(ARRAY(UUID(as_uuid=True)), nullable=True) + photos: Mapped[list | None] = mapped_column(ARRAY(Text), nullable=True) + manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), 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()) diff --git a/backend/app/models/work_plan.py b/backend/app/models/work_plan.py new file mode 100644 index 0000000..883f2ba --- /dev/null +++ b/backend/app/models/work_plan.py @@ -0,0 +1,17 @@ +import uuid +from datetime import date +from sqlalchemy import String, Text, Date, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID +from app.database import Base + + +class WorkPlan(Base): + __tablename__ = "work_plans" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + customer_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("customers.id"), index=True) + plan_content: Mapped[str] = mapped_column(Text, default="") + plan_date: Mapped[date] = mapped_column(Date, index=True) + manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) + status: Mapped[str] = mapped_column(String(20), default="计划中") # 计划中/已完成/已取消 diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..bd71473 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,10 @@ +from app.schemas.customer import ( + CustomerCreate, CustomerUpdate, CustomerOut, CustomerListOut, + ContactCreate, ContactOut, AssignmentCreate, AssignmentOut, BatchAssignRequest, +) +from app.schemas.visit import VisitCreate, VisitUpdate, VisitOut, VisitListOut +from app.schemas.work_plan import WorkPlanCreate, WorkPlanUpdate, WorkPlanOut +from app.schemas.mini_business import MiniBusinessCreate, MiniBusinessUpdate, MiniBusinessOut +from app.schemas.key_visit import KeyVisitCreate, KeyVisitUpdate, KeyVisitOut +from app.schemas.user import TokenResponse, WecomLoginRequest, CasdoorLoginRequest, WecomBindRequest +from app.schemas.customer import UserCreate, UserOut diff --git a/backend/app/schemas/customer.py b/backend/app/schemas/customer.py new file mode 100644 index 0000000..2f096ef --- /dev/null +++ b/backend/app/schemas/customer.py @@ -0,0 +1,118 @@ +from pydantic import BaseModel, Field +from typing import Optional +import uuid +from datetime import date, datetime + + +# ── User ── +class UserCreate(BaseModel): + name: str + role: str = "manager" + department: str = "" + wecom_userid: Optional[str] = None + + +class UserOut(BaseModel): + id: uuid.UUID + casdoor_id: str + name: str + role: str + department: str + wecom_userid: Optional[str] = None + + model_config = {"from_attributes": True} + + +# ── CustomerContact ── +class ContactCreate(BaseModel): + name: str + phone: str = "" + role_desc: str = "" + + +class ContactOut(BaseModel): + id: uuid.UUID + customer_id: uuid.UUID + name: str + phone: str + role_desc: str + + model_config = {"from_attributes": True} + + +# ── Customer ── +class CustomerCreate(BaseModel): + name: str + industry: str = "" + address: str = "" + in_use_services: str = "" + monthly_fee: str = "" + remarks: str = "" + contacts: list[ContactCreate] = [] + assignee_id: Optional[uuid.UUID] = None + + +class CustomerUpdate(BaseModel): + name: Optional[str] = None + industry: Optional[str] = None + address: Optional[str] = None + in_use_services: Optional[str] = None + monthly_fee: Optional[str] = None + remarks: Optional[str] = None + assignee_id: Optional[uuid.UUID] = None + + +class CustomerOut(BaseModel): + id: uuid.UUID + name: str + industry: str + address: str + in_use_services: str + monthly_fee: str + remarks: str = "" + created_by: uuid.UUID + created_at: datetime + updated_at: datetime + contacts: list[ContactOut] = [] + primary_manager_name: Optional[str] = None + + model_config = {"from_attributes": True} + + +class CustomerListOut(BaseModel): + id: uuid.UUID + name: str + industry: str + in_use_services: str + + model_config = {"from_attributes": True} + + +class CustomerListResponse(BaseModel): + items: list[CustomerListOut] + total: int + page: int + page_size: int + + +# ── CustomerAssignment ── +class AssignmentCreate(BaseModel): + customer_id: uuid.UUID + manager_id: uuid.UUID + role: str = "primary" + + +class AssignmentOut(BaseModel): + id: uuid.UUID + customer_id: uuid.UUID + manager_id: uuid.UUID + role: str + assigned_at: datetime + assigned_by: uuid.UUID + + model_config = {"from_attributes": True} + + +class BatchAssignRequest(BaseModel): + customer_ids: list[uuid.UUID] + manager_id: uuid.UUID diff --git a/backend/app/schemas/daily_note.py b/backend/app/schemas/daily_note.py new file mode 100644 index 0000000..1664ada --- /dev/null +++ b/backend/app/schemas/daily_note.py @@ -0,0 +1,32 @@ +from pydantic import BaseModel +from typing import Optional +import uuid +from datetime import date, datetime + + +class DailyNoteCreate(BaseModel): + note_date: str # YYYY-MM-DD + category: str = "其他" + content: str = "" + time_range: str = "" + + +class DailyNoteUpdate(BaseModel): + note_date: Optional[str] = None + category: Optional[str] = None + content: Optional[str] = None + time_range: Optional[str] = None + + +class DailyNoteOut(BaseModel): + id: uuid.UUID + manager_id: uuid.UUID + note_date: date + category: str + content: str + time_range: str + created_at: datetime + updated_at: datetime + manager_name: Optional[str] = None + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/key_visit.py b/backend/app/schemas/key_visit.py new file mode 100644 index 0000000..c7d7454 --- /dev/null +++ b/backend/app/schemas/key_visit.py @@ -0,0 +1,39 @@ +from pydantic import BaseModel +from typing import Optional +import uuid + + +class KeyVisitCreate(BaseModel): + customer_id: uuid.UUID + urgency_level: str = "一般" # 重要/一般/紧急 + description: str = "" + progress_status: str = "未开始" + planned_date: str = "" + planned_visitor: str = "" + visit_target: str = "" + + +class KeyVisitUpdate(BaseModel): + customer_id: Optional[uuid.UUID] = None + urgency_level: Optional[str] = None + description: Optional[str] = None + progress_status: Optional[str] = None + planned_date: Optional[str] = None + planned_visitor: Optional[str] = None + visit_target: Optional[str] = None + + +class KeyVisitOut(BaseModel): + id: uuid.UUID + customer_id: uuid.UUID + urgency_level: str + description: str + progress_status: str + planned_date: str + planned_visitor: str + visit_target: str + manager_id: uuid.UUID + customer_name: Optional[str] = None + manager_name: Optional[str] = None + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/mini_business.py b/backend/app/schemas/mini_business.py new file mode 100644 index 0000000..a0719aa --- /dev/null +++ b/backend/app/schemas/mini_business.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel +from typing import Optional +import uuid + + +class MiniBusinessCreate(BaseModel): + customer_id: uuid.UUID + product_type: str = "" + amount: str = "" + follow_up_detail: str = "" + status: str = "跟进中" + expected_revenue_date: str = "" + + +class MiniBusinessUpdate(BaseModel): + customer_id: Optional[uuid.UUID] = None + product_type: Optional[str] = None + amount: Optional[str] = None + follow_up_detail: Optional[str] = None + status: Optional[str] = None + expected_revenue_date: Optional[str] = None + + +class MiniBusinessOut(BaseModel): + id: uuid.UUID + customer_id: uuid.UUID + product_type: str + amount: str + follow_up_detail: str + status: str + manager_id: uuid.UUID + expected_revenue_date: str + customer_name: Optional[str] = None + manager_name: Optional[str] = None + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..0803bd8 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel +from typing import Optional +import uuid + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user_id: str + name: str + role: str + + +class WecomLoginRequest(BaseModel): + code: str + + +class CasdoorLoginRequest(BaseModel): + code: str + state: str + + +class WecomBindRequest(BaseModel): + casdoor_code: str + wecom_userid: Optional[str] = None diff --git a/backend/app/schemas/visit.py b/backend/app/schemas/visit.py new file mode 100644 index 0000000..dd18d89 --- /dev/null +++ b/backend/app/schemas/visit.py @@ -0,0 +1,60 @@ +from pydantic import BaseModel, Field +from typing import Optional +import uuid +from datetime import date, datetime + + +class VisitCreate(BaseModel): + customer_id: uuid.UUID + visit_date: str # "YYYY-MM-DD" + visit_method: str = "上门" + time_range: str = "" + communication_content: str = "" + customer_demand: str = "" + companions: list[uuid.UUID] = [] + photos: list[str] = [] + + +class VisitUpdate(BaseModel): + customer_id: Optional[uuid.UUID] = None + visit_date: Optional[str] = None + visit_method: Optional[str] = None + time_range: Optional[str] = None + communication_content: Optional[str] = None + customer_demand: Optional[str] = None + companions: Optional[list[uuid.UUID]] = None + photos: Optional[list[str]] = None + + +class VisitOut(BaseModel): + id: uuid.UUID + customer_id: uuid.UUID + visit_date: date + visit_method: str + time_range: str + communication_content: str + customer_demand: str + companions: Optional[list[uuid.UUID]] = None + photos: Optional[list[str]] = None + manager_id: uuid.UUID + created_at: datetime + updated_at: datetime + # Joined fields + customer_name: Optional[str] = None + manager_name: Optional[str] = None + + model_config = {"from_attributes": True} + + +class VisitListOut(BaseModel): + id: uuid.UUID + customer_id: uuid.UUID + visit_date: date + visit_method: str + time_range: str + manager_id: uuid.UUID + customer_name: Optional[str] = None + manager_name: Optional[str] = None + has_photos: bool = False + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/work_plan.py b/backend/app/schemas/work_plan.py new file mode 100644 index 0000000..1816798 --- /dev/null +++ b/backend/app/schemas/work_plan.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel +from typing import Optional +import uuid +from datetime import date + + +class WorkPlanCreate(BaseModel): + customer_id: uuid.UUID + plan_content: str = "" + plan_date: str # "YYYY-MM-DD" + status: str = "计划中" + + +class WorkPlanUpdate(BaseModel): + customer_id: Optional[uuid.UUID] = None + plan_content: Optional[str] = None + plan_date: Optional[str] = None + status: Optional[str] = None + + +class WorkPlanOut(BaseModel): + id: uuid.UUID + customer_id: uuid.UUID + plan_content: str + plan_date: date + manager_id: uuid.UUID + status: str + customer_name: Optional[str] = None + manager_name: Optional[str] = None + + model_config = {"from_attributes": True} diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 0000000..f0372e1 --- /dev/null +++ b/backend/app/services/auth.py @@ -0,0 +1,106 @@ +from uuid import uuid4 +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +import httpx +from app.config import settings +from app.models.user import User +from app.utils.security import create_access_token + + +async def get_or_create_user_from_casdoor( + db: AsyncSession, casdoor_id: str, name: str, + role: str = "manager", department: str = "" +) -> User: + """Find existing user by casdoor_id, or create a new one.""" + result = await db.execute(select(User).where(User.casdoor_id == casdoor_id)) + user = result.scalar_one_or_none() + if user is None: + user = User( + casdoor_id=casdoor_id, + name=name, + role=role, + department=department, + ) + db.add(user) + await db.commit() + await db.refresh(user) + else: + # Update name/department if changed + if user.name != name or user.department != department: + user.name = name + user.department = department + await db.commit() + await db.refresh(user) + return user + + +async def exchange_casdoor_code(code: str) -> dict | None: + """Exchange Casdoor OIDC authorization code for user info.""" + import logging + logger = logging.getLogger("uvicorn") + + token_url = f"{settings.CASDOOR_ENDPOINT}/api/login/oauth/access_token" + async with httpx.AsyncClient() as client: + try: + resp = await client.post(token_url, data={ + "grant_type": "authorization_code", + "client_id": settings.CASDOOR_CLIENT_ID, + "client_secret": settings.CASDOOR_CLIENT_SECRET, + "code": code, + }, timeout=10) + if resp.status_code != 200: + logger.error(f"[Casdoor] token exchange failed: status={resp.status_code}, body={resp.text[:500]}") + return None + token_data = resp.json() + access_token = token_data.get("access_token", "") + if not access_token: + logger.error(f"[Casdoor] no access_token in response: {token_data}") + return None + except Exception as e: + logger.error(f"[Casdoor] token exchange exception: {e}") + return None + + userinfo_url = f"{settings.CASDOOR_ENDPOINT}/api/userinfo" + async with httpx.AsyncClient() as client: + try: + resp = await client.get(userinfo_url, headers={ + "Authorization": f"Bearer {access_token}" + }, timeout=10) + if resp.status_code != 200: + logger.error(f"[Casdoor] userinfo failed: status={resp.status_code}, body={resp.text[:500]}") + return None + return resp.json() + except Exception as e: + logger.error(f"[Casdoor] userinfo exception: {e}") + return None + return None + + +async def bind_wecom_user(db: AsyncSession, casdoor_id: str, wecom_userid: str) -> User | None: + """Bind a WeChat Work userid to a Casdoor user.""" + result = await db.execute(select(User).where(User.casdoor_id == casdoor_id)) + user = result.scalar_one_or_none() + if user is None: + return None + user.wecom_userid = wecom_userid + await db.commit() + await db.refresh(user) + return user + + +async def get_user_by_wecom_id(db: AsyncSession, wecom_userid: str) -> User | None: + """Find user by wecom_userid.""" + result = await db.execute(select(User).where(User.wecom_userid == wecom_userid)) + return result.scalar_one_or_none() + + +def build_token_for_user(user: User) -> str: + """Build a JWT token for the given user.""" + return create_access_token(data={ + "sub": str(user.id), + "user_id": str(user.id), + "casdoor_id": user.casdoor_id, + "name": user.name, + "role": user.role, + "department": user.department, + }) diff --git a/backend/app/services/dashboard.py b/backend/app/services/dashboard.py new file mode 100644 index 0000000..aa69bf1 --- /dev/null +++ b/backend/app/services/dashboard.py @@ -0,0 +1,253 @@ +from datetime import date, timedelta +from uuid import UUID +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased +from app.models.customer import Customer +from app.models.user import User +from app.models.visit import Visit +from app.models.work_plan import WorkPlan +from app.models.mini_business import MiniBusiness +from app.models.key_visit import KeyVisit +from app.models.daily_note import DailyNote +from app.utils.timezone import today_cst + + +def get_week_range(reference_date: date | None = None): + today = reference_date or date.today() + monday = today - timedelta(days=today.weekday()) + sunday = monday + timedelta(days=6) + return monday, sunday + + +async def get_dashboard_stats(db: AsyncSession) -> dict: + """Get dashboard statistics for the current week.""" + monday, sunday = get_week_range() + today = date.today() + + visits_count = (await db.execute( + select(func.count(Visit.id)).where(Visit.visit_date >= monday, Visit.visit_date <= sunday) + )).scalar() or 0 + + plans_count = (await db.execute( + select(func.count(WorkPlan.id)) + )).scalar() or 0 + + mini_biz_count = (await db.execute( + select(func.count(MiniBusiness.id)) + )).scalar() or 0 + + key_visit_count = (await db.execute( + select(func.count(KeyVisit.id)) + )).scalar() or 0 + + return { + "week_visits": visits_count, + "work_plans": plans_count, + "mini_business": mini_biz_count, + "key_visits": key_visit_count, + "week_start": str(monday), + "week_end": str(sunday), + } + + +async def get_reporting_progress(db: AsyncSession) -> list[dict]: + """Get per-manager reporting progress for the current week.""" + monday, sunday = get_week_range() + + # Get all managers + managers_result = await db.execute(select(User).where(User.role == "manager")) + managers = managers_result.scalars().all() + + # Get visit counts per manager this week + visits_result = await db.execute( + select(Visit.manager_id, func.count(Visit.id)) + .where(Visit.visit_date >= monday, Visit.visit_date <= sunday) + .group_by(Visit.manager_id) + ) + visit_map = {str(uid): cnt for uid, cnt in visits_result.all()} + + progress = [] + for m in managers: + count = visit_map.get(str(m.id), 0) + # Calculate expected working days (Mon-Fri) + days_passed = min((date.today() - monday).days + 1, 5) + expected = days_passed # At least 1 visit per working day + progress.append({ + "manager_id": str(m.id), + "manager_name": m.name, + "department": m.department, + "visit_count": count, + "expected": expected, + "completed": count >= expected, + "has_reported_today": False, # Will be set below + }) + + # Check today's reporting — visits OR daily notes + today = today_cst() + today_visits = await db.execute( + select(Visit.manager_id).where(Visit.visit_date == today) + ) + today_notes = await db.execute( + select(DailyNote.manager_id).where(DailyNote.note_date == today) + ) + reported_today = {str(uid) for uid, in today_visits.all()} | {str(uid) for uid, in today_notes.all()} + for p in progress: + p["has_reported_today"] = p["manager_id"] in reported_today + + return progress + + +async def get_weekly_report( + db: AsyncSession, user_id: UUID, role: str, + filter_manager_id: UUID | None = None, + filter_customer_id: UUID | None = None, +) -> dict: + """Get full weekly report data organized by module.""" + monday, sunday = get_week_range() + + # Base filters respecting role visibility + customer_map = {} + user_map = {} + + customers_result = await db.execute(select(Customer.id, Customer.name)) + customer_map = {c.id: c.name for c in customers_result.all()} + users_result = await db.execute(select(User.id, User.name)) + user_map = {u.id: u.name for u in users_result.all()} + + def build_manager_filter(existing_filter=None): + """If role is manager, only see own data. Otherwise optionally filter by manager_id.""" + if role == "manager": + return str(user_id) + return str(filter_manager_id) if filter_manager_id else None + + # ── Visits ── + visit_query = select(Visit).where(Visit.visit_date >= monday, Visit.visit_date <= sunday) + if role == "manager": + visit_query = visit_query.where(Visit.manager_id == user_id) + elif filter_manager_id: + visit_query = visit_query.where(Visit.manager_id == filter_manager_id) + if filter_customer_id: + visit_query = visit_query.where(Visit.customer_id == filter_customer_id) + visit_query = visit_query.order_by(Visit.visit_date.desc()) + visits_result = await db.execute(visit_query) + visits = visits_result.scalars().all() + + visits_data = [] + for v in visits: + visits_data.append({ + "id": str(v.id), + "customer_id": str(v.customer_id), + "customer_name": customer_map.get(v.customer_id, ""), + "visit_date": str(v.visit_date), + "visit_method": v.visit_method, + "time_range": v.time_range, + "communication_content": v.communication_content, + "customer_demand": v.customer_demand, + "companions": [str(c) for c in (v.companions or [])], + "photos": v.photos or [], + "manager_id": str(v.manager_id), + "manager_name": user_map.get(v.manager_id, ""), + "created_at": str(v.created_at), + }) + + # ── Work Plans ── + wp_query = select(WorkPlan) + if role == "manager": + wp_query = wp_query.where(WorkPlan.manager_id == user_id) + elif filter_manager_id: + wp_query = wp_query.where(WorkPlan.manager_id == filter_manager_id) + if filter_customer_id: + wp_query = wp_query.where(WorkPlan.customer_id == filter_customer_id) + wp_result = await db.execute(wp_query) + work_plans_data = [] + for w in wp_result.scalars(): + work_plans_data.append({ + "id": str(w.id), + "customer_id": str(w.customer_id), + "customer_name": customer_map.get(w.customer_id, ""), + "plan_content": w.plan_content, + "plan_date": str(w.plan_date), + "manager_id": str(w.manager_id), + "manager_name": user_map.get(w.manager_id, ""), + "status": w.status, + }) + + # ── Mini Business ── + mb_query = select(MiniBusiness) + if role == "manager": + mb_query = mb_query.where(MiniBusiness.manager_id == user_id) + elif filter_manager_id: + mb_query = mb_query.where(MiniBusiness.manager_id == filter_manager_id) + if filter_customer_id: + mb_query = mb_query.where(MiniBusiness.customer_id == filter_customer_id) + mb_result = await db.execute(mb_query) + mini_biz_data = [] + for m in mb_result.scalars(): + mini_biz_data.append({ + "id": str(m.id), + "customer_id": str(m.customer_id), + "customer_name": customer_map.get(m.customer_id, ""), + "product_type": m.product_type, + "amount": m.amount, + "follow_up_detail": m.follow_up_detail, + "status": m.status, + "manager_id": str(m.manager_id), + "manager_name": user_map.get(m.manager_id, ""), + "expected_revenue_date": m.expected_revenue_date, + }) + + # ── Key Visits ── + kv_query = select(KeyVisit) + if role == "manager": + kv_query = kv_query.where(KeyVisit.manager_id == user_id) + elif filter_manager_id: + kv_query = kv_query.where(KeyVisit.manager_id == filter_manager_id) + if filter_customer_id: + kv_query = kv_query.where(KeyVisit.customer_id == filter_customer_id) + kv_result = await db.execute(kv_query) + key_visits_data = [] + for k in kv_result.scalars(): + key_visits_data.append({ + "id": str(k.id), + "customer_id": str(k.customer_id), + "customer_name": customer_map.get(k.customer_id, ""), + "urgency_level": k.urgency_level, + "description": k.description, + "progress_status": k.progress_status, + "planned_date": k.planned_date, + "planned_visitor": k.planned_visitor, + "visit_target": k.visit_target, + "manager_id": str(k.manager_id), + "manager_name": user_map.get(k.manager_id, ""), + }) + + # ── Daily Notes ── + dn_query = select(DailyNote).where(DailyNote.note_date >= monday, DailyNote.note_date <= sunday) + if role == "manager": + dn_query = dn_query.where(DailyNote.manager_id == user_id) + elif filter_manager_id: + dn_query = dn_query.where(DailyNote.manager_id == filter_manager_id) + dn_query = dn_query.order_by(DailyNote.note_date.desc()) + dn_result = await db.execute(dn_query) + daily_notes_data = [] + for d in dn_result.scalars(): + daily_notes_data.append({ + "id": str(d.id), + "note_date": str(d.note_date), + "category": d.category, + "content": d.content, + "time_range": d.time_range, + "manager_id": str(d.manager_id), + "manager_name": user_map.get(d.manager_id, ""), + }) + + return { + "week_start": str(monday), + "week_end": str(sunday), + "visits": visits_data, + "daily_notes": daily_notes_data, + "work_plans": work_plans_data, + "mini_business": mini_biz_data, + "key_visits": key_visits_data, + } diff --git a/backend/app/services/excel_export.py b/backend/app/services/excel_export.py new file mode 100644 index 0000000..37ef246 --- /dev/null +++ b/backend/app/services/excel_export.py @@ -0,0 +1,137 @@ +import io +from datetime import date, timedelta +from openpyxl import Workbook +from openpyxl.styles import Font, Alignment, Border, Side +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.models.visit import Visit +from app.models.work_plan import WorkPlan +from app.models.mini_business import MiniBusiness +from app.models.key_visit import KeyVisit +from app.models.customer import Customer +from app.models.user import User + + +def get_week_range(reference_date: date | None = None): + """Get the Monday and Sunday of the week containing reference_date (defaults to today).""" + today = reference_date or date.today() + monday = today - timedelta(days=today.weekday()) + sunday = monday + timedelta(days=6) + return monday, sunday + + +async def export_weekly_report(db: AsyncSession, reference_date: date | None = None) -> io.BytesIO: + """Generate a 4-sheet xlsx matching the existing weekly report template.""" + monday, sunday = get_week_range(reference_date) + wb = Workbook() + + # Pre-fetch lookups + customers_result = await db.execute(select(Customer.id, Customer.name)) + customer_map = {str(c.id): c.name for c in customers_result.all()} + users_result = await db.execute(select(User.id, User.name)) + user_map = {str(u.id): u.name for u in users_result.all()} + + thin_border = Border( + left=Side(style='thin'), right=Side(style='thin'), + top=Side(style='thin'), bottom=Side(style='thin') + ) + header_font = Font(bold=True) + + # ── Sheet 1: 每日拜访记录 ── + ws1 = wb.active + ws1.title = "每日拜访记录" + headers1 = ["客户单位", "拜访日期", "拜访方式", "时间范围", "沟通内容", "客户需求", "同访人员", "客户经理"] + ws1.append(headers1) + for col in range(1, len(headers1) + 1): + cell = ws1.cell(row=1, column=col) + cell.font = header_font + cell.border = thin_border + + visits_result = await db.execute( + select(Visit).where(Visit.visit_date >= monday, Visit.visit_date <= sunday) + ) + for v in visits_result.scalars(): + companions_names = [user_map.get(str(cid), str(cid)) for cid in (v.companions or [])] + ws1.append([ + customer_map.get(str(v.customer_id), ""), + str(v.visit_date), + v.visit_method, + v.time_range, + v.communication_content, + v.customer_demand, + ", ".join(companions_names), + user_map.get(str(v.manager_id), ""), + ]) + + # ── Sheet 2: 下周工作计划 ── + ws2 = wb.create_sheet("下周工作计划") + headers2 = ["客户单位", "工作计划", "计划拜访时间", "客户经理", "状态"] + ws2.append(headers2) + for col in range(1, len(headers2) + 1): + cell = ws2.cell(row=1, column=col) + cell.font = header_font + cell.border = thin_border + + plans_result = await db.execute(select(WorkPlan)) + for p in plans_result.scalars(): + ws2.append([ + customer_map.get(str(p.customer_id), ""), + p.plan_content, + str(p.plan_date), + user_map.get(str(p.manager_id), ""), + p.status, + ]) + + # ── Sheet 3: 小微业务商机 ── + ws3 = wb.create_sheet("小微业务商机") + headers3 = ["客户单位", "产品类型", "金额", "跟进内容具体情况", "跟进状态", "客户经理", "预计列收时间"] + ws3.append(headers3) + for col in range(1, len(headers3) + 1): + cell = ws3.cell(row=1, column=col) + cell.font = header_font + cell.border = thin_border + + mb_result = await db.execute(select(MiniBusiness)) + for m in mb_result.scalars(): + ws3.append([ + customer_map.get(str(m.customer_id), ""), + m.product_type, + m.amount, + m.follow_up_detail, + m.status, + user_map.get(str(m.manager_id), ""), + m.expected_revenue_date, + ]) + + # ── Sheet 4: 要客拜访计划 ── + ws4 = wb.create_sheet("要客拜访计划") + headers4 = ["客户单位", "紧急重要度", "内容描述", "进展状态", "计划拜访时间", "计划拜访人", "拜访对象", "客户经理"] + ws4.append(headers4) + for col in range(1, len(headers4) + 1): + cell = ws4.cell(row=1, column=col) + cell.font = header_font + cell.border = thin_border + + kv_result = await db.execute(select(KeyVisit)) + for k in kv_result.scalars(): + ws4.append([ + customer_map.get(str(k.customer_id), ""), + k.urgency_level, + k.description, + k.progress_status, + k.planned_date, + k.planned_visitor, + k.visit_target, + user_map.get(str(k.manager_id), ""), + ]) + + # Adjust column widths + for ws in [ws1, ws2, ws3, ws4]: + for col_cells in ws.columns: + max_length = max((len(str(cell.value or "")) for cell in col_cells), default=10) + ws.column_dimensions[col_cells[0].column_letter].width = min(max_length + 4, 50) + + output = io.BytesIO() + wb.save(output) + output.seek(0) + return output diff --git a/backend/app/services/excel_import.py b/backend/app/services/excel_import.py new file mode 100644 index 0000000..875a71d --- /dev/null +++ b/backend/app/services/excel_import.py @@ -0,0 +1,78 @@ +import uuid +from datetime import datetime, date +from typing import Any +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +import openpyxl +from app.models.customer import Customer +from app.models.visit import Visit +from app.models.work_plan import WorkPlan +from app.models.mini_business import MiniBusiness +from app.models.key_visit import KeyVisit +from app.models.user import User + + +async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uuid.UUID) -> dict: + """Parse old weekly report Excel and import data. Returns summary stats.""" + wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True) + stats = {"visits": 0, "work_plans": 0, "mini_business": 0, "key_visits": 0, "skipped": 0} + + # Resolve customer name -> id cache + customers_result = await db.execute(select(Customer.id, Customer.name)) + customer_map = {c.name: c.id for c in customers_result.all()} + + # ── Parse Sheet 1: 每日拜访记录 ── + if "每日拜访记录" in wb.sheetnames: + ws = wb["每日拜访记录"] + for row in ws.iter_rows(min_row=2, values_only=True): + if not row[0]: + continue + try: + cust_name, visit_date_str, visit_method, time_range, content, demand, companions_str, mgr_str = \ + row[0], str(row[1]) if row[1] else str(date.today()), \ + str(row[2]) if row[2] else "上门", str(row[3]) if row[3] else "", \ + str(row[4]) if row[4] else "", str(row[5]) if row[5] else "", \ + str(row[6]) if row[6] else "", str(row[7]) if row[7] else "" + + customer_id = customer_map.get(cust_name) + if not customer_id: + stats["skipped"] += 1 + continue + + # Parse date + try: + visit_date = datetime.strptime(visit_date_str[:10], "%Y-%m-%d").date() + except ValueError: + visit_date = date.today() + + # Check for duplicates (same day, same person, same customer) + existing = await db.execute( + select(Visit).where( + Visit.visit_date == visit_date, + Visit.manager_id == manager_id, + Visit.customer_id == customer_id, + ) + ) + if existing.scalar_one_or_none(): + stats["skipped"] += 1 + continue + + visit = Visit( + customer_id=customer_id, + visit_date=visit_date, + visit_method=visit_method if visit_method in ["上门", "电话", "微信", "出差"] else "上门", + time_range=time_range, + communication_content=content, + customer_demand=demand, + manager_id=manager_id, + ) + db.add(visit) + stats["visits"] += 1 + except Exception: + stats["skipped"] += 1 + + await db.commit() + return stats + + +import io diff --git a/backend/app/services/minio_client.py b/backend/app/services/minio_client.py new file mode 100644 index 0000000..4f01872 --- /dev/null +++ b/backend/app/services/minio_client.py @@ -0,0 +1,43 @@ +from datetime import timedelta +from minio import Minio +from app.config import settings + +_client: Minio | None = None + + +def get_minio_client() -> Minio: + global _client + if _client is None: + _client = Minio( + settings.MINIO_ENDPOINT, + access_key=settings.MINIO_ACCESS_KEY, + secret_key=settings.MINIO_SECRET_KEY, + secure=settings.MINIO_SECURE, + ) + # Ensure bucket exists + if not _client.bucket_exists(settings.MINIO_BUCKET): + _client.make_bucket(settings.MINIO_BUCKET) + return _client + + +def generate_presigned_upload_url(object_key: str, expires: int = 600) -> str: + """Generate a presigned PUT URL for direct upload to MinIO.""" + client = get_minio_client() + return client.presigned_put_object(settings.MINIO_BUCKET, object_key, expires=timedelta(seconds=expires)) + + +def generate_presigned_download_url(object_key: str, expires: int = 3600) -> str: + """Generate a presigned GET URL for viewing/downloading an object.""" + client = get_minio_client() + return client.presigned_get_object(settings.MINIO_BUCKET, object_key, expires=timedelta(seconds=expires)) + + +def delete_objects(object_keys: list[str]) -> None: + """Delete multiple objects from MinIO.""" + if not object_keys: + return + client = get_minio_client() + from minio.deleteobjects import DeleteObject + errors = client.remove_objects(settings.MINIO_BUCKET, [DeleteObject(k) for k in object_keys]) + for err in errors: + pass # Log errors in production diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py new file mode 100644 index 0000000..a70f2c5 --- /dev/null +++ b/backend/app/services/scheduler.py @@ -0,0 +1,54 @@ +from datetime import date, datetime +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.visit import Visit +from app.models.daily_note import DailyNote +from app.models.user import User +from app.services.wecom import wecom_client +from app.utils.timezone import today_cst + + +async def check_daily_reporting(db: AsyncSession) -> dict: + """Check today's reporting progress and send wecom reminders to managers who haven't reported.""" + today = today_cst() + weekday = today.weekday() + if weekday >= 5: # Skip weekends + return {"status": "weekend", "date": str(today)} + + # Get all managers + result = await db.execute(select(User).where(User.role == "manager")) + managers = result.scalars().all() + + # Get managers who have reported today + reported_visits = await db.execute( + select(Visit.manager_id).where(Visit.visit_date == today) + ) + reported_notes = await db.execute( + select(DailyNote.manager_id).where(DailyNote.note_date == today) + ) + reported_map = {str(uid): True for uid, in reported_visits.all()} + for uid, in reported_notes.all(): + reported_map[str(uid)] = True + + not_reported = [] + for m in managers: + if str(m.id) not in reported_map: + not_reported.append(m) + + if not_reported and managers: + content = f"📋 今日填报提醒({today})\n\n以下同事尚未提交今日拜访记录:\n" + for m in not_reported: + content += f"• {m.name}\n" + content += "\n请尽快完成今日拜访填报 🙏" + + user_ids = [m.wecom_userid for m in not_reported if m.wecom_userid] + if user_ids: + await wecom_client.send_text_message(user_ids, content) + + return { + "status": "ok", + "date": str(today), + "total_managers": len(managers), + "reported": len(reported_map), + "not_reported": len(not_reported), + } diff --git a/backend/app/services/wecom.py b/backend/app/services/wecom.py new file mode 100644 index 0000000..9f51b38 --- /dev/null +++ b/backend/app/services/wecom.py @@ -0,0 +1,55 @@ +import httpx +from app.config import settings + + +class WecomClient: + """Minimal WeChat Work API client for sending app messages.""" + + def __init__(self): + self.corp_id = settings.WECOM_CORP_ID + self.agent_id = settings.WECOM_AGENT_ID + self.secret = settings.WECOM_SECRET + self._access_token: str | None = None + + async def _get_token(self) -> str: + if self._access_token: + return self._access_token + url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corp_id}&corpsecret={self.secret}" + async with httpx.AsyncClient() as client: + resp = await client.get(url, timeout=10) + data = resp.json() + if data.get("errcode") == 0: + self._access_token = data["access_token"] + return self._access_token + raise Exception(f"Failed to get wecom token: {data}") + + async def get_userinfo_by_code(self, code: str) -> dict | None: + """Exchange OAuth2 code for userid (used in silent login).""" + token = await self._get_token() + url = f"https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token={token}&code={code}" + async with httpx.AsyncClient() as client: + resp = await client.get(url, timeout=10) + data = resp.json() + if data.get("errcode") == 0: + return data + return None + + async def send_text_message(self, user_ids: list[str], content: str) -> bool: + """Send a text app message to specified users.""" + if not settings.WECOM_AGENT_ID: + return False # Not configured, skip silently in dev + token = await self._get_token() + url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}" + body = { + "touser": "|".join(user_ids), + "msgtype": "text", + "agentid": int(settings.WECOM_AGENT_ID), + "text": {"content": content}, + } + async with httpx.AsyncClient() as client: + resp = await client.post(url, json=body, timeout=10) + data = resp.json() + return data.get("errcode") == 0 + + +wecom_client = WecomClient() diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/utils/security.py b/backend/app/utils/security.py new file mode 100644 index 0000000..bae475a --- /dev/null +++ b/backend/app/utils/security.py @@ -0,0 +1,19 @@ +from datetime import datetime, timedelta, timezone +from typing import Optional +from jose import jwt, JWTError +from app.config import settings + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + to_encode = data.copy() + expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.JWT_EXPIRE_MINUTES)) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + + +def decode_token(token: str) -> Optional[dict]: + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) + return payload + except JWTError: + return None diff --git a/backend/app/utils/timezone.py b/backend/app/utils/timezone.py new file mode 100644 index 0000000..3ae4de2 --- /dev/null +++ b/backend/app/utils/timezone.py @@ -0,0 +1,13 @@ +from datetime import date, datetime, timezone, timedelta + +CST = timezone(timedelta(hours=8)) # China Standard Time + + +def today_cst() -> date: + """Get today's date in Asia/Shanghai timezone.""" + return datetime.now(timezone.utc).astimezone(CST).date() + + +def parse_date(s: str) -> date: + """Parse a date string that may be YYYY-MM-DD or a full ISO datetime.""" + return date.fromisoformat(s[:10]) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..e830a11 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy[asyncio]==2.0.36 +asyncpg==0.30.0 +alembic==1.14.0 +pydantic==2.10.3 +pydantic-settings==2.7.0 +python-jose[cryptography]==3.3.0 +httpx==0.28.1 +python-multipart==0.0.18 +minio==7.2.10 +openpyxl==3.1.5 +apscheduler==3.11.0 +python-dotenv==1.0.1 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..29f89dc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,46 @@ +version: "3.8" + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: qiji + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + ports: + - "9000:9000" + - "9001:9001" + volumes: + - miniodata:/data + + backend: + build: ./backend + ports: + - "8000:8000" + environment: + DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/qiji + MINIO_ENDPOINT: minio:9000 + MINIO_ACCESS_KEY: minioadmin + MINIO_SECRET_KEY: minioadmin + MINIO_BUCKET: qiji-photos + depends_on: + - postgres + - minio + volumes: + - ./backend:/app + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + +volumes: + pgdata: + miniodata: diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..b9352e7 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,10 @@ +# ============================================ +# 企迹 (qiji) — 前端环境变量 +# 复制此文件为 .env 并填入真实值 +# ============================================ + +# Casdoor 认证服务地址(与后端 CASDOOR_ENDPOINT 一致) +VITE_CASDOOR_ENDPOINT=http://localhost:8001 + +# Casdoor 应用 Client ID(与后端 CASDOOR_CLIENT_ID 一致) +VITE_CASDOOR_CLIENT_ID=e715d85bf5b2dc8988ac diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..92324c9 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..de2e77b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + +
+ + + +请稍候,正在验证您的身份。
+政企周报管理系统
+使用 Casdoor 账号登录
+请使用模板格式上传 Excel 文件。
+{{ stats.week_start }} ~ {{ stats.week_end }}
+ + +手动触发今日填报检查(通常每日 18:00 自动执行)
+{{ report.week_start }} ~ {{ report.week_end }}
+