企迹(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:
@@ -0,0 +1,3 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
@@ -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"])
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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")
|
||||
@@ -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])
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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="")
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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="计划中") # 计划中/已完成/已取消
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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])
|
||||
Reference in New Issue
Block a user