企迹(qiji) 政企周报管理系统 — v0.1

后端: FastAPI + SQLAlchemy 2.0 (async) + Alembic + MinIO + Casdoor + 企微
前端: Vue 3 + Vite + TypeScript + Element Plus + Pinia

功能清单:
- 8 张数据表自动建表 / Casdoor OIDC 登录 / 企微静默登录
- 双布局: 移动端(填报) + PC端(汇总管理)
- 拜访记录 CRUD + MinIO 照片直传 + 缩略图预览 + 同访人草稿
- 今日纪要 (6 分类) / 工作计划 / 小微商机 / 要客拜访 CRUD
- 客户档案: 备注/收支费用/联系人/归属分配/批量转移
- 客户导入导出 + 模板下载 + 搜索/分页/筛选
- 仪表盘: 四卡统计 + 填报进度 (拜访+纪要双维度)
- 周报详情: 五 Tab + 按人/客户筛选 + 时间轴
- 用户管理 / 客户经理 PC 端工作台
- 企微: 催办/公告/定时提醒 / 时区修正
- Docker 部署配置

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-23 01:35:46 +08:00
parent 2a8225c31f
commit a1886074dd
97 changed files with 8830 additions and 0 deletions
+41
View File
@@ -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"]
+12
View File
@@ -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"]
+36
View File
@@ -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
+57
View File
@@ -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()
+26
View File
@@ -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"}
View File
+3
View File
@@ -0,0 +1,3 @@
from fastapi import APIRouter
router = APIRouter()
+93
View File
@@ -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,
)
+507
View File
@@ -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
+131
View File
@@ -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"}
+45
View File
@@ -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,
)
+22
View File
@@ -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"},
)
+43
View File
@@ -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
+107
View File
@@ -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"}
+105
View File
@@ -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"}
+36
View File
@@ -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}
+82
View File
@@ -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,
}
+209
View File
@@ -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"}
+68
View File
@@ -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
+105
View File
@@ -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"}
+48
View File
@@ -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()
+19
View File
@@ -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()
+58
View File
@@ -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}
View File
+31
View File
@@ -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"])
+21
View File
@@ -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",
]
+24
View File
@@ -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")
+20
View File
@@ -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])
+17
View File
@@ -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")
+19
View File
@@ -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())
+19
View File
@@ -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)
+18
View File
@@ -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="")
+18
View File
@@ -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())
+23
View File
@@ -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())
+17
View File
@@ -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="计划中") # 计划中/已完成/已取消
+10
View File
@@ -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
+118
View File
@@ -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
+32
View File
@@ -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}
+39
View File
@@ -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}
+36
View File
@@ -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}
+25
View File
@@ -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
+60
View File
@@ -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}
+31
View File
@@ -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}
View File
+106
View File
@@ -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,
})
+253
View File
@@ -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,
}
+137
View File
@@ -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
+78
View File
@@ -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
+43
View File
@@ -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
+54
View File
@@ -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),
}
+55
View File
@@ -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()
View File
+19
View File
@@ -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
+13
View File
@@ -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])
+14
View File
@@ -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