bf67e0575f
- 信息架构重组: 周报精简为拜访+纪要两个Tab,工作计划/商机/要客独立为侧边栏「工作」分组下的独立页面 - 侧边栏分组: 汇总/工作/管理三层分组,仪表盘四卡可点击跳转 - 变更追踪(edit_log): 5张表新增JSONB edit_log列,POST创建/PUT diff自动记录,编辑弹窗变更时间轴,表格🕐编辑标记 - 图片预览增强: ImagePreview统一组件,支持适应页面/缩放/拖拽平移/滚轮缩放/键盘快捷键 - 修复客户导入500错误(errors变量未初始化) - 移除工作计划/商机/要客页面冗余编辑按钮 Co-Authored-By: Claude <noreply@anthropic.com>
533 lines
21 KiB
Python
533 lines
21 KiB
Python
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)
|
|
items = result.scalars().all()
|
|
|
|
# Enrich with manager names
|
|
if items:
|
|
mgr_result = await db.execute(
|
|
select(CustomerAssignment.customer_id, User.name)
|
|
.join(User, CustomerAssignment.manager_id == User.id)
|
|
.where(CustomerAssignment.customer_id.in_([c.id for c in items]), CustomerAssignment.role == "primary")
|
|
)
|
|
mgr_map = {str(cid): name for cid, name in mgr_result.all()}
|
|
for item in items:
|
|
item.primary_manager_name = mgr_map.get(str(item.id), None)
|
|
|
|
return CustomerListResponse(
|
|
items=items,
|
|
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, updated, skipped = 0, 0, 0
|
|
reasons = []
|
|
errors = []
|
|
|
|
# Build user name → id lookup (all users, not just managers)
|
|
user_rows = await db.execute(select(User.name, User.id))
|
|
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 ""
|
|
|
|
assignee_id = user_map.get(mgr_name, default_user_id)
|
|
|
|
existing_result = await db.execute(select(Customer).where(Customer.name == name))
|
|
existing = existing_result.scalar_one_or_none()
|
|
|
|
try:
|
|
if existing:
|
|
# Update existing customer
|
|
existing.industry = industry or existing.industry
|
|
existing.address = address or existing.address
|
|
existing.in_use_services = services or existing.in_use_services
|
|
existing.monthly_fee = fee or existing.monthly_fee
|
|
existing.remarks = remarks or existing.remarks
|
|
# Update or create primary assignment if manager changed
|
|
if mgr_name:
|
|
assign_rows = await db.execute(
|
|
select(CustomerAssignment).where(CustomerAssignment.customer_id == existing.id, CustomerAssignment.role == "primary")
|
|
)
|
|
first_assign = assign_rows.first()
|
|
if first_assign:
|
|
first_assign[0].manager_id = assignee_id
|
|
else:
|
|
db.add(CustomerAssignment(customer_id=existing.id, manager_id=assignee_id, role="primary", assigned_by=default_user_id))
|
|
updated += 1
|
|
reasons.append(f"更新「{name}」的信息")
|
|
else:
|
|
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
|
|
reasons.append(f"新建「{name}」")
|
|
except Exception as e:
|
|
errors.append(f"第{row_idx}行({name}): {str(e)}")
|
|
|
|
await db.commit()
|
|
return {"created": created, "updated": updated, "skipped": skipped, "reasons": reasons, "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),
|
|
):
|
|
if current_user["role"] == "leader":
|
|
raise HTTPException(status_code=403, detail="分管领导无法创建客户")
|
|
"""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),
|
|
):
|
|
if current_user["role"] == "leader":
|
|
raise HTTPException(status_code=403, detail="分管领导无法编辑客户")
|
|
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
|