feat: 导入增强 + 客户合并 + 同伴自定义 + 性能优化
=== 导入系统全面增强 === - 5 Sheet 完整导入:拜访/计划/商机/要客/纪要 - 同访人智能解析:中英文逗号/顿号/分号 → 系统用户UUID + 外部人员TEXT - 客户自动创建:Excel中不存在的客户自动入库 - 模糊名称匹配:去空格 + 包含关系纠错 - manager_id 修正:导入时用客户分配的经理(非Excel列/非导入人) - 模板更新:Sheet5「客户经理」→「填报人」,同访人示例含逗号分隔 === 客户合并功能 === - PUT 改名碰撞检测 → 409 + 合并预览 - GET merge-preview / POST merge 端点 - 事务级迁移:Visit/WorkPlan/MiniBusiness/KeyVisit/联系人/分配 - 去重逻辑:联系人(name+phone)、分配(manager+role) - last_visit_date 取最大值 === 拜访记录完善 === - visits 新增 companion_names TEXT[] 列 - 同访人支持自定义输入(外部人员),el-select allow-create - 移动端 VisitForm + PC端 ManagerWorkspace 统一 - 周报「客户经理」→「相关人员」(创建人+同访人) - 纪要「客户经理」→「填报人」 - 删除拜访后重新计算 customer.last_visit_date === 客户选择放开 === - 客户经理可看到全部客户(不再限自己分配的) - 拜访时客户下拉返回全量 === 前端性能优化 === - Element Plus 按需加载 (unplugin-vue-components + unplugin-element-plus) - 周报照片URL并行请求 (Promise.all) - onMounted 三路并行 (loadReport + dropdowns + AI) - nginx Cache-Control: immutable for /assets/ - Google Fonts preconnect hints - 图片上传前 Canvas 压缩 (max 1920px, quality 0.8) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+202
-17
@@ -1,17 +1,24 @@
|
||||
import io
|
||||
import uuid as uuid_mod
|
||||
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 import select, or_, update
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel
|
||||
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.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.models.user import User
|
||||
from app.schemas.customer import (
|
||||
CustomerCreate, CustomerUpdate, CustomerOut, CustomerListOut, CustomerListResponse,
|
||||
@@ -48,19 +55,10 @@ async def list_customers(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List customers with filters and pagination. Managers only see their assigned."""
|
||||
"""List customers with filters and pagination. All roles see all customers."""
|
||||
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}%"))
|
||||
@@ -425,6 +423,28 @@ async def update_customer(
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
assignee_id = update_data.pop("assignee_id", None) # Handle separately
|
||||
|
||||
# ── Name collision detection (for merge) ──
|
||||
new_name = update_data.get("name")
|
||||
if new_name and new_name.strip() != customer.name:
|
||||
existing = await db.execute(
|
||||
select(Customer).where(
|
||||
Customer.name == new_name.strip(),
|
||||
Customer.id != customer.id,
|
||||
)
|
||||
)
|
||||
target = existing.scalar_one_or_none()
|
||||
if target:
|
||||
# Return 409 with merge preview — frontend should show merge dialog
|
||||
preview = await _build_merge_preview(db, str(customer.id), str(target.id))
|
||||
raise HTTPException(status_code=409, detail={
|
||||
"message": "名称冲突 — 同名客户已存在",
|
||||
"source_id": str(customer.id),
|
||||
"source_name": customer.name,
|
||||
"target_id": str(target.id),
|
||||
"target_name": target.name,
|
||||
"preview": preview,
|
||||
})
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(customer, key, value)
|
||||
|
||||
@@ -432,17 +452,16 @@ async def update_customer(
|
||||
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"])
|
||||
existing_a = assign_result.scalar_one_or_none()
|
||||
if existing_a:
|
||||
existing_a.manager_id = assignee_id
|
||||
existing_a.assigned_by = uuid_mod.UUID(current_user["user_id"])
|
||||
else:
|
||||
db.add(CustomerAssignment(
|
||||
customer_id=customer.id, manager_id=assignee_id,
|
||||
@@ -472,7 +491,173 @@ async def delete_customer(
|
||||
return {"detail": "deleted"}
|
||||
|
||||
|
||||
# ── Contacts ──
|
||||
# ── Customer Merge ──
|
||||
|
||||
|
||||
class MergePreviewOut(BaseModel):
|
||||
source_id: str
|
||||
source_name: str
|
||||
target_id: str
|
||||
target_name: str
|
||||
visits: int = 0
|
||||
work_plans: int = 0
|
||||
mini_business: int = 0
|
||||
key_visits: int = 0
|
||||
contacts: int = 0
|
||||
assignments: int = 0
|
||||
note: str = ""
|
||||
|
||||
|
||||
class MergeRequest(BaseModel):
|
||||
target_id: str
|
||||
|
||||
|
||||
async def _build_merge_preview(db: AsyncSession, source_id: str, target_id: str) -> dict:
|
||||
"""Count all records that would be migrated from source to target."""
|
||||
sid = uuid_mod.UUID(source_id)
|
||||
tid = uuid_mod.UUID(target_id)
|
||||
|
||||
visits = (await db.execute(
|
||||
select(select(Visit).where(Visit.customer_id == sid).subquery()).with_only_columns(
|
||||
__import__('sqlalchemy').func.count()
|
||||
)
|
||||
)).scalar() or 0
|
||||
# Simpler: count directly
|
||||
from sqlalchemy import func as sa_func
|
||||
visits = (await db.execute(select(sa_func.count()).select_from(Visit).where(Visit.customer_id == sid))).scalar() or 0
|
||||
plans = (await db.execute(select(sa_func.count()).select_from(WorkPlan).where(WorkPlan.customer_id == sid))).scalar() or 0
|
||||
mini = (await db.execute(select(sa_func.count()).select_from(MiniBusiness).where(MiniBusiness.customer_id == sid))).scalar() or 0
|
||||
kv = (await db.execute(select(sa_func.count()).select_from(KeyVisit).where(KeyVisit.customer_id == sid))).scalar() or 0
|
||||
contacts = (await db.execute(select(sa_func.count()).select_from(CustomerContact).where(CustomerContact.customer_id == sid))).scalar() or 0
|
||||
assignments = (await db.execute(select(sa_func.count()).select_from(CustomerAssignment).where(CustomerAssignment.customer_id == sid))).scalar() or 0
|
||||
|
||||
# Check for assignment conflicts
|
||||
target_assigns = await db.execute(
|
||||
select(CustomerAssignment.manager_id).where(CustomerAssignment.customer_id == tid)
|
||||
)
|
||||
target_managers = {str(row[0]) for row in target_assigns.all()}
|
||||
source_assigns = await db.execute(
|
||||
select(CustomerAssignment).where(CustomerAssignment.customer_id == sid)
|
||||
)
|
||||
conflict_note = ""
|
||||
for sa in source_assigns.scalars().all():
|
||||
if str(sa.manager_id) in target_managers:
|
||||
conflict_note = f"目标客户已有相同经理的分配,将去重"
|
||||
|
||||
return {
|
||||
"source_id": source_id,
|
||||
"source_name": "",
|
||||
"target_id": target_id,
|
||||
"target_name": "",
|
||||
"visits": visits,
|
||||
"work_plans": plans,
|
||||
"mini_business": mini,
|
||||
"key_visits": kv,
|
||||
"contacts": contacts,
|
||||
"assignments": assignments,
|
||||
"note": conflict_note,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{customer_id}/merge-preview")
|
||||
async def get_merge_preview(
|
||||
customer_id: str,
|
||||
target_id: str = Query(...),
|
||||
current_user: dict = Depends(require_director),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Preview merge: show what data would be moved from source to target."""
|
||||
source = (await db.execute(select(Customer).where(Customer.id == customer_id))).scalar_one_or_none()
|
||||
target = (await db.execute(select(Customer).where(Customer.id == target_id))).scalar_one_or_none()
|
||||
if not source or not target:
|
||||
raise HTTPException(status_code=404, detail="客户不存在")
|
||||
if customer_id == target_id:
|
||||
raise HTTPException(status_code=400, detail="不能合并到自身")
|
||||
|
||||
preview = await _build_merge_preview(db, customer_id, target_id)
|
||||
preview["source_name"] = source.name
|
||||
preview["target_name"] = target.name
|
||||
return preview
|
||||
|
||||
|
||||
@router.post("/{customer_id}/merge")
|
||||
async def execute_merge(
|
||||
customer_id: str,
|
||||
body: MergeRequest,
|
||||
current_user: dict = Depends(require_director),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Merge source customer into target. All related data is migrated, source is deleted."""
|
||||
source_id = uuid_mod.UUID(customer_id)
|
||||
target_id = uuid_mod.UUID(body.target_id)
|
||||
|
||||
if customer_id == body.target_id:
|
||||
raise HTTPException(status_code=400, detail="不能合并到自身")
|
||||
|
||||
source = (await db.execute(select(Customer).where(Customer.id == source_id))).scalar_one_or_none()
|
||||
target = (await db.execute(select(Customer).where(Customer.id == target_id))).scalar_one_or_none()
|
||||
if not source or not target:
|
||||
raise HTTPException(status_code=404, detail="客户不存在")
|
||||
|
||||
# Build preview for response
|
||||
preview = await _build_merge_preview(db, customer_id, body.target_id)
|
||||
|
||||
# ── Transaction: migrate all FK references ──
|
||||
for model, fk_col in [
|
||||
(Visit, Visit.customer_id),
|
||||
(WorkPlan, WorkPlan.customer_id),
|
||||
(MiniBusiness, MiniBusiness.customer_id),
|
||||
(KeyVisit, KeyVisit.customer_id),
|
||||
]:
|
||||
await db.execute(
|
||||
update(model).where(fk_col == source_id).values(customer_id=target_id)
|
||||
)
|
||||
|
||||
# Contacts: migrate, skip duplicates
|
||||
source_contacts = (await db.execute(
|
||||
select(CustomerContact).where(CustomerContact.customer_id == source_id)
|
||||
)).scalars().all()
|
||||
target_contacts = (await db.execute(
|
||||
select(CustomerContact).where(CustomerContact.customer_id == target_id)
|
||||
)).scalars().all()
|
||||
existing_contact_keys = {(c.name, c.phone) for c in target_contacts}
|
||||
for c in source_contacts:
|
||||
if (c.name, c.phone) in existing_contact_keys:
|
||||
await db.delete(c) # Skip duplicate
|
||||
else:
|
||||
c.customer_id = target_id
|
||||
db.add(c)
|
||||
|
||||
# Assignments: migrate, skip same (manager_id, role) pairs
|
||||
target_assigns = (await db.execute(
|
||||
select(CustomerAssignment).where(CustomerAssignment.customer_id == target_id)
|
||||
)).scalars().all()
|
||||
existing_assign_keys = {(str(a.manager_id), a.role) for a in target_assigns}
|
||||
source_assigns = (await db.execute(
|
||||
select(CustomerAssignment).where(CustomerAssignment.customer_id == source_id)
|
||||
)).scalars().all()
|
||||
for a in source_assigns:
|
||||
if (str(a.manager_id), a.role) in existing_assign_keys:
|
||||
await db.delete(a) # Skip duplicate
|
||||
else:
|
||||
a.customer_id = target_id
|
||||
db.add(a)
|
||||
|
||||
# Update target's last_visit_date to the max of both
|
||||
if source.last_visit_date:
|
||||
if not target.last_visit_date or source.last_visit_date > target.last_visit_date:
|
||||
target.last_visit_date = source.last_visit_date
|
||||
target.last_visit_manager_id = source.last_visit_manager_id
|
||||
|
||||
# Delete source customer
|
||||
source_name = source.name
|
||||
target_name = target.name
|
||||
await db.delete(source)
|
||||
await db.commit()
|
||||
|
||||
preview["result"] = f"已将「{source_name}」合并到「{target_name}」"
|
||||
return preview
|
||||
|
||||
|
||||
@router.post("/{customer_id}/contacts", response_model=ContactOut)
|
||||
async def add_contact(
|
||||
|
||||
Reference in New Issue
Block a user