From 6504a393e3f2b6bb9a7cd0f1f38664ee76aca59e Mon Sep 17 00:00:00 2001 From: v6ole Date: Sun, 12 Jul 2026 22:14:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=AA=E4=BA=BA=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=81=9A=E5=90=88=E8=A7=86=E5=9B=BE=20=E2=80=94=20=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AE=A1=E7=90=86=E5=8F=8CTab+=E5=9B=9B=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E6=95=B0=E6=8D=AE=E8=81=9A=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - customers 表新增 customer_type 列 (unit/individual) - 客户管理页:单位客户 | 个人用户 Tab 切换 - 个人用户 Tab: 直接内嵌4 Tab 显示所有个人用户的聚合数据 - 个人用户通过业务模块快速新建产生 (customer_type=individual) - 4个业务API新增 customer_type 筛选参数(JOIN customers) - 客户列表排序: 个人用户置顶 Co-Authored-By: Claude --- backend/app/api/customers.py | 5 +- backend/app/api/key_visits.py | 3 + backend/app/api/mini_business.py | 3 + backend/app/api/visits.py | 3 + backend/app/api/work_plans.py | 3 + backend/app/main.py | 3 + backend/app/models/customer.py | 1 + backend/app/schemas/customer.py | 4 + ...2026-07-12-work-plan-auto-status-design.md | 122 ++++++++++++++++++ frontend/src/components.d.ts | 2 + frontend/src/views/desktop/CustomerManage.vue | 90 +++++++++++++ 11 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-07-12-work-plan-auto-status-design.md diff --git a/backend/app/api/customers.py b/backend/app/api/customers.py index d516300..9cca076 100644 --- a/backend/app/api/customers.py +++ b/backend/app/api/customers.py @@ -50,6 +50,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), @@ -60,6 +61,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: @@ -89,7 +92,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() diff --git a/backend/app/api/key_visits.py b/backend/app/api/key_visits.py index 2f18bc4..388ebf0 100644 --- a/backend/app/api/key_visits.py +++ b/backend/app/api/key_visits.py @@ -36,6 +36,7 @@ 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), current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): @@ -44,6 +45,8 @@ 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) 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()] diff --git a/backend/app/api/mini_business.py b/backend/app/api/mini_business.py index 8435030..aec4a49 100644 --- a/backend/app/api/mini_business.py +++ b/backend/app/api/mini_business.py @@ -35,6 +35,7 @@ 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), current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): @@ -43,6 +44,8 @@ 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) 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()] diff --git a/backend/app/api/visits.py b/backend/app/api/visits.py index fe35321..48f89a1 100644 --- a/backend/app/api/visits.py +++ b/backend/app/api/visits.py @@ -62,6 +62,7 @@ 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), current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): @@ -77,6 +78,8 @@ 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) query = query.order_by(Visit.visit_date.desc(), Visit.created_at.desc()).limit(200) result = await db.execute(query) diff --git a/backend/app/api/work_plans.py b/backend/app/api/work_plans.py index 7897736..c7bcd65 100644 --- a/backend/app/api/work_plans.py +++ b/backend/app/api/work_plans.py @@ -35,6 +35,7 @@ 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), current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): @@ -43,6 +44,8 @@ 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) 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()] diff --git a/backend/app/main.py b/backend/app/main.py index 028c2ef..7b07ab9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -42,6 +42,9 @@ 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" )) + await conn.run_sync(lambda c: c.exec_driver_sql( + "ALTER TABLE customers ADD COLUMN IF NOT EXISTS customer_type VARCHAR(20) DEFAULT 'unit'" + )) # 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 '{}'" diff --git a/backend/app/models/customer.py b/backend/app/models/customer.py index 1e6a654..990b6f6 100644 --- a/backend/app/models/customer.py +++ b/backend/app/models/customer.py @@ -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="") diff --git a/backend/app/schemas/customer.py b/backend/app/schemas/customer.py index d779751..a5f8e72 100644 --- a/backend/app/schemas/customer.py +++ b/backend/app/schemas/customer.py @@ -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 diff --git a/docs/superpowers/specs/2026-07-12-work-plan-auto-status-design.md b/docs/superpowers/specs/2026-07-12-work-plan-auto-status-design.md new file mode 100644 index 0000000..6af49ed --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-work-plan-auto-status-design.md @@ -0,0 +1,122 @@ +# 工作计划状态自动流转 — 设计文档 + +> 日期:2026-07-12 | 状态:已确认 + +## 一、需求概述 + +工作计划目前有三种状态(计划中/已完成/已取消),完全依赖手动切换。利用已有的拜访记录数据,实现状态的自动流转,减少客户经理手动操作。 + +## 二、核心决策 + +| 决策项 | 结论 | +|--------|------| +| 匹配精度 | 仅匹配 `customer_id`(任何人拜访该客户即触发,与现有逻辑一致) | +| 增强范围 | 创建/更新触发完成 + 逾期自动取消 + Excel 导入联动 | +| 删除回退 | 不做(边缘场景少,复杂度高) | + +## 三、现状 + +- `POST /visits/` 已有自动完成逻辑:同一 `customer_id` + `plan_date <= visit_date` → 标记"已完成" +- 该逻辑嵌在 API 层,不可复用 +- 更新拜访、Excel 导入均不触发自动完成 +- 逾期计划(`plan_date < today`)只发企微提醒,不自动取消 + +## 四、设计 + +### 4.1 抽取可复用函数 + +**文件:** `backend/app/services/visits.py`(如不存在则新建,如已有则追加) + +```python +async def auto_complete_work_plans( + db: AsyncSession, + customer_id: UUID, + visit_date: date, + editor_name: str, + reason: str = "拜访自动完成", +) -> int: + """将匹配的工作计划自动标记为已完成。返回完成数量。""" + from app.models.work_plan import WorkPlan + from app.utils.edit_log import append_entry as append_edit_log + + 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_edit_log(plan, editor_name, [{ + "field": "status", "from": "计划中", "to": "已完成", + "reason": reason, + }]) + count += 1 + return count +``` + +### 4.2 三处调用点 + +| 调用点 | 文件 | 触发时机 | reason 参数 | +|--------|------|---------|------------| +| POST /visits/ | `api/visits.py` | 创建拜访后 | `"拜访自动完成"` | +| PUT /visits/{id} | `api/visits.py` | 更新拜访后 | `"拜访更新自动完成"` | +| excel_import.py | `services/excel_import.py` | 导入每条拜访后 | `"旧周报导入自动完成"` | + +### 4.3 逾期计划自动取消 + +**文件:** `backend/app/services/scheduler.py`,`check_overdue_plans` 函数 + +在现有「发送企微提醒」逻辑后新增: + +```python +# 自动取消:逾期且无匹配拜访记录的计划 +for plan in overdue: + has_visit = await db.execute( + select(Visit.id).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": "逾期自动取消", + }]) +``` + +每日 09:00 执行,与现有逾期检查共用一个调度任务。 + +### 4.4 状态流转总图 + +``` +计划中 ──┬── 拜访创建/更新/导入(customer_id + plan_date <= visit_date)──→ 已完成 + │ + └── 每日 09:00 调度(plan_date < today 且无匹配拜访)───────────→ 已取消 +``` + +## 五、文件变更 + +### 后端新增/修改 + +| 文件 | 变更 | +|------|------| +| `backend/app/services/visits.py` | 新增 `auto_complete_work_plans()` 函数 | +| `backend/app/api/visits.py` | POST 重构为调用 service 函数;PUT 新增调用 | +| `backend/app/services/excel_import.py` | 导入拜访后调用自动完成 | +| `backend/app/services/scheduler.py` | `check_overdue_plans` 新增自动取消逻辑 | + +### 前端 + +无需改动。 + +## 六、验证要点 + +1. 创建拜访 → 匹配的计划自动变为"已完成",edit_log 有记录 +2. 更新拜访日期 → 匹配的计划自动完成 +3. 导入旧周报 → 导入的拜访触发计划自动完成 +4. 逾期且无拜访的计划 → 每日 09:00 自动变为"已取消" +5. 逾期但有拜访的计划 → 保持"计划中"(已有拜访覆盖,不算僵尸) diff --git a/frontend/src/components.d.ts b/frontend/src/components.d.ts index e77789a..faa1991 100644 --- a/frontend/src/components.d.ts +++ b/frontend/src/components.d.ts @@ -31,6 +31,8 @@ declare module 'vue' { ElOption: typeof import('element-plus/es')['ElOption'] ElPagination: typeof import('element-plus/es')['ElPagination'] ElProgress: typeof import('element-plus/es')['ElProgress'] + ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] + ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElRow: typeof import('element-plus/es')['ElRow'] ElSelect: typeof import('element-plus/es')['ElSelect'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] diff --git a/frontend/src/views/desktop/CustomerManage.vue b/frontend/src/views/desktop/CustomerManage.vue index 3b4cfd0..549dacd 100644 --- a/frontend/src/views/desktop/CustomerManage.vue +++ b/frontend/src/views/desktop/CustomerManage.vue @@ -15,7 +15,16 @@ const search = ref('') const filterIndustry = ref('') const filterService = ref('') const filterManagerId = ref('') +const customerType = ref('unit') const customers = ref([]) + +// Individual view tab data +const indivTab = ref('visits') +const indivVisits = ref([]) +const indivPlans = ref([]) +const indivMini = ref([]) +const indivKeyVisits = ref([]) +const indivLoading = ref(false) const currentPage = ref(1) const pageSize = ref(25) const total = ref(0) @@ -79,6 +88,32 @@ function onPageChange(page: number) { currentPage.value = page; loadCustomers() function onPageSizeChange(size: number) { pageSize.value = size; currentPage.value = 1; loadCustomers() } function onFilterChange() { currentPage.value = 1; loadCustomers() } +function switchCustomerType(type: string) { + customerType.value = type + if (type === 'unit') { + onFilterChange() + } else { + loadIndividualData() + } +} + +async function loadIndividualData() { + indivLoading.value = true + try { + const [visits, plans, mini, keyVisits] = await Promise.all([ + api.get('/visits/', { params: { customer_type: 'individual' } }), + api.get('/work-plans/', { params: { customer_type: 'individual' } }), + api.get('/mini-business/', { params: { customer_type: 'individual' } }), + api.get('/key-visits/', { params: { customer_type: 'individual' } }), + ]) + indivVisits.value = Array.isArray(visits.data) ? visits.data : (visits.data.items || []) + indivPlans.value = Array.isArray(plans.data) ? plans.data : (plans.data.items || []) + indivMini.value = Array.isArray(mini.data) ? mini.data : (mini.data.items || []) + indivKeyVisits.value = Array.isArray(keyVisits.data) ? keyVisits.data : (keyVisits.data.items || []) + } catch (_) {} + finally { indivLoading.value = false } +} + function buildMonthlyFee(): string { const amt = form.value.fee_amount.trim() if (!amt) return '' @@ -329,6 +364,60 @@ async function handleImport() {
+ +
+ + 单位客户 + 个人用户 + +
+ + +
+ + + + + + + + +
暂无拜访记录
+
+ + + + + + + +
暂无工作计划
+
+ + + + + + + + +
暂无商机记录
+
+ + + + + + + +
暂不要客记录
+
+
+
+ + + +