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:
2026-06-29 22:25:04 +08:00
parent b343970ecc
commit 3285e22142
23 changed files with 1629 additions and 172 deletions
+202 -17
View File
@@ -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(
+9 -2
View File
@@ -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)
+38
View File
@@ -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"}
+4
View File
@@ -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")
+1
View File
@@ -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)
+3
View File
@@ -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
+25 -2
View File
@@ -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)
+327 -19
View File
@@ -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