Merge branch 'main' into develop

# Conflicts:
#	backend/app/api/key_visits.py
#	backend/app/api/mini_business.py
#	backend/app/api/visits.py
#	backend/app/api/work_plans.py
#	backend/app/main.py
#	backend/app/models/__init__.py
#	backend/app/schemas/key_visit.py
#	backend/app/schemas/mini_business.py
#	backend/app/schemas/work_plan.py
#	backend/app/services/light_board.py
#	frontend/src/components/DesktopLayout.vue
#	frontend/src/stores/theme.ts
#	frontend/src/views/desktop/ManagerWorkspace.vue
#	frontend/src/views/desktop/WorkPlans.vue
#	frontend/src/views/mobile/KeyVisitForm.vue
#	frontend/src/views/mobile/LeaveForm.vue
#	frontend/src/views/mobile/PlansList.vue
#	frontend/src/views/mobile/VisitForm.vue
#	frontend/src/views/mobile/WorkPlanForm.vue
This commit is contained in:
2026-08-17 09:31:12 +08:00
46 changed files with 2682 additions and 213 deletions
+4 -1
View File
@@ -51,6 +51,7 @@ async def list_customers(
industry: Optional[str] = Query(None),
service: Optional[str] = Query(None),
manager_id: Optional[str] = Query(None),
customer_type: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(100, ge=1, le=1000),
current_user: dict = Depends(get_current_user),
@@ -61,6 +62,8 @@ async def list_customers(
base_query = select(Customer)
if customer_type:
base_query = base_query.where(Customer.customer_type == customer_type)
if industry:
base_query = base_query.where(Customer.industry.ilike(f"%{industry}%"))
if service:
@@ -90,7 +93,7 @@ async def list_customers(
# Paginate
offset = (page - 1) * page_size
query = base_query.order_by(Customer.name).offset(offset).limit(page_size)
query = base_query.order_by(Customer.customer_type, Customer.name).offset(offset).limit(page_size)
result = await db.execute(query)
items = result.scalars().all()
+6 -1
View File
@@ -31,6 +31,7 @@ async def _enrich(note: DailyNote, db: AsyncSession) -> dict:
async def list_notes(
date_from: Optional[str] = Query(None),
date_to: Optional[str] = Query(None),
search: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
@@ -41,6 +42,8 @@ async def list_notes(
query = query.where(DailyNote.note_date >= parse_date(date_from))
if date_to:
query = query.where(DailyNote.note_date <= parse_date(date_to))
if search:
query = query.where(DailyNote.content.ilike(f"%{search}%"))
query = query.order_by(DailyNote.note_date.desc(), DailyNote.created_at.desc()).limit(100)
result = await db.execute(query)
return [await _enrich(n, db) for n in result.scalars().all()]
@@ -81,7 +84,7 @@ async def create_note(
db: AsyncSession = Depends(get_db),
):
note = DailyNote(
manager_id=uuid.UUID(current_user["user_id"]),
manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]),
note_date=parse_date(data.note_date),
category=data.category,
content=data.content,
@@ -112,6 +115,8 @@ async def update_note(
old_snapshot = {"note_date": str(note.note_date), "category": note.category, "content": note.content, "time_range": note.time_range}
update_data = data.model_dump(exclude_unset=True)
if current_user["role"] not in ("director", "leader"):
update_data.pop("manager_id", None)
if "note_date" in update_data and update_data["note_date"]:
update_data["note_date"] = parse_date(update_data["note_date"])
for k, v in update_data.items():
+27 -2
View File
@@ -2,7 +2,7 @@ import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy import select, or_
from app.database import get_db
from app.middleware.auth import get_current_user, require_any_role
from app.models.key_visit import KeyVisit
@@ -37,6 +37,8 @@ async def _enrich(k: KeyVisit, db: AsyncSession) -> dict:
@router.get("/")
async def list_key_visits(
customer_id: Optional[str] = Query(None),
customer_type: Optional[str] = Query(None),
search: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
@@ -45,11 +47,32 @@ async def list_key_visits(
query = query.where(KeyVisit.manager_id == uuid.UUID(current_user["user_id"]))
if customer_id:
query = query.where(KeyVisit.customer_id == uuid.UUID(customer_id))
if customer_type:
query = query.join(Customer, KeyVisit.customer_id == Customer.id).where(Customer.customer_type == customer_type)
if search:
query = query.outerjoin(Customer, KeyVisit.customer_id == Customer.id).where(
or_(KeyVisit.description.ilike(f"%{search}%"), KeyVisit.planned_visitor.ilike(f"%{search}%"), Customer.name.ilike(f"%{search}%"))
)
query = query.order_by(KeyVisit.planned_date.desc()).limit(200)
result = await db.execute(query)
return [await _enrich(k, db) for k in result.scalars().all()]
@router.get("/{item_id}")
async def get_key_visit(
item_id: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(KeyVisit).where(KeyVisit.id == item_id))
k = result.scalar_one_or_none()
if not k:
raise HTTPException(status_code=404, detail="Not found")
if current_user["role"] == "manager" and str(k.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
return await _enrich(k, db)
@router.post("/")
async def create_key_visit(
data: KeyVisitCreate,
@@ -64,7 +87,7 @@ async def create_key_visit(
planned_date=data.planned_date,
planned_visitor=data.planned_visitor,
visit_target=data.visit_target,
manager_id=uuid.UUID(current_user["user_id"]),
manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]),
)
init_entry(k, current_user["name"])
db.add(k)
@@ -103,6 +126,8 @@ async def update_key_visit(
old_snapshot = {"customer_id": str(k.customer_id), "urgency_level": k.urgency_level, "description": k.description, "progress_status": k.progress_status, "planned_date": k.planned_date, "planned_visitor": k.planned_visitor, "visit_target": k.visit_target}
update_data = data.model_dump(exclude_unset=True)
if current_user["role"] not in ("director", "leader"):
update_data.pop("manager_id", None)
if "manager_id" in update_data and update_data["manager_id"]:
update_data["manager_id"] = uuid.UUID(update_data["manager_id"])
for key, v in update_data.items():
+30 -2
View File
@@ -2,7 +2,7 @@ import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy import select, or_
from app.database import get_db
from app.middleware.auth import get_current_user, require_any_role
from app.models.mini_business import MiniBusiness
@@ -36,6 +36,8 @@ async def _enrich(m: MiniBusiness, db: AsyncSession) -> dict:
@router.get("/")
async def list_mini_business(
customer_id: Optional[str] = Query(None),
customer_type: Optional[str] = Query(None),
search: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
@@ -44,11 +46,32 @@ async def list_mini_business(
query = query.where(MiniBusiness.manager_id == uuid.UUID(current_user["user_id"]))
if customer_id:
query = query.where(MiniBusiness.customer_id == uuid.UUID(customer_id))
if customer_type:
query = query.join(Customer, MiniBusiness.customer_id == Customer.id).where(Customer.customer_type == customer_type)
if search:
query = query.outerjoin(Customer, MiniBusiness.customer_id == Customer.id).where(
or_(MiniBusiness.product_type.ilike(f"%{search}%"), MiniBusiness.follow_up_detail.ilike(f"%{search}%"), Customer.name.ilike(f"%{search}%"))
)
query = query.order_by(MiniBusiness.expected_revenue_date.desc()).limit(200)
result = await db.execute(query)
return [await _enrich(m, db) for m in result.scalars().all()]
@router.get("/{item_id}")
async def get_mini_business(
item_id: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(MiniBusiness).where(MiniBusiness.id == item_id))
m = result.scalar_one_or_none()
if not m:
raise HTTPException(status_code=404, detail="Not found")
if current_user["role"] == "manager" and str(m.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
return await _enrich(m, db)
@router.post("/")
async def create_mini_business(
data: MiniBusinessCreate,
@@ -61,7 +84,7 @@ async def create_mini_business(
amount=data.amount,
follow_up_detail=data.follow_up_detail,
status=data.status,
manager_id=uuid.UUID(current_user["user_id"]),
manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]),
expected_revenue_date=data.expected_revenue_date,
)
init_entry(m, current_user["name"])
@@ -101,10 +124,15 @@ async def update_mini_business(
old_snapshot = {"customer_id": str(m.customer_id), "product_type": m.product_type, "amount": m.amount, "follow_up_detail": m.follow_up_detail, "status": m.status, "expected_revenue_date": m.expected_revenue_date}
update_data = data.model_dump(exclude_unset=True)
if current_user["role"] not in ("director", "leader"):
update_data.pop("manager_id", None)
if "manager_id" in update_data and update_data["manager_id"]:
update_data["manager_id"] = uuid.UUID(update_data["manager_id"])
for k, v in update_data.items():
setattr(m, k, v)
# Auto-clear expected revenue date when status becomes 已流失
if m.status == "已流失" and m.expected_revenue_date:
m.expected_revenue_date = ""
new_snapshot = {"customer_id": str(m.customer_id), "product_type": m.product_type, "amount": m.amount, "follow_up_detail": m.follow_up_detail, "status": m.status, "expected_revenue_date": m.expected_revenue_date}
changes = compute_diff(old_snapshot, new_snapshot)
if changes:
+143
View File
@@ -0,0 +1,143 @@
"""MiniBusinessLog CRUD API — follow-up log entries for mini business opportunities."""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.auth import get_current_user, require_any_role
from app.models.mini_business import MiniBusiness
from app.models.mini_business_log import MiniBusinessLog
from app.models.user import User
from app.schemas.mini_business_log import (
MiniBusinessLogCreate, MiniBusinessLogUpdate, MiniBusinessLogOut,
)
router = APIRouter(prefix="/mini-business", tags=["MiniBusiness Logs"])
async def _get_log(db: AsyncSession, log_id: str) -> MiniBusinessLog:
result = await db.execute(select(MiniBusinessLog).where(MiniBusinessLog.id == log_id))
log = result.scalar_one_or_none()
if not log:
raise HTTPException(status_code=404, detail="跟进记录不存在")
return log
@router.get("/{business_id}/logs")
async def list_logs(
business_id: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""List follow-up logs for a mini business, newest first."""
# Verify business exists and user has access
mb = await db.get(MiniBusiness, business_id)
if not mb:
raise HTTPException(status_code=404, detail="商机不存在")
if current_user["role"] == "manager" and str(mb.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
result = await db.execute(
select(MiniBusinessLog)
.where(MiniBusinessLog.business_id == business_id)
.order_by(MiniBusinessLog.log_date.desc(), MiniBusinessLog.created_at.desc())
)
logs = result.scalars().all()
# Resolve creator names
user_ids = {log.created_by for log in logs}
users = (await db.execute(select(User).where(User.id.in_(user_ids)))).scalars().all()
user_map = {u.id: u.name for u in users}
return [
{
"id": log.id,
"business_id": log.business_id,
"log_date": str(log.log_date),
"method": log.method,
"content": log.content,
"created_by": log.created_by,
"created_by_name": user_map.get(log.created_by, ""),
"created_at": str(log.created_at) if log.created_at else None,
}
for log in logs
]
@router.post("/{business_id}/logs")
async def create_log(
business_id: str,
data: MiniBusinessLogCreate,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
"""Add a follow-up log entry."""
mb = await db.get(MiniBusiness, business_id)
if not mb:
raise HTTPException(status_code=404, detail="商机不存在")
if current_user["role"] == "manager" and str(mb.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
log = MiniBusinessLog(
business_id=uuid.UUID(business_id),
log_date=data.log_date,
method=data.method,
content=data.content,
created_by=uuid.UUID(current_user["user_id"]),
)
db.add(log)
await db.commit()
await db.refresh(log)
creator = await db.get(User, log.created_by)
return {
"id": log.id,
"business_id": log.business_id,
"log_date": str(log.log_date),
"method": log.method,
"content": log.content,
"created_by": log.created_by,
"created_by_name": creator.name if creator else "",
"created_at": str(log.created_at) if log.created_at else None,
}
@router.put("/logs/{log_id}")
async def update_log(
log_id: str,
data: MiniBusinessLogUpdate,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
"""Update a follow-up log entry."""
log = await _get_log(db, log_id)
# Managers can only edit their own logs; directors/leaders can edit any
if current_user["role"] == "manager" and str(log.created_by) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
update_data = data.model_dump(exclude_unset=True)
for k, v in update_data.items():
setattr(log, k, v)
await db.commit()
await db.refresh(log)
return {"id": log.id, "log_date": str(log.log_date), "method": log.method, "content": log.content}
@router.delete("/logs/{log_id}")
async def delete_log(
log_id: str,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
"""Delete a follow-up log entry."""
log = await _get_log(db, log_id)
if current_user["role"] == "manager" and str(log.created_by) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
await db.delete(log)
await db.commit()
return {"status": "deleted"}
+53 -12
View File
@@ -3,7 +3,7 @@ from datetime import date, datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy import select, func, or_
from app.database import get_db
from app.middleware.auth import get_current_user, require_any_role
from app.models.visit import Visit
@@ -74,6 +74,8 @@ async def list_visits(
date_from: Optional[str] = Query(None),
date_to: Optional[str] = Query(None),
customer_id: Optional[str] = Query(None),
customer_type: Optional[str] = Query(None),
search: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
@@ -89,6 +91,12 @@ async def list_visits(
query = query.where(Visit.visit_date <= parse_date(date_to))
if customer_id:
query = query.where(Visit.customer_id == uuid.UUID(customer_id))
if customer_type:
query = query.join(Customer, Visit.customer_id == Customer.id).where(Customer.customer_type == customer_type)
if search:
query = query.outerjoin(Customer, Visit.customer_id == Customer.id).where(
or_(Visit.communication_content.ilike(f"%{search}%"), Visit.customer_demand.ilike(f"%{search}%"), Visit.visitor_name.ilike(f"%{search}%"), Customer.name.ilike(f"%{search}%"))
)
query = query.order_by(Visit.visit_date.desc(), Visit.created_at.desc()).limit(200)
result = await db.execute(query)
@@ -161,7 +169,7 @@ async def create_visit(
companions=data.companions,
companion_names=data.companion_names,
photos=data.photos,
manager_id=uuid.UUID(current_user["user_id"]),
manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]),
visit_group_id=group_id,
)
init_entry(visit, current_user["name"])
@@ -176,18 +184,22 @@ async def create_visit(
db.add(customer)
# Auto-complete matching work plans for this customer
from app.models.work_plan import WorkPlan
plans_result = await db.execute(
select(WorkPlan).where(
WorkPlan.customer_id == data.customer_id,
WorkPlan.status == "计划中",
WorkPlan.plan_date <= parse_date(data.visit_date),
from app.services.visits import auto_complete_work_plans
await auto_complete_work_plans(
db, data.customer_id, parse_date(data.visit_date),
current_user["name"], "拜访自动完成",
)
# Auto-create follow-up logs for active mini business opportunities
from app.models.mini_business import MiniBusiness
from app.models.mini_business_log import MiniBusinessLog
method_map = {"上门": "上门", "电话": "电话", "微信": "微信", "出差": "其他"}
active_biz = await db.execute(
select(MiniBusiness).where(
MiniBusiness.customer_id == data.customer_id,
MiniBusiness.status == "跟进中",
)
)
for plan in plans_result.scalars().all():
plan.status = "已完成"
append_entry(plan, current_user["name"], [{"field": "status", "from": "计划中", "to": "已完成", "reason": "拜访自动完成"}])
# Create full copies for companions (not blank drafts)
creator_name = current_user["name"]
for companion_id in data.companions:
@@ -215,6 +227,24 @@ async def create_visit(
init_entry(draft, current_user["name"])
db.add(draft)
# Auto-create follow-up logs for active mini business opportunities
for biz in active_biz.scalars().all():
parts = []
if data.communication_content and data.communication_content.strip():
parts.append(f"沟通内容:{data.communication_content.strip()}")
if data.customer_demand and data.customer_demand.strip():
parts.append(f"客户需求:{data.customer_demand.strip()}")
log_content = "\n".join(parts) if parts else "拜访跟进"
follow_method = method_map.get(data.visit_method, "其他")
log = MiniBusinessLog(
business_id=biz.id,
log_date=parse_date(data.visit_date),
method=follow_method,
content=log_content,
created_by=uuid.UUID(current_user["user_id"]),
)
db.add(log)
# Audit log (before commit — part of same transaction)
cust = await db.execute(select(Customer.name).where(Customer.id == visit.customer_id))
await log_audit(db, "visit", visit.id, f"{cust.scalar_one_or_none() or ''} ({data.visit_date})", "create", current_user["user_id"], current_user["name"])
@@ -251,6 +281,8 @@ async def update_visit(
}
update_data = data.model_dump(exclude_unset=True)
if current_user["role"] not in ("director", "leader"):
update_data.pop("manager_id", None)
if "visit_date" in update_data and update_data["visit_date"]:
update_data["visit_date"] = parse_date(update_data["visit_date"])
if "manager_id" in update_data and update_data["manager_id"]:
@@ -289,6 +321,15 @@ async def update_visit(
await log_audit(db, "visit", visit.id, f"{cust.scalar_one_or_none() or ''} ({visit.visit_date})", "update", current_user["user_id"], current_user["name"], ", ".join(changes.keys()) if changes else "")
await db.commit()
await db.refresh(visit)
# Auto-complete matching work plans after visit update
from app.services.visits import auto_complete_work_plans
await auto_complete_work_plans(
db, visit.customer_id, visit.visit_date,
current_user["name"], "拜访更新自动完成",
)
await db.commit()
return await _enrich_visit(visit, db)
+28 -46
View File
@@ -3,12 +3,11 @@ from datetime import date
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy import select, or_
from app.database import get_db
from app.middleware.auth import get_current_user, require_any_role
from app.utils.timezone import parse_date
from app.utils.edit_log import compute_diff, append_entry, init_entry
from app.utils.audit import log_audit
from app.models.work_plan import WorkPlan
from app.models.customer import Customer
from app.models.user import User
@@ -36,6 +35,8 @@ async def _enrich(wp: WorkPlan, db: AsyncSession) -> dict:
@router.get("/")
async def list_work_plans(
customer_id: Optional[str] = Query(None),
customer_type: Optional[str] = Query(None),
search: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
@@ -44,59 +45,49 @@ async def list_work_plans(
query = query.where(WorkPlan.manager_id == uuid.UUID(current_user["user_id"]))
if customer_id:
query = query.where(WorkPlan.customer_id == uuid.UUID(customer_id))
if customer_type:
query = query.join(Customer, WorkPlan.customer_id == Customer.id).where(Customer.customer_type == customer_type)
if search:
query = query.outerjoin(Customer, WorkPlan.customer_id == Customer.id).where(
or_(WorkPlan.plan_content.ilike(f"%{search}%"), Customer.name.ilike(f"%{search}%"))
)
query = query.order_by(WorkPlan.plan_date.desc()).limit(200)
result = await db.execute(query)
return [await _enrich(w, db) for w in result.scalars().all()]
@router.get("/{item_id}")
async def get_work_plan(
item_id: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(WorkPlan).where(WorkPlan.id == item_id))
wp = result.scalar_one_or_none()
if not wp:
raise HTTPException(status_code=404, detail="Not found")
if current_user["role"] == "manager" and str(wp.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
return await _enrich(wp, db)
@router.post("/")
async def create_work_plan(
data: WorkPlanCreate,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
plan_date = parse_date(data.plan_date)
# Auto-complete if a visit already exists for this customer on/after the plan date
from app.models.visit import Visit
if data.status == "计划中":
existing_visit = await db.execute(
select(Visit.id).where(
Visit.customer_id == data.customer_id,
Visit.visit_date >= plan_date,
).limit(1)
)
if existing_visit.scalar_one_or_none():
data.status = "已完成"
wp = WorkPlan(
customer_id=data.customer_id,
plan_content=data.plan_content,
plan_date=plan_date,
manager_id=uuid.UUID(current_user["user_id"]),
plan_date=parse_date(data.plan_date),
manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]),
status=data.status,
)
init_entry(wp, current_user["name"])
if data.status == "已完成":
append_entry(wp, current_user["name"], [{"field": "status", "from": "计划中", "to": "已完成", "reason": "已有拜访记录,自动完成"}])
db.add(wp)
await db.commit()
await db.refresh(wp)
# Audit log
cust = await db.execute(select(Customer.name).where(Customer.id == wp.customer_id))
await log_audit(db, "work_plan", wp.id, f"{cust.scalar_one_or_none() or ''} ({wp.plan_date})", "create", current_user["user_id"], current_user["name"])
await db.commit()
return await _enrich(wp, db)
@router.get("/{plan_id}")
async def get_work_plan(plan_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user)):
result = await db.execute(select(WorkPlan).where(WorkPlan.id == plan_id))
wp = result.scalar_one_or_none()
if not wp:
raise HTTPException(status_code=404, detail="Not found")
if current_user["role"] == "manager" and str(wp.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
return await _enrich(wp, db)
@@ -115,10 +106,10 @@ async def update_work_plan(
old_snapshot = {"customer_id": str(wp.customer_id), "plan_content": wp.plan_content, "plan_date": str(wp.plan_date), "status": wp.status}
update_data = data.model_dump(exclude_unset=True)
if current_user["role"] not in ("director", "leader"):
update_data.pop("manager_id", None)
if "plan_date" in update_data and update_data["plan_date"]:
update_data["plan_date"] = parse_date(update_data["plan_date"])
if "manager_id" in update_data and update_data["manager_id"]:
update_data["manager_id"] = uuid.UUID(update_data["manager_id"])
for k, v in update_data.items():
setattr(wp, k, v)
new_snapshot = {"customer_id": str(wp.customer_id), "plan_content": wp.plan_content, "plan_date": str(wp.plan_date), "status": wp.status}
@@ -127,10 +118,6 @@ async def update_work_plan(
append_entry(wp, current_user["name"], changes, getattr(data, "edit_reason", None))
await db.commit()
await db.refresh(wp)
# Audit log
cust = await db.execute(select(Customer.name).where(Customer.id == wp.customer_id))
await log_audit(db, "work_plan", wp.id, f"{cust.scalar_one_or_none() or ''} ({wp.plan_date})", "update", current_user["user_id"], current_user["name"])
await db.commit()
return await _enrich(wp, db)
@@ -146,11 +133,6 @@ async def delete_work_plan(
raise HTTPException(status_code=404, detail="Not found")
if current_user["role"] == "manager" and str(wp.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
cust = await db.execute(select(Customer.name).where(Customer.id == wp.customer_id))
entity_name = f"{cust.scalar_one_or_none() or ''} ({wp.plan_date})"
await db.delete(wp)
await db.commit()
# Audit log
await log_audit(db, "work_plan", uuid.UUID(plan_id), entity_name, "delete", current_user["user_id"], current_user["name"])
await db.commit()
return {"detail": "deleted"}
+2 -1
View File
@@ -5,7 +5,7 @@ from sqlalchemy import select
from app.config import settings, validate_security_settings
from app.database import engine, async_session
from app.api import router as api_router
from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits
from app.api import auth, users, customers, visits, work_plans, mini_business, mini_business_logs, key_visits
from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary, system_config, leaves, audit
from app.models.system_config import SystemConfig
from app.services.holidays import refresh_holidays
@@ -63,6 +63,7 @@ app.include_router(customers.router, prefix="/api")
app.include_router(visits.router, prefix="/api")
app.include_router(work_plans.router, prefix="/api")
app.include_router(mini_business.router, prefix="/api")
app.include_router(mini_business_logs.router, prefix="/api")
app.include_router(key_visits.router, prefix="/api")
app.include_router(dashboard.router, prefix="/api")
app.include_router(leaves.router, prefix="/api")
+2
View File
@@ -11,6 +11,7 @@ from app.models.ai_summary import AISummary
from app.models.system_config import SystemConfig
from app.models.leave import Leave
from app.models.wecom_bind_token import WecomBindToken
from app.models.mini_business_log import MiniBusinessLog
__all__ = [
"User",
@@ -20,6 +21,7 @@ __all__ = [
"Visit",
"WorkPlan",
"MiniBusiness",
"MiniBusinessLog",
"KeyVisit",
"DailyNote",
"AISummary",
+1
View File
@@ -11,6 +11,7 @@ class Customer(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200), index=True)
customer_type: Mapped[str] = mapped_column(String(20), default="unit")
industry: Mapped[str] = mapped_column(String(100), default="")
address: Mapped[str] = mapped_column(String(500), default="")
in_use_services: Mapped[str] = mapped_column(Text, default="")
+7 -1
View File
@@ -1,9 +1,13 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import String, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
if TYPE_CHECKING:
from app.models.mini_business_log import MiniBusinessLog
class MiniBusiness(Base):
__tablename__ = "mini_business"
@@ -17,3 +21,5 @@ class MiniBusiness(Base):
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
expected_revenue_date: Mapped[str] = mapped_column(String(50), default="")
edit_log: Mapped[list] = mapped_column(JSONB, default=list)
logs: Mapped[list["MiniBusinessLog"]] = relationship(back_populates="business", cascade="all, delete-orphan")
+30
View File
@@ -0,0 +1,30 @@
"""MiniBusinessLog — follow-up log entries for mini business opportunities."""
import uuid
from datetime import date, datetime
from sqlalchemy import String, Date, DateTime, ForeignKey, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class MiniBusinessLog(Base):
__tablename__ = "mini_business_logs"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
business_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("mini_business.id", ondelete="CASCADE"), index=True
)
log_date: Mapped[date] = mapped_column(Date)
method: Mapped[str] = mapped_column(String(20), default="电话")
content: Mapped[str] = mapped_column(Text, default="")
created_by: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id")
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
business: Mapped["MiniBusiness"] = relationship(back_populates="logs")
+4
View File
@@ -45,6 +45,7 @@ class ContactOut(BaseModel):
# ── Customer ──
class CustomerCreate(BaseModel):
name: str
customer_type: str = "unit"
industry: str = ""
address: str = ""
in_use_services: str = ""
@@ -56,6 +57,7 @@ class CustomerCreate(BaseModel):
class CustomerUpdate(BaseModel):
name: Optional[str] = None
customer_type: Optional[str] = None
industry: Optional[str] = None
address: Optional[str] = None
in_use_services: Optional[str] = None
@@ -67,6 +69,7 @@ class CustomerUpdate(BaseModel):
class CustomerOut(BaseModel):
id: uuid.UUID
name: str
customer_type: str = "unit"
industry: str
address: str
in_use_services: str
@@ -84,6 +87,7 @@ class CustomerOut(BaseModel):
class CustomerListOut(BaseModel):
id: uuid.UUID
name: str
customer_type: str = "unit"
industry: str
in_use_services: str
primary_manager_name: Optional[str] = None
+2
View File
@@ -9,6 +9,7 @@ class DailyNoteCreate(BaseModel):
category: str = "其他"
content: str = ""
time_range: str = ""
manager_id: Optional[uuid.UUID] = None
class DailyNoteUpdate(BaseModel):
@@ -16,6 +17,7 @@ class DailyNoteUpdate(BaseModel):
category: Optional[str] = None
content: Optional[str] = None
time_range: Optional[str] = None
manager_id: Optional[uuid.UUID] = None
class DailyNoteOut(BaseModel):
+2 -1
View File
@@ -11,6 +11,7 @@ class KeyVisitCreate(BaseModel):
planned_date: str = ""
planned_visitor: str = ""
visit_target: str = ""
manager_id: Optional[uuid.UUID] = None
class KeyVisitUpdate(BaseModel):
@@ -21,7 +22,7 @@ class KeyVisitUpdate(BaseModel):
planned_date: Optional[str] = None
planned_visitor: Optional[str] = None
visit_target: Optional[str] = None
manager_id: Optional[str] = None
manager_id: Optional[uuid.UUID] = None
class KeyVisitOut(BaseModel):
+2 -1
View File
@@ -10,6 +10,7 @@ class MiniBusinessCreate(BaseModel):
follow_up_detail: str = ""
status: str = "跟进中"
expected_revenue_date: str = ""
manager_id: Optional[uuid.UUID] = None
class MiniBusinessUpdate(BaseModel):
@@ -19,7 +20,7 @@ class MiniBusinessUpdate(BaseModel):
follow_up_detail: Optional[str] = None
status: Optional[str] = None
expected_revenue_date: Optional[str] = None
manager_id: Optional[str] = None
manager_id: Optional[uuid.UUID] = None
class MiniBusinessOut(BaseModel):
+33
View File
@@ -0,0 +1,33 @@
"""MiniBusinessLog Pydantic schemas."""
from datetime import date, datetime
from uuid import UUID
from typing import Optional
from pydantic import BaseModel, Field
FOLLOW_UP_METHODS = ["电话", "微信", "上门", "邮件", "其他"]
class MiniBusinessLogCreate(BaseModel):
log_date: date
method: str = Field(default="电话", pattern="^(电话|微信|上门|邮件|其他)$")
content: str = ""
class MiniBusinessLogUpdate(BaseModel):
log_date: Optional[date] = None
method: Optional[str] = Field(default=None, pattern="^(电话|微信|上门|邮件|其他)$")
content: Optional[str] = None
class MiniBusinessLogOut(BaseModel):
id: UUID
business_id: UUID
log_date: date
method: str
content: str
created_by: UUID
created_by_name: str = ""
created_at: datetime | None = None
model_config = {"from_attributes": True}
+2
View File
@@ -16,6 +16,7 @@ class VisitCreate(BaseModel):
companions: list[uuid.UUID] = []
companion_names: list[str] = []
photos: list[str] = []
manager_id: Optional[uuid.UUID] = None # director/leader can specify
class VisitUpdate(BaseModel):
@@ -28,6 +29,7 @@ class VisitUpdate(BaseModel):
communication_content: Optional[str] = None
customer_demand: Optional[str] = None
companions: Optional[list[uuid.UUID]] = None
manager_id: Optional[uuid.UUID] = None
companion_names: Optional[list[str]] = None
photos: Optional[list[str]] = None
manager_id: Optional[str] = None
+2 -1
View File
@@ -9,6 +9,7 @@ class WorkPlanCreate(BaseModel):
plan_content: str = ""
plan_date: str # "YYYY-MM-DD"
status: str = "计划中"
manager_id: Optional[uuid.UUID] = None
class WorkPlanUpdate(BaseModel):
@@ -16,7 +17,7 @@ class WorkPlanUpdate(BaseModel):
plan_content: Optional[str] = None
plan_date: Optional[str] = None
status: Optional[str] = None
manager_id: Optional[str] = None
manager_id: Optional[uuid.UUID] = None
class WorkPlanOut(BaseModel):
+13 -5
View File
@@ -132,10 +132,14 @@ async def get_reporting_progress(db: AsyncSession, reference_date: date | None =
} if leave else None,
})
# Check today's reporting — visits OR daily notes
today_visits = await db.execute(
select(Visit.manager_id).where(Visit.visit_date == today)
# Check today's reporting — visits (direct + companions) OR daily notes
from sqlalchemy import union_all
today_direct = select(Visit.manager_id).where(Visit.visit_date == today)
today_companion = select(func.unnest(Visit.companions).label("manager_id")).where(
Visit.visit_date == today, Visit.companions.isnot(None)
)
today_combined = union_all(today_direct, today_companion).subquery()
today_visits = await db.execute(select(today_combined.c.manager_id))
today_notes = await db.execute(
select(DailyNote.manager_id).where(DailyNote.note_date == today)
)
@@ -173,9 +177,13 @@ async def get_weekly_report(
# ── Visits ──
visit_query = select(Visit).where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
if role == "manager":
visit_query = visit_query.where(Visit.manager_id == user_id)
visit_query = visit_query.where(
(Visit.manager_id == user_id) | (Visit.companions.any(user_id))
)
elif filter_manager_id:
visit_query = visit_query.where(Visit.manager_id == filter_manager_id)
visit_query = visit_query.where(
(Visit.manager_id == filter_manager_id) | (Visit.companions.any(filter_manager_id))
)
if filter_customer_id:
visit_query = visit_query.where(Visit.customer_id == filter_customer_id)
visit_query = visit_query.order_by(Visit.visit_date.desc())
+7
View File
@@ -203,6 +203,13 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui
db.add(visit)
stats["visits"] += 1
# Auto-complete matching work plans
from app.services.visits import auto_complete_work_plans
await auto_complete_work_plans(
db, customer_id, visit_date,
mgr_name, "旧周报导入自动完成",
)
# 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):
+24 -19
View File
@@ -9,7 +9,7 @@ Status (rolling 30-day window):
from datetime import date, timedelta
from uuid import UUID
from sqlalchemy import select, and_
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.models.customer import Customer
@@ -85,29 +85,34 @@ async def get_light_board(
for m in visible_managers
}
# ── All customers with their last visit per manager ──
# Subquery: latest visit (date + method) per (manager_id, customer_id).
# DISTINCT ON keeps exactly one row per pair even when multiple visits
# share the same latest date (e.g. companion/collaborative visits),
# preventing fan-out that would duplicate customer cards on the board.
# ── All customers with their last visit per manager (including companions) ──
# UNION direct + companions (unnested), then DISTINCT ON to get the latest per (manager, customer)
from sqlalchemy import union_all, distinct
direct = select(
Visit.manager_id, Visit.customer_id, Visit.visit_date, Visit.visit_method,
)
companion = select(
func.unnest(Visit.companions).label("manager_id"),
Visit.customer_id,
Visit.visit_date,
Visit.visit_method,
).where(Visit.companions.isnot(None))
combined = union_all(direct, companion).subquery()
# Get latest visit (date + method) per (manager_id, customer_id) using DISTINCT ON
latest_visit = (
select(
Visit.manager_id,
Visit.customer_id,
Visit.visit_date.label("last_visit_date"),
Visit.visit_method.label("last_visit_method"),
)
.distinct(Visit.manager_id, Visit.customer_id)
.order_by(
Visit.manager_id,
Visit.customer_id,
Visit.visit_date.desc(),
Visit.id.desc(),
combined.c.manager_id,
combined.c.customer_id,
combined.c.visit_date.label("last_visit_date"),
combined.c.visit_method,
)
.distinct(combined.c.manager_id, combined.c.customer_id)
.order_by(combined.c.manager_id, combined.c.customer_id, combined.c.visit_date.desc())
.subquery()
)
# Join: assignments → customers → latest_visit (date + method)
# Join: assignments → customers → latest_visit
rows = await db.execute(
select(
CustomerAssignment.manager_id,
@@ -118,7 +123,7 @@ async def get_light_board(
Customer.in_use_services,
Customer.monthly_fee,
latest_visit.c.last_visit_date,
latest_visit.c.last_visit_method,
latest_visit.c.visit_method,
)
.join(Customer, Customer.id == CustomerAssignment.customer_id)
.outerjoin(latest_visit, and_(
+24
View File
@@ -168,9 +168,33 @@ async def check_overdue_plans(db: AsyncSession) -> dict:
await wecom_client.send_text_message([user.wecom_userid], content)
# ── Auto-cancel overdue plans that have no matching visit ──
from app.utils.edit_log import append_entry as append_edit_log
auto_cancelled = 0
for plan in overdue:
has_visit = await db.execute(
select(Visit).where(
Visit.customer_id == plan.customer_id,
Visit.visit_date >= plan.plan_date,
)
)
if not has_visit.scalar():
plan.status = "已取消"
append_edit_log(plan, "系统", [{
"field": "status",
"from": "计划中",
"to": "已取消",
"reason": "逾期自动取消",
}])
auto_cancelled += 1
if auto_cancelled:
await db.commit()
return {
"status": "ok",
"date": str(today),
"overdue": len(overdue),
"auto_cancelled": auto_cancelled,
"managers_affected": len(by_manager),
}
+40
View File
@@ -0,0 +1,40 @@
"""Visit service — shared logic for visit creation, update, and import."""
from uuid import UUID
from datetime import date
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.work_plan import WorkPlan
from app.utils.edit_log import append_entry
async def auto_complete_work_plans(
db: AsyncSession,
customer_id: UUID,
visit_date: date,
editor_name: str,
reason: str = "拜访自动完成",
) -> int:
"""Auto-complete matching work plans when a visit is created/updated/imported.
Matches by: same customer_id + status=="计划中" + plan_date <= visit_date.
Returns the number of plans completed.
"""
plans_result = await db.execute(
select(WorkPlan).where(
WorkPlan.customer_id == customer_id,
WorkPlan.status == "计划中",
WorkPlan.plan_date <= visit_date,
)
)
count = 0
for plan in plans_result.scalars().all():
plan.status = "已完成"
append_entry(plan, editor_name, [{
"field": "status",
"from": "计划中",
"to": "已完成",
"reason": reason,
}])
count += 1
return count