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:
@@ -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