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(
|
||||
|
||||
@@ -12,7 +12,7 @@ router = APIRouter(prefix="/import", tags=["Import"])
|
||||
|
||||
@router.get("/template")
|
||||
async def download_weekly_report_template():
|
||||
"""Download a 4-sheet weekly report import template."""
|
||||
"""Download a 5-sheet weekly report import template."""
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font
|
||||
|
||||
@@ -24,7 +24,7 @@ async def download_weekly_report_template():
|
||||
ws1.title = "每日拜访记录"
|
||||
ws1.append(["客户单位", "拜访日期", "拜访方式", "时间范围", "拜访人姓名", "拜访人电话", "沟通内容", "客户需求", "同访人员", "客户经理"])
|
||||
for c in ws1[1]: c.font = header_font
|
||||
ws1.append(["XX科技有限公司", "2026-06-23", "上门", "9:00-10:00", "韦柳柏", "13800000000", "沟通了解云桌面需求", "希望扩容", "", "韦矍森"])
|
||||
ws1.append(["XX科技有限公司", "2026-06-23", "上门", "9:00-10:00", "韦柳柏", "13800000000", "沟通了解云桌面需求", "希望扩容", "韦柳柏, 张科长", "韦矍森"])
|
||||
ws1.column_dimensions['A'].width = 20; ws1.column_dimensions['E'].width = 30; ws1.column_dimensions['F'].width = 20
|
||||
|
||||
# Sheet 2: 下周工作计划
|
||||
@@ -48,6 +48,13 @@ async def download_weekly_report_template():
|
||||
ws4.append(["XX科技有限公司", "重要", "拜访技术负责人确认方案", "未开始", "2026-07-01", "韦柳柏", "王局长", "韦矍森"])
|
||||
ws4.column_dimensions['A'].width = 20; ws4.column_dimensions['C'].width = 30
|
||||
|
||||
# Sheet 5: 今日纪要
|
||||
ws5 = wb.create_sheet("今日纪要")
|
||||
ws5.append(["日期", "分类", "内容", "时间范围", "填报人"])
|
||||
for c in ws5[1]: c.font = header_font
|
||||
ws5.append(["2026-06-23", "内部会议", "参加云桌面项目方案讨论会", "14:00-16:00", "韦矍森"])
|
||||
ws5.column_dimensions['A'].width = 15; ws5.column_dimensions['B'].width = 12; ws5.column_dimensions['C'].width = 40
|
||||
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
|
||||
@@ -28,6 +28,12 @@ async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict:
|
||||
mgr_result = await db.execute(select(User.name).where(User.id == visit.manager_id))
|
||||
manager_name = mgr_result.scalar_one_or_none()
|
||||
|
||||
# Resolve companion UUIDs to names
|
||||
companion_names_resolved = list(visit.companion_names or [])
|
||||
if visit.companions:
|
||||
comp_result = await db.execute(select(User.name).where(User.id.in_(visit.companions)))
|
||||
companion_names_resolved = [n for n, in comp_result.all()] + companion_names_resolved
|
||||
|
||||
return {
|
||||
"id": str(visit.id),
|
||||
"customer_id": str(visit.customer_id),
|
||||
@@ -40,6 +46,8 @@ async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict:
|
||||
"communication_content": visit.communication_content,
|
||||
"customer_demand": visit.customer_demand,
|
||||
"companions": visit.companions,
|
||||
"companion_names": visit.companion_names or [],
|
||||
"companion_names_resolved": companion_names_resolved,
|
||||
"photos": visit.photos,
|
||||
"manager_id": str(visit.manager_id),
|
||||
"manager_name": manager_name,
|
||||
@@ -137,6 +145,7 @@ async def create_visit(
|
||||
communication_content=data.communication_content,
|
||||
customer_demand=data.customer_demand,
|
||||
companions=data.companions,
|
||||
companion_names=data.companion_names,
|
||||
photos=data.photos,
|
||||
manager_id=uuid.UUID(current_user["user_id"]),
|
||||
)
|
||||
@@ -245,10 +254,39 @@ async def delete_visit(
|
||||
if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
customer_id = visit.customer_id
|
||||
manager_id = visit.manager_id
|
||||
|
||||
# Clean up photos in MinIO
|
||||
if visit.photos:
|
||||
delete_objects(visit.photos)
|
||||
|
||||
await db.delete(visit)
|
||||
|
||||
# Recalculate customer's last_visit_date from remaining visits
|
||||
latest = await db.execute(
|
||||
select(func.max(Visit.visit_date)).where(
|
||||
Visit.customer_id == customer_id,
|
||||
)
|
||||
)
|
||||
new_latest = latest.scalar()
|
||||
cust = await db.get(Customer, customer_id)
|
||||
if cust:
|
||||
if new_latest:
|
||||
cust.last_visit_date = new_latest
|
||||
# Keep the existing manager if date unchanged, or find who made the latest visit
|
||||
latest_visit = await db.execute(
|
||||
select(Visit).where(
|
||||
Visit.customer_id == customer_id,
|
||||
Visit.visit_date == new_latest,
|
||||
).order_by(Visit.created_at.desc()).limit(1)
|
||||
)
|
||||
lv = latest_visit.scalar_one_or_none()
|
||||
if lv:
|
||||
cust.last_visit_manager_id = lv.manager_id
|
||||
else:
|
||||
cust.last_visit_date = None
|
||||
cust.last_visit_manager_id = None
|
||||
|
||||
await db.commit()
|
||||
return {"detail": "deleted"}
|
||||
|
||||
@@ -54,6 +54,10 @@ async def lifespan(app: FastAPI):
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_manager_id UUID"
|
||||
))
|
||||
# New columns for v0.4
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS companion_names TEXT[] DEFAULT '{}'"
|
||||
))
|
||||
|
||||
# Start daily reporting scheduler (17:30 CST = 09:30 UTC)
|
||||
_scheduler.add_job(_scheduled_check, "cron", hour=17, minute=30, id="daily_check")
|
||||
|
||||
@@ -19,6 +19,7 @@ class Visit(Base):
|
||||
visitor_name: Mapped[str] = mapped_column(String(50), default="")
|
||||
visitor_phone: Mapped[str] = mapped_column(String(20), default="")
|
||||
companions: Mapped[list | None] = mapped_column(ARRAY(UUID(as_uuid=True)), nullable=True)
|
||||
companion_names: Mapped[list] = mapped_column(ARRAY(Text), default=list)
|
||||
photos: Mapped[list | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
|
||||
edit_log: Mapped[list] = mapped_column(JSONB, default=list)
|
||||
|
||||
@@ -14,6 +14,7 @@ class VisitCreate(BaseModel):
|
||||
communication_content: str = ""
|
||||
customer_demand: str = ""
|
||||
companions: list[uuid.UUID] = []
|
||||
companion_names: list[str] = []
|
||||
photos: list[str] = []
|
||||
|
||||
|
||||
@@ -27,6 +28,7 @@ class VisitUpdate(BaseModel):
|
||||
communication_content: Optional[str] = None
|
||||
customer_demand: Optional[str] = None
|
||||
companions: Optional[list[uuid.UUID]] = None
|
||||
companion_names: Optional[list[str]] = None
|
||||
photos: Optional[list[str]] = None
|
||||
|
||||
|
||||
@@ -41,6 +43,7 @@ class VisitOut(BaseModel):
|
||||
communication_content: str
|
||||
customer_demand: str
|
||||
companions: Optional[list[uuid.UUID]] = None
|
||||
companion_names: list[str] = []
|
||||
photos: Optional[list[str]] = None
|
||||
manager_id: uuid.UUID
|
||||
created_at: datetime
|
||||
|
||||
@@ -8,6 +8,7 @@ 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.customer import Customer
|
||||
from app.models.user import User
|
||||
|
||||
@@ -21,7 +22,7 @@ def get_week_range(reference_date: date | None = None):
|
||||
|
||||
|
||||
async def export_weekly_report(db: AsyncSession, reference_date: date | None = None) -> io.BytesIO:
|
||||
"""Generate a 4-sheet xlsx matching the existing weekly report template."""
|
||||
"""Generate a 5-sheet xlsx matching the existing weekly report template."""
|
||||
monday, sunday = get_week_range(reference_date)
|
||||
wb = Workbook()
|
||||
|
||||
@@ -52,6 +53,7 @@ async def export_weekly_report(db: AsyncSession, reference_date: date | None = N
|
||||
)
|
||||
for v in visits_result.scalars():
|
||||
companions_names = [user_map.get(str(cid), str(cid)) for cid in (v.companions or [])]
|
||||
companions_names.extend(v.companion_names or [])
|
||||
ws1.append([
|
||||
customer_map.get(str(v.customer_id), ""),
|
||||
str(v.visit_date),
|
||||
@@ -127,8 +129,29 @@ async def export_weekly_report(db: AsyncSession, reference_date: date | None = N
|
||||
user_map.get(str(k.manager_id), ""),
|
||||
])
|
||||
|
||||
# ── Sheet 5: 今日纪要 ──
|
||||
ws5 = wb.create_sheet("今日纪要")
|
||||
headers5 = ["日期", "分类", "内容", "时间范围", "填报人"]
|
||||
ws5.append(headers5)
|
||||
for col in range(1, len(headers5) + 1):
|
||||
cell = ws5.cell(row=1, column=col)
|
||||
cell.font = header_font
|
||||
cell.border = thin_border
|
||||
|
||||
notes_result = await db.execute(
|
||||
select(DailyNote).where(DailyNote.note_date >= monday, DailyNote.note_date <= sunday)
|
||||
)
|
||||
for n in notes_result.scalars():
|
||||
ws5.append([
|
||||
str(n.note_date),
|
||||
n.category,
|
||||
n.content,
|
||||
n.time_range,
|
||||
user_map.get(str(n.manager_id), ""),
|
||||
])
|
||||
|
||||
# Adjust column widths
|
||||
for ws in [ws1, ws2, ws3, ws4]:
|
||||
for ws in [ws1, ws2, ws3, ws4, ws5]:
|
||||
for col_cells in ws.columns:
|
||||
max_length = max((len(str(cell.value or "")) for cell in col_cells), default=10)
|
||||
ws.column_dimensions[col_cells[0].column_letter].width = min(max_length + 4, 50)
|
||||
|
||||
@@ -1,26 +1,142 @@
|
||||
import io
|
||||
import uuid
|
||||
import re
|
||||
from datetime import datetime, date
|
||||
from typing import Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import openpyxl
|
||||
from sqlalchemy import select, update
|
||||
from app.models.customer import Customer
|
||||
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
|
||||
|
||||
import openpyxl
|
||||
|
||||
# Companion name separators: Chinese comma, English comma, Chinese semicolon, dun-hao
|
||||
_COMPANION_SEP = re.compile(r'[,,;、;]')
|
||||
|
||||
|
||||
def _parse_companions(raw: str, user_map: dict[str, uuid.UUID]) -> tuple[list[uuid.UUID], list[str]]:
|
||||
"""Split a companion string into system user IDs and external names.
|
||||
|
||||
Returns (system_ids, external_names).
|
||||
"""
|
||||
if not raw or not raw.strip():
|
||||
return [], []
|
||||
|
||||
parts = [p.strip() for p in _COMPANION_SEP.split(raw) if p.strip()]
|
||||
system_ids: list[uuid.UUID] = []
|
||||
external_names: list[str] = []
|
||||
|
||||
for name in parts:
|
||||
uid = user_map.get(name)
|
||||
if uid:
|
||||
system_ids.append(uid)
|
||||
else:
|
||||
external_names.append(name)
|
||||
|
||||
return system_ids, external_names
|
||||
|
||||
|
||||
def _fuzzy_match_customer(
|
||||
cust_name: str,
|
||||
customer_map: dict[str, uuid.UUID],
|
||||
) -> uuid.UUID | None:
|
||||
"""Try to match a customer name with fuzzy rules.
|
||||
|
||||
Rules (in order):
|
||||
1. Exact match (already handled before calling this)
|
||||
2. Normalize whitespace → exact match
|
||||
3. One name fully contains the other
|
||||
"""
|
||||
norm = cust_name.replace(' ', '').replace(' ', '')
|
||||
# Rule 2: whitespace-normalized match
|
||||
for existing_name, cid in customer_map.items():
|
||||
existing_norm = existing_name.replace(' ', '').replace(' ', '')
|
||||
if norm == existing_norm:
|
||||
return cid
|
||||
|
||||
# Rule 3: containment (longer name contains shorter)
|
||||
for existing_name, cid in customer_map.items():
|
||||
if len(norm) >= 4 and len(existing_name.replace(' ', '').replace(' ', '')) >= 4:
|
||||
if norm in existing_name.replace(' ', '').replace(' ', '') or \
|
||||
existing_name.replace(' ', '').replace(' ', '') in norm:
|
||||
return cid
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uuid.UUID) -> dict:
|
||||
"""Parse old weekly report Excel and import data. Returns summary stats."""
|
||||
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True)
|
||||
stats = {"visits": 0, "work_plans": 0, "mini_business": 0, "key_visits": 0, "skipped": 0, "skip_reasons": []}
|
||||
"""Parse old weekly report Excel and import data. Returns summary stats.
|
||||
|
||||
# Resolve customer name -> id cache
|
||||
Features:
|
||||
- Companion parsing: split by comma/dun-hao → system users + external names
|
||||
- Auto-create customers: if customer not found, create from Excel data
|
||||
- Fuzzy name matching: whitespace normalization + containment
|
||||
- Daily notes import (Sheet 5)
|
||||
"""
|
||||
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True)
|
||||
stats = {
|
||||
"visits": 0, "work_plans": 0, "mini_business": 0, "key_visits": 0,
|
||||
"daily_notes": 0, "skipped": 0, "skip_reasons": [],
|
||||
"customers_created": 0, "customers_created_names": [],
|
||||
"external_companions": 0, "name_corrections": [],
|
||||
}
|
||||
|
||||
# ── Pre-fetch lookups ──
|
||||
customers_result = await db.execute(select(Customer.id, Customer.name))
|
||||
customer_map = {c.name: c.id for c in customers_result.all()}
|
||||
|
||||
users_result = await db.execute(select(User.id, User.name))
|
||||
user_map = {u.name: u.id for u in users_result.all()}
|
||||
|
||||
# Customer → assigned primary manager lookup (not from Excel)
|
||||
assign_result = await db.execute(
|
||||
select(CustomerAssignment.customer_id, CustomerAssignment.manager_id)
|
||||
.where(CustomerAssignment.role == "primary")
|
||||
)
|
||||
customer_manager_map: dict[uuid.UUID, uuid.UUID] = {}
|
||||
for cid, mid in assign_result.all():
|
||||
if cid not in customer_manager_map: # first primary wins
|
||||
customer_manager_map[cid] = mid
|
||||
|
||||
def get_assigned_manager(customer_id: uuid.UUID) -> uuid.UUID:
|
||||
"""Return the customer's assigned primary manager, or the importer as fallback."""
|
||||
return customer_manager_map.get(customer_id, manager_id)
|
||||
|
||||
# Helper: resolve or create customer
|
||||
async def resolve_customer(cust_name: str, mgr_name: str = "") -> tuple[uuid.UUID | None, str]:
|
||||
"""Resolve customer by name. Auto-creates if not found. Returns (id, note)."""
|
||||
if not cust_name or not cust_name.strip():
|
||||
return None, ""
|
||||
|
||||
cust_name = cust_name.strip()
|
||||
cid = customer_map.get(cust_name)
|
||||
if cid:
|
||||
return cid, ""
|
||||
|
||||
# Fuzzy match
|
||||
fuzzy_id = _fuzzy_match_customer(cust_name, customer_map)
|
||||
if fuzzy_id:
|
||||
real_name = next((n for n, i in customer_map.items() if i == fuzzy_id), cust_name)
|
||||
stats["name_corrections"].append(f"「{cust_name}」→「{real_name}」")
|
||||
customer_map[cust_name] = fuzzy_id # cache for future rows
|
||||
return fuzzy_id, ""
|
||||
|
||||
# Auto-create customer (no manager assignment — leave unassigned)
|
||||
customer = Customer(name=cust_name, created_by=manager_id)
|
||||
db.add(customer)
|
||||
await db.flush()
|
||||
customer_map[cust_name] = customer.id
|
||||
stats["customers_created"] += 1
|
||||
stats["customers_created_names"].append(cust_name)
|
||||
|
||||
return customer.id, ""
|
||||
|
||||
# ── Parse Sheet 1: 每日拜访记录 ──
|
||||
if "每日拜访记录" in wb.sheetnames:
|
||||
ws = wb["每日拜访记录"]
|
||||
@@ -28,28 +144,36 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui
|
||||
if not row[0]:
|
||||
continue
|
||||
try:
|
||||
cust_name, visit_date_str, visit_method, time_range, visitor_name, visitor_phone, content, demand, companions_str, mgr_str = \
|
||||
row[0], str(row[1]) if row[1] else str(date.today()), \
|
||||
str(row[2]) if row[2] else "上门", str(row[3]) if row[3] else "", \
|
||||
str(row[4]) if row[4] else "", str(row[5]) if row[5] else "", \
|
||||
str(row[6]) if row[6] else "", str(row[7]) if row[7] else "", \
|
||||
str(row[8]) if row[8] else "", str(row[9]) if row[9] else ""
|
||||
cust_name, visit_date_str, visit_method, time_range, visitor_name, visitor_phone, \
|
||||
content, demand, companions_str, mgr_str = (
|
||||
row[0], str(row[1]) if row[1] else str(date.today()),
|
||||
str(row[2]) if row[2] else "上门", str(row[3]) if row[3] else "",
|
||||
str(row[4]) if row[4] else "", str(row[5]) if row[5] else "",
|
||||
str(row[6]) if row[6] else "", str(row[7]) if row[7] else "",
|
||||
str(row[8]) if row[8] else "", str(row[9]) if row[9] else "",
|
||||
)
|
||||
|
||||
customer_id = customer_map.get(cust_name)
|
||||
# Resolve customer (auto-create if needed)
|
||||
customer_id, _ = await resolve_customer(cust_name, str(row[9]) if row[9] else "")
|
||||
if not customer_id:
|
||||
stats["skipped"] += 1
|
||||
stats["skip_reasons"].append(f"客户「{cust_name}」不存在,跳过")
|
||||
stats["skip_reasons"].append(f"客户名称为空,跳过")
|
||||
continue
|
||||
|
||||
# Parse visit date
|
||||
try:
|
||||
visit_date = datetime.strptime(visit_date_str[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
visit_date = date.today()
|
||||
|
||||
# Use customer's assigned primary manager (not Excel column)
|
||||
visit_manager_id = get_assigned_manager(customer_id)
|
||||
|
||||
# Check duplicate: same date + same manager + same customer
|
||||
existing = await db.execute(
|
||||
select(Visit).where(
|
||||
Visit.visit_date == visit_date,
|
||||
Visit.manager_id == manager_id,
|
||||
Visit.manager_id == visit_manager_id,
|
||||
Visit.customer_id == customer_id,
|
||||
)
|
||||
)
|
||||
@@ -58,6 +182,11 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui
|
||||
stats["skip_reasons"].append(f"重复:{cust_name} {visit_date} 已存在")
|
||||
continue
|
||||
|
||||
# Parse companions into system users + external names
|
||||
sys_companions, ext_names = _parse_companions(str(companions_str), user_map)
|
||||
if ext_names:
|
||||
stats["external_companions"] += len(ext_names)
|
||||
|
||||
visit = Visit(
|
||||
customer_id=customer_id,
|
||||
visit_date=visit_date,
|
||||
@@ -67,15 +196,194 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui
|
||||
visitor_phone=visitor_phone,
|
||||
communication_content=content,
|
||||
customer_demand=demand,
|
||||
manager_id=manager_id,
|
||||
companions=sys_companions,
|
||||
companion_names=ext_names,
|
||||
manager_id=visit_manager_id,
|
||||
)
|
||||
db.add(visit)
|
||||
stats["visits"] += 1
|
||||
|
||||
# Update customer's last_visit_date for light board
|
||||
cust = await db.get(Customer, customer_id)
|
||||
if cust and (not cust.last_visit_date or visit_date > cust.last_visit_date):
|
||||
cust.last_visit_date = visit_date
|
||||
cust.last_visit_manager_id = visit_manager_id
|
||||
except Exception as e:
|
||||
stats["skipped"] += 1
|
||||
stats["skip_reasons"].append(f"拜访解析异常:{repr(e)[:120]}")
|
||||
|
||||
# ── Parse Sheet 2: 下周工作计划 ──
|
||||
if "下周工作计划" in wb.sheetnames:
|
||||
ws2 = wb["下周工作计划"]
|
||||
for row in ws2.iter_rows(min_row=2, values_only=True):
|
||||
if not row[0]:
|
||||
continue
|
||||
try:
|
||||
cust_name = str(row[0]).strip() if row[0] else ""
|
||||
plan_content = str(row[1]) if row[1] else ""
|
||||
plan_date_str = str(row[2]) if row[2] else str(date.today())
|
||||
mgr_name = str(row[3]).strip() if row[3] else ""
|
||||
status = str(row[4]) if row[4] else "计划中"
|
||||
|
||||
customer_id, _ = await resolve_customer(cust_name, mgr_name)
|
||||
if not customer_id:
|
||||
stats["skipped"] += 1
|
||||
stats["skip_reasons"].append(f"工作计划:客户「{cust_name}」无法解析")
|
||||
continue
|
||||
|
||||
try:
|
||||
plan_date = datetime.strptime(plan_date_str[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
plan_date = date.today()
|
||||
|
||||
plan_manager_id = get_assigned_manager(customer_id)
|
||||
|
||||
work_plan = WorkPlan(
|
||||
customer_id=customer_id,
|
||||
plan_content=plan_content,
|
||||
plan_date=plan_date,
|
||||
manager_id=plan_manager_id,
|
||||
status=status if status in ["计划中", "已完成", "已取消"] else "计划中",
|
||||
)
|
||||
db.add(work_plan)
|
||||
stats["work_plans"] += 1
|
||||
except Exception:
|
||||
stats["skipped"] += 1
|
||||
|
||||
# ── Parse Sheet 3: 小微业务商机 ──
|
||||
if "小微业务商机" in wb.sheetnames:
|
||||
ws3 = wb["小微业务商机"]
|
||||
for row in ws3.iter_rows(min_row=2, values_only=True):
|
||||
if not row[0]:
|
||||
continue
|
||||
try:
|
||||
cust_name = str(row[0]).strip() if row[0] else ""
|
||||
product_type = str(row[1]) if row[1] else ""
|
||||
amount = str(row[2]) if row[2] else ""
|
||||
follow_up = str(row[3]) if row[3] else ""
|
||||
status = str(row[4]) if row[4] else "跟进中"
|
||||
mgr_name = str(row[5]).strip() if row[5] else ""
|
||||
expected_revenue = str(row[6]) if row[6] else ""
|
||||
|
||||
customer_id, _ = await resolve_customer(cust_name, mgr_name)
|
||||
if not customer_id:
|
||||
stats["skipped"] += 1
|
||||
stats["skip_reasons"].append(f"商机:客户「{cust_name}」无法解析")
|
||||
continue
|
||||
|
||||
biz_manager_id = get_assigned_manager(customer_id)
|
||||
|
||||
mini = MiniBusiness(
|
||||
customer_id=customer_id,
|
||||
product_type=product_type,
|
||||
amount=amount,
|
||||
follow_up_detail=follow_up,
|
||||
status=status if status in ["跟进中", "已成交", "已流失"] else "跟进中",
|
||||
manager_id=biz_manager_id,
|
||||
expected_revenue_date=expected_revenue,
|
||||
)
|
||||
db.add(mini)
|
||||
stats["mini_business"] += 1
|
||||
except Exception:
|
||||
stats["skipped"] += 1
|
||||
|
||||
# ── Parse Sheet 4: 要客拜访计划 ──
|
||||
if "要客拜访计划" in wb.sheetnames:
|
||||
ws4 = wb["要客拜访计划"]
|
||||
for row in ws4.iter_rows(min_row=2, values_only=True):
|
||||
if not row[0]:
|
||||
continue
|
||||
try:
|
||||
cust_name = str(row[0]).strip() if row[0] else ""
|
||||
urgency = str(row[1]) if row[1] else "一般"
|
||||
description = str(row[2]) if row[2] else ""
|
||||
progress = str(row[3]) if row[3] else "未开始"
|
||||
planned_date = str(row[4]) if row[4] else ""
|
||||
planned_visitor = str(row[5]) if row[5] else ""
|
||||
visit_target = str(row[6]) if row[6] else ""
|
||||
mgr_name = str(row[7]).strip() if row[7] else ""
|
||||
|
||||
customer_id, _ = await resolve_customer(cust_name, mgr_name)
|
||||
if not customer_id:
|
||||
stats["skipped"] += 1
|
||||
stats["skip_reasons"].append(f"要客:客户「{cust_name}」无法解析")
|
||||
continue
|
||||
|
||||
kv_manager_id = get_assigned_manager(customer_id)
|
||||
valid_urgency = urgency if urgency in ["一般", "重要", "紧急"] else "一般"
|
||||
valid_progress = progress if progress in ["未开始", "进行中", "已完成"] else "未开始"
|
||||
|
||||
key_visit = KeyVisit(
|
||||
customer_id=customer_id,
|
||||
urgency_level=valid_urgency,
|
||||
description=description,
|
||||
progress_status=valid_progress,
|
||||
planned_date=planned_date,
|
||||
planned_visitor=planned_visitor,
|
||||
visit_target=visit_target,
|
||||
manager_id=kv_manager_id,
|
||||
)
|
||||
db.add(key_visit)
|
||||
stats["key_visits"] += 1
|
||||
except Exception:
|
||||
stats["skipped"] += 1
|
||||
|
||||
# ── Parse Sheet 5: 今日纪要 ──
|
||||
if "今日纪要" in wb.sheetnames:
|
||||
ws5 = wb["今日纪要"]
|
||||
valid_categories = ["行政事务", "合同整理", "发票处理", "内部会议", "培训学习", "其他"]
|
||||
for row in ws5.iter_rows(min_row=2, values_only=True):
|
||||
if not row[0]:
|
||||
continue
|
||||
try:
|
||||
note_date_str = str(row[0]) if row[0] else str(date.today())
|
||||
category = str(row[1]) if row[1] else "其他"
|
||||
content = str(row[2]) if row[2] else ""
|
||||
time_range = str(row[3]) if row[3] else ""
|
||||
mgr_name = str(row[4]).strip() if row[4] else ""
|
||||
|
||||
# Validate category
|
||||
if category not in valid_categories:
|
||||
category = "其他"
|
||||
|
||||
# Resolve manager by name
|
||||
note_manager_id = manager_id # default to importer
|
||||
if mgr_name:
|
||||
mgr_id = user_map.get(mgr_name)
|
||||
if mgr_id:
|
||||
note_manager_id = mgr_id
|
||||
else:
|
||||
stats["skip_reasons"].append(f"纪要:客户经理「{mgr_name}」不存在,使用导入人")
|
||||
|
||||
try:
|
||||
note_date = datetime.strptime(note_date_str[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
note_date = date.today()
|
||||
|
||||
# Skip duplicates: same manager, same date, same category
|
||||
existing = await db.execute(
|
||||
select(DailyNote).where(
|
||||
DailyNote.note_date == note_date,
|
||||
DailyNote.manager_id == note_manager_id,
|
||||
DailyNote.category == category,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
stats["skipped"] += 1
|
||||
stats["skip_reasons"].append(f"重复:{note_date} {category} 纪要已存在")
|
||||
continue
|
||||
|
||||
daily_note = DailyNote(
|
||||
manager_id=note_manager_id,
|
||||
note_date=note_date,
|
||||
category=category,
|
||||
content=content,
|
||||
time_range=time_range,
|
||||
)
|
||||
db.add(daily_note)
|
||||
stats["daily_notes"] += 1
|
||||
except Exception:
|
||||
stats["skipped"] += 1
|
||||
|
||||
await db.commit()
|
||||
return stats
|
||||
|
||||
|
||||
import io
|
||||
|
||||
Reference in New Issue
Block a user