企迹(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"}
|
||||
Reference in New Issue
Block a user